Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
fecd3a6c16 Version Packages (#6205)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-07 20:07:21 -08:00
Phillip HoandGitHub 8aeb4d6308 chore: Export sources array (#6206) 2025-02-08 12:00:43 +08:00
Joaquim VergesandGitHub f1cd2539d1 [SDK] chore: Update coinbase/wallet-sdk (#6204) 2025-02-07 19:31:39 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
a322e16853 Version Packages (#6184)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-07 16:10:52 -08:00
0xFirekeeper e50d77d28f Docs up to .NET 2.17.1 / Unity 5.17.1 (#6200)
Closes TOOL-2991
<!--

## 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 `sidebar` structure and API documentation, adds new wallet authentication options, and refines existing content for clarity. It also includes new RPC overrides and updates to the `InAppWallet` and `EcosystemWallet` functionalities.

### Detailed summary
- Added `rpcOverrides` example to `page.mdx`.
- Updated `sidebar.tsx` to include "API Reference" and "Blockchain API" sections.
- Replaced "Blockchain API" with "Nebula AI" in `sidebar.tsx`.
- Enhanced `EcosystemWallet` and `InAppWallet` functionalities with new login options.
- Clarified documentation on wallet connections and authentication methods.
- Added examples for `SiweExternal` login process.
- Updated various sections in `page.mdx` for better clarity and detail.

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

<!-- end pr-codex -->
2025-02-07 21:06:12 +00:00
MananTank 7f3b91da37 [TOOL-3346] Portal: Add Nebula icon support in dynamic OG image generation (#6199) 2025-02-07 21:02:35 +00:00
MananTank 41d50443d9 [TOOL-3328] Dashboard: Show error message in toast on Email submission fail in onboaridng flow (#6186)
<!-- start pr-codex -->

## PR-Codex overview
This PR enhances error handling in the `AccountForm` component by improving the way errors are processed and displayed to the user.

### Detailed summary
- Replaced the `onError` parameter's type from `Error` to a more generic `error`.
- Added console logging for errors.
- Implemented specific error handling for duplicate email and invalid email address cases.
- Integrated `toast` notifications for user-friendly error messages.

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

<!-- end pr-codex -->
2025-02-07 20:40:27 +00:00
catalyst17andcatalyst17 a8ac98bd49 feat: allowedToCreateChains in TeamResponse (#6172)
<!--

## 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 adding the `canCreatePublicChains` property to the `mocks.ts` and modifying its type in `api.ts` to enhance the API's capabilities regarding public chain creation.

### Detailed summary
- Added `canCreatePublicChains: false` to the object in `mocks.ts`.
- Updated the type of `canCreatePublicChains` in `api.ts` to `boolean | null`.

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

<!-- end pr-codex -->
2025-02-07 11:52:05 -08:00
YashandGitHub 0781da9dca [SDK] Check bytecode prefix (#6196) 2025-02-07 09:52:00 -08:00
MananTank c5dfad8dbb [TOOL-3298] Playground: Add Insight playground (#6185)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces several enhancements and new features to the `playground-web` application, including a new `Spinner` component, improved layout for the `APIHeader`, and various updates to forms, UI components, and hooks for better functionality and usability.

### Detailed summary
- Added `isProd` export in `env.ts`.
- Introduced `Spinner` component in `Spinner.tsx` with CSS animations.
- Updated `Layout` to include `APIHeader` with title and links.
- Enhanced `RenderCode` to support new scrollable container class.
- Added `Anchor` component with improved link handling.
- Modified `CodeClient` to accept new props for scrollable container.
- Implemented `useShowMore` hook for dynamic item display.
- Updated `tailwind.config.ts` for new color schemes.
- Refactored `AppSidebar` to accept dynamic links.
- Introduced `fetchAllBlueprints` function for sidebar link generation.
- Added `Alert`, `Tooltip`, and `Table` components with improved styling.
- Enhanced `SelectWithSearch` for better option handling and search functionality.
- Updated various components to use client-side rendering (`"use client"`).
- Improved error handling and loading states in image components.
- Added new utility functions for better link and chain handling.

> The following files were skipped due to too many changes: `apps/playground-web/src/components/ui/select.tsx`, `apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx`, `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-02-07 15:29:21 +00:00
Joaquim VergesandGitHub 9663079062 [SDK] feat: Add session keys to smart wallet options (#6194) 2025-02-07 04:37:28 -08:00
Joaquim VergesandGitHub 1f6bb7c329 [SDK] feat: Add fiat value display to buy token input (#6193) 2025-02-07 04:19:24 -08:00
Joaquim VergesandGitHub 30e13e6b91 [SDK] feat: Add editable token amount input to buy screen (#6192) 2025-02-07 03:54:22 -08:00
Joaquim VergesandGitHub 2dfc245d44 [SDK] feat: Add provider selection to fiat onramp (#6190) 2025-02-07 01:41:47 -08:00
jnsdls 64d7bf358f prefer explicit clientId over secretKey computation (#6188)
TOOL-0000

### TL;DR

Prioritize explicitly passed `clientId` over computed values when creating a Thirdweb client.

### What changed?

Modified the client initialization logic to prefer explicitly passed `clientId` parameters over computing them from `secretKey`. Only falls back to computing the `clientId` from `secretKey` when no explicit `clientId` is provided.

### How to test?

1. Create a Thirdweb client with both `clientId` and `secretKey` parameters
2. Verify that the provided `clientId` is used instead of computing one from the `secretKey`
3. Create a client with only `secretKey` and verify the `clientId` is computed correctly

### Why make this change?

To give developers more control over client configuration by respecting explicitly provided parameters rather than always computing values from the secret key. This allows for more flexible client setup scenarios where the computed `clientId` might not be desired.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the behavior of the `createThirdwebClient()` function to prioritize the explicitly provided `clientId` over deriving it from the `secretKey`. It also modifies the corresponding test to reflect this new behavior.

### Detailed summary
- Updated `createThirdwebClient()` to prefer `clientId` over computed value from `secretKey`.
- Changed test description to indicate `clientId` is not ignored when `secretKey` is provided.
- Updated test expectation to check that `clientId` is equal to the explicitly provided value instead of the computed one.

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

<!-- end pr-codex -->
2025-02-07 08:28:29 +00:00
jnsdls a6e9c97c6f add redirect from /connect/pay to /connect/universal-bridge (#6189)
fixes: TOOL-0000
2025-02-07 08:07:35 +00:00
Joaquim VergesandGitHub ee57ded902 [SDK] fix: Requery allowances on quote screen return (#6187) 2025-02-06 21:00:01 -08:00
MananTank 6d3947ab0c [TOOL-3314] Playground: Fix Next.js errors in Headless UI example pages (#6175)
Fixed "Can not import server (async) components in client components" error

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new preview components for wallet, NFT, token, and account functionalities in the `playground-web` application. It enhances code modularity by separating previews into distinct files, improving readability and maintainability.

### Detailed summary
- Added `wallet-previews.tsx` with previews for `WalletIcon`, `WalletName`, and formatting options.
- Created `nft-previews.tsx` with previews for `NFTMedia`, `NFTName`, and `NFTDescription`.
- Introduced `token-previews.tsx` with previews for `TokenIcon`, `TokenName`, and `TokenSymbol`.
- Developed `account-previews.tsx` for `AccountAddress`, `AccountName`, and `AccountBalance` with various configurations.
- Updated example files (`wallet-examples.tsx`, `nft-examples.tsx`, `token-examples.tsx`, `account-examples.tsx`) to utilize new preview components, enhancing code clarity.

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

<!-- end pr-codex -->
2025-02-06 20:10:27 +00:00
MananTank 632f1d138f [TOOL-3299] Playground: Update Sidebar, Minor improvements and fixes (#6174) 2025-02-06 20:10:26 +00:00
Phillip HoandGitHub 18f0e575c4 [service-utils] Update list of usageV2 sources (#6183) 2025-02-06 17:39:33 +08:00
109 changed files with 4956 additions and 1727 deletions
+6
View File
@@ -337,6 +337,12 @@ async function redirects() {
destination: "/connect/account-abstraction",
permanent: false,
},
// redirect /connect/pay to /connect/universal-bridge
{
source: "/connect/pay",
destination: "/connect/universal-bridge",
permanent: false,
},
// PREVIOUS CAMPAIGNS
{
source: "/unlimited-wallets",
@@ -8,6 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type AccountValidationSchema,
accountValidationSchema,
@@ -81,15 +82,21 @@ export const AccountForm: React.FC<AccountFormProps> = ({
data,
});
},
onError: (err) => {
const error = err as Error;
onError: (error) => {
console.error(error);
if (
onDuplicateError &&
error?.message.match(/email address already exists/)
) {
onDuplicateError(values.email);
return;
} else if (error.message.includes("INVALID_EMAIL_ADDRESS")) {
toast.error("Invalid Email Address");
} else {
toast.error(error.message || "Failed to update account");
}
trackEvent({
category: "account",
action: "update",
+14
View File
@@ -1,5 +1,19 @@
# thirdweb-login
## 0.1.4
### Patch Changes
- Updated dependencies [[`f1cd253`](https://github.com/thirdweb-dev/js/commit/f1cd2539d1be15eb18807b3f5f2b90509e3d58cf)]:
- thirdweb@5.88.1
## 0.1.3
### Patch Changes
- Updated dependencies [[`1f6bb7c`](https://github.com/thirdweb-dev/js/commit/1f6bb7c3294d70648b120a6a6a6cba13302a84fc), [`30e13e6`](https://github.com/thirdweb-dev/js/commit/30e13e6b9176265a2f4eddfa53578889abbcb750), [`2dfc245`](https://github.com/thirdweb-dev/js/commit/2dfc245d44dde86e42f6c799305db707316432aa), [`ee57ded`](https://github.com/thirdweb-dev/js/commit/ee57ded902cb69da6fc171599a4a90776e650149), [`9663079`](https://github.com/thirdweb-dev/js/commit/966307906212ac99dc0a2a9be88e514c920d39c4), [`64d7bf3`](https://github.com/thirdweb-dev/js/commit/64d7bf358fe2014b684688d41d525a75e47f1b82)]:
- thirdweb@5.88.0
## 0.1.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "thirdweb-login",
"version": "0.1.2",
"version": "0.1.4",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
+8 -2
View File
@@ -10,11 +10,13 @@
"prelint": "biome check ./src",
"lint": "eslint ./src",
"prefix": "biome check ./src --fix",
"fix": "eslint ./src --fix"
"fix": "eslint ./src --fix",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@abstract-foundation/agw-client": "^1.4.0",
"@abstract-foundation/agw-react": "^1.5.0",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "1.1.5",
@@ -36,16 +38,20 @@
"lucide-react": "0.474.0",
"next": "15.1.6",
"next-themes": "^0.4.4",
"nextjs-toploader": "^1.6.12",
"openapi-types": "^12.1.3",
"prettier": "3.3.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "7.54.2",
"react-pick-color": "^2.0.0",
"server-only": "^0.0.1",
"shiki": "1.27.0",
"tailwind-merge": "^2.6.0",
"thirdweb": "workspace:*",
"timeago.js": "^4.0.2",
"use-debounce": "^10.0.4"
"use-debounce": "^10.0.4",
"zod": "3.24.1"
},
"devDependencies": {
"@types/node": "22.13.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+18 -18
View File
@@ -1,34 +1,34 @@
import thirdwebIconSrc from "@/../public/thirdweb.svg";
import { Sidebar } from "@/components/ui/sidebar";
import { Sidebar, type SidebarLink } from "@/components/ui/sidebar";
import Image from "next/image";
import Link from "next/link";
import { navLinks } from "./navLinks";
import { ScrollShadow } from "../components/ui/ScrollShadow/ScrollShadow";
import { otherLinks } from "./otherLinks";
export function AppSidebar() {
export function AppSidebar(props: {
links: SidebarLink[];
}) {
return (
<div className="sticky top-0 z-10 hidden h-dvh w-[300px] flex-col border-border/50 border-r-2 xl:flex">
<div className="px-6 pt-6">
<Link
className="flex items-center gap-2"
href="/"
aria-label="thirdweb Docs"
title="thirdweb Docs"
>
<Image src={thirdwebIconSrc} className="size-6" alt="" />
<div className="z-10 hidden h-dvh w-[300px] flex-col border-border/50 border-r-2 xl:flex">
<div className="border-b px-6 py-5">
<div className="flex items-center gap-2">
<Image src={thirdwebIconSrc} className="size-6" alt="thirdweb" />
<span className="font-bold text-lg leading-none tracking-tight">
Playground
</span>
</Link>
</div>
</div>
<div className="h-5" />
<div className="px-6">
<Sidebar links={navLinks} />
<div className="relative flex max-h-full flex-1 flex-col overflow-hidden">
<ScrollShadow
className="grow pr-4 pl-6"
scrollableClassName="max-h-full pt-6"
>
<Sidebar links={props.links} />
</ScrollShadow>
</div>
<div className="mt-auto flex flex-col gap-4 p-6">
<div className="mt-auto flex flex-col gap-4 border-t px-6 py-6">
{otherLinks.map((link) => {
return (
<Link
+52 -40
View File
@@ -5,12 +5,14 @@ import { MenuIcon, XIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { ScrollShadow } from "../components/ui/ScrollShadow/ScrollShadow";
import { Button } from "../components/ui/button";
import { Sidebar } from "../components/ui/sidebar";
import { navLinks } from "./navLinks";
import { Sidebar, type SidebarLink } from "../components/ui/sidebar";
import { otherLinks } from "./otherLinks";
export function MobileHeader() {
export function MobileHeader(props: {
links: SidebarLink[];
}) {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
@@ -26,43 +28,53 @@ export function MobileHeader() {
}, [isOpen]);
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<header
className="sticky top-0 z-10 flex justify-between gap-4 border-b bg-background px-4 py-4 xl:hidden "
onClick={(e) => {
if (e.target instanceof HTMLAnchorElement) {
setIsOpen(false);
}
}}
>
<Link
className="flex items-center gap-3"
href="/"
aria-label="thirdweb Docs"
title="thirdweb Docs"
>
<Image src={thirdwebIconSrc} className="size-7" alt="" />
<span className="font-bold text-xl leading-none tracking-tight">
Playground
</span>
</Link>
<Button
variant="outline"
className="!h-auto p-2"
onClick={() => {
setIsOpen((v) => !v);
}}
>
{!isOpen ? (
<MenuIcon className="size-6" />
) : (
<XIcon className="size-6" />
)}
</Button>
<>
<header className="sticky top-0 z-10 flex justify-between gap-4 border-b bg-background px-4 py-4 xl:hidden">
<Link
className="flex items-center gap-3"
href="/"
aria-label="thirdweb Docs"
title="thirdweb Docs"
>
<Image src={thirdwebIconSrc} className="size-7" alt="" />
<span className="font-bold text-xl leading-none tracking-tight">
Playground
</span>
</Link>
<Button
variant="outline"
className="!h-auto p-2"
onClick={() => {
setIsOpen((v) => !v);
}}
>
{!isOpen ? (
<MenuIcon className="size-6" />
) : (
<XIcon className="size-6" />
)}
</Button>
</header>
{isOpen && (
<div className="fade-in-0 slide-in-from-top-5 fixed top-[75px] right-0 bottom-0 left-0 z-50 flex animate-in flex-col gap-6 overflow-auto bg-background p-6 duration-200">
<Sidebar links={navLinks} />
<div className="mt-auto flex flex-col gap-4">
<div
className="fade-in-0 slide-in-from-top-5 fixed top-[75px] right-0 bottom-0 left-0 z-50 flex animate-in flex-col bg-background duration-200"
onClickCapture={(e) => {
if (e.target instanceof HTMLElement && e.target.closest("a")) {
setIsOpen(false);
}
}}
>
<div className="relative flex max-h-full flex-1 flex-col overflow-hidden">
<ScrollShadow
className="grow px-6"
scrollableClassName="max-h-full pt-6"
>
<Sidebar links={props.links} />
</ScrollShadow>
</div>
<div className="mt-auto flex flex-col gap-4 border-t px-6 py-6">
{otherLinks.map((link) => {
return (
<Link
@@ -78,6 +90,6 @@ export function MobileHeader() {
</div>
</div>
)}
</header>
</>
);
}
@@ -1,7 +1,7 @@
import { abstractWallet } from "@abstract-foundation/agw-react/thirdweb";
import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
arbitrumSepolia,
baseSepolia,
@@ -25,21 +25,26 @@ import { cn } from "../../../../lib/utils";
import { CodeGen } from "../components/CodeGen";
import type { ConnectPlaygroundOptions } from "../components/types";
type Tab = "modal" | "button" | "code";
export function RightSection(props: {
connectOptions: ConnectPlaygroundOptions;
tab?: string;
}) {
const router = useRouter();
const previewTab = useMemo(
() =>
["modal", "button", "code"].includes(props.tab || "")
? (props.tab as "modal" | "button" | "code")
: "modal",
[props.tab],
);
const setPreviewTab = (tab: "modal" | "button" | "code") => {
router.push(`/connect/sign-in?tab=${tab}`);
};
const pathname = usePathname();
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
return props.tab === "code"
? "code"
: props.tab === "button"
? "button"
: "modal";
});
function setPreviewTab(tab: "modal" | "button" | "code") {
_setPreviewTab(tab);
window.history.replaceState({}, "", `${pathname}?tab=${tab}`);
}
const { connectOptions } = props;
const wallet = useActiveWallet();
const account = useActiveAccount();
@@ -134,7 +134,7 @@ function WalletButton(props: {
>
<span className="flex items-center gap-3">
<Img
src={walletImage.data || ""}
src={walletImage.data}
alt=""
className="size-7 rounded-lg"
loading="lazy"
+26 -1
View File
@@ -20,6 +20,11 @@
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--destructive-text: 357.15deg 100% 68.72%;
--success-text: 142.09 70.56% 35.29%;
--warning-text: 38 92% 40%;
--inverted-foreground: 0 0% 100%;
--inverted: 0 0% 4%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
@@ -35,7 +40,7 @@
.dark {
--background: 240deg 2% 11%;
--foreground: 0 0% 98%;
--card: 240deg 2% 11%;
--card: 240deg 2% 13%;
--card-foreground: 0 0% 98%;
--popover: 240deg 2% 11%;
--popover-foreground: 0 0% 98%;
@@ -49,7 +54,13 @@
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--destructive-text: 360 72% 55%;
--warning-text: 38 92% 50%;
--success-text: 142 75% 50%;
--border: 240deg 2% 20%;
--active-border: 240deg 2% 25%;
--inverted-foreground: 0 0% 0%;
--inverted: 0 0% 100%;
--input: 240deg 2% 20%;
--ring: 240deg 2% 30%;
--chart-1: 220 70% 50%;
@@ -79,3 +90,17 @@ html.dark .shiki span {
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar,
.no-scrollbar *::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar,
.no-scrollbar * {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
@@ -0,0 +1,39 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { ChainMetadata } from "thirdweb/chains";
async function fetchChainsFromApi() {
const res = await fetch("https://api.thirdweb.com/v1/chains");
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
return json.data as ChainMetadata[];
}
export function useAllChainsData() {
const query = useQuery({
queryKey: ["all-chains"],
queryFn: async () => {
const idToChain = new Map<number, ChainMetadata>();
const chains = await fetchChainsFromApi();
for (const c of chains) {
idToChain.set(c.chainId, c);
}
return {
allChains: chains,
idToChain,
};
},
});
return {
isPending: query.isLoading,
data: query.data || { allChains: [], idToChain: new Map() },
};
}
@@ -0,0 +1,37 @@
"use client";
import { useCallback, useState } from "react";
/**
*
* @internal
*/
export function useShowMore<T extends HTMLElement>(
initialItemsToShow: number,
itemsToAdd: number,
) {
// start with showing first `initialItemsToShow` items, when the last item is in view, show `itemsToAdd` more
const [itemsToShow, setItemsToShow] = useState(initialItemsToShow);
const lastItemRef = useCallback(
(node: T) => {
if (!node) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setItemsToShow((prev) => prev + itemsToAdd); // show 10 more items
}
},
{ threshold: 1 },
);
observer.observe(node);
// when the node is removed from the DOM, observer will be disconnected automatically by the browser
},
[itemsToAdd],
);
return { itemsToShow, lastItemRef };
}
@@ -0,0 +1,886 @@
"use client";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { CodeClient, CodeLoading } from "@/components/code/code.client";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowDownLeftIcon,
ArrowUpRightIcon,
CheckIcon,
CopyIcon,
InfoIcon,
PlayIcon,
} from "lucide-react";
import type { OpenAPIV3 } from "openapi-types";
import { useEffect, useMemo, useState } from "react";
import {
type ControllerRenderProps,
type UseFormReturn,
useForm,
} from "react-hook-form";
import { z } from "zod";
import { isProd } from "../../../lib/env";
import type { BlueprintParameter, BlueprintPathMetadata } from "../utils";
export function BlueprintPlayground(props: {
metadata: BlueprintPathMetadata;
backLink: string;
clientId: string;
path: string;
supportedChainIds: number[];
}) {
const [abortController, setAbortController] =
useState<AbortController | null>(null);
const requestMutation = useMutation({
mutationFn: async (url: string) => {
const controller = new AbortController();
setAbortController(controller);
const start = performance.now();
try {
const res = await fetch(url, {
signal: controller.signal,
});
return {
status: res.status,
data: await res.text(),
time: performance.now() - start,
};
} catch (e) {
const time = performance.now() - start;
if (e instanceof Error) {
return {
data: e.message,
time: time,
};
}
return {
data: "Failed to fetch",
time: time,
};
}
},
});
const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb";
return (
<BlueprintPlaygroundUI
backLink={props.backLink}
clientId={props.clientId}
metadata={props.metadata}
isPending={requestMutation.isPending}
onRun={(url) => {
requestMutation.mutate(url);
}}
response={
abortController?.signal.aborted ? undefined : requestMutation.data
}
abortRequest={() => {
if (abortController) {
// just abort it - don't set a new controller
abortController.abort();
}
}}
domain={`https://insight.${thirdwebDomain}.com`}
path={props.path}
supportedChainIds={props.supportedChainIds}
/>
);
}
function modifyParametersForPlayground(_parameters: BlueprintParameter[]) {
const parameters = [..._parameters];
// make chain query param required - its not required in open api spec - because it either has to be set in subdomain or as a query param
const chainIdParameter = parameters.find((p) => p.name === "chain");
if (chainIdParameter) {
chainIdParameter.required = true;
}
// remove the client id parameter if it is present - we will always replace the parameter with project's client id
const clientIdParameterIndex = parameters.findIndex(
(p) => p.name === "clientId",
);
if (clientIdParameterIndex !== -1) {
parameters.splice(clientIdParameterIndex, 1);
}
return parameters;
}
export function BlueprintPlaygroundUI(props: {
backLink: string;
isPending: boolean;
onRun: (url: string) => void;
response:
| {
time: number;
data: undefined | string;
status?: number;
}
| undefined;
clientId: string;
abortRequest: () => void;
domain: string;
path: string;
metadata: BlueprintPathMetadata;
supportedChainIds: number[];
}) {
const parameters = useMemo(() => {
const filteredParams = props.metadata.parameters?.filter(
isOpenAPIV3ParameterObject,
);
return modifyParametersForPlayground(filteredParams || []);
}, [props.metadata.parameters]);
const formSchema = useMemo(() => {
return createParametersFormSchema(parameters);
}, [parameters]);
const defaultValues = useMemo(() => {
const values: Record<string, string | number> = {};
for (const param of parameters) {
if (param.name === "chain") {
values.chain = "1";
} else if (
param.schema &&
"type" in param.schema &&
param.schema.default
) {
values[param.name] = param.schema.default;
} else {
values[param.name] = "";
}
}
return values;
}, [parameters]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues,
});
function onSubmit(values: z.infer<typeof formSchema>) {
const url = createBlueprintUrl({
parameters: parameters,
values: values,
clientId: props.clientId,
domain: props.domain,
path: props.path,
intent: "run",
});
props.onRun(url);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex grow flex-col">
<div className="flex grow flex-col pb-20">
<div className="flex grow flex-col overflow-hidden rounded-xl border bg-card">
<PlaygroundHeader
domain={props.domain}
parameters={parameters}
path={props.path}
isPending={props.isPending}
getFormValues={() => form.getValues()}
clientId={props.clientId}
/>
<div className="grid grow grid-cols-1 lg:grid-cols-2">
<div className="flex max-h-[500px] grow flex-col max-sm:border-b lg:max-h-[740px] lg:border-r">
<RequestConfigSection
domain={props.domain}
parameters={parameters}
path={props.path}
form={form}
supportedChainIds={props.supportedChainIds}
/>
</div>
<div className="flex h-[500px] grow flex-col lg:h-[740px]">
<ResponseSection
isPending={props.isPending}
response={props.response}
abortRequest={props.abortRequest}
/>
</div>
</div>
</div>
</div>
</div>
</form>
</Form>
);
}
function PlaygroundHeader(props: {
parameters: BlueprintParameter[];
isPending: boolean;
getFormValues: () => Record<string, string>;
clientId: string;
domain: string;
path: string;
}) {
const [hasCopied, setHasCopied] = useState(false);
return (
<div className="border-b px-4 py-4 lg:flex lg:justify-center lg:py-3">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 overflow-hidden rounded-xl border bg-muted/50 p-2 lg:justify-center">
{/* copy url */}
<Button
variant="ghost"
className="hover:accent h-auto w-auto p-1"
onClick={() => {
setHasCopied(true);
const url = createBlueprintUrl({
clientId: props.clientId,
parameters: props.parameters,
domain: props.domain,
path: props.path,
values: props.getFormValues(),
intent: "copy",
});
setTimeout(() => {
setHasCopied(false);
}, 500);
navigator.clipboard.writeText(url);
}}
>
{hasCopied ? (
<CheckIcon className="size-4 text-green-500" />
) : (
<CopyIcon className="size-4" />
)}
</Button>
{/* vertical line */}
<div className="h-6 w-[1px] bg-border" />
{/* domain + path */}
<div className="flex items-center gap-1.5 overflow-hidden">
<div className="rounded-xl bg-muted-foreground/10 px-2.5 py-1 font-mono text-xs lg:text-sm">
<span className="hidden lg:inline">{props.domain}</span>
<span className="lg:hidden">...</span>
</div>
<div className="truncate font-mono text-xs lg:text-sm">
{props.path}
</div>
</div>
{/* Run */}
<Button
className="ml-2 hidden h-auto w-auto gap-1.5 p-1 px-2 lg:flex"
disabled={props.isPending}
type="submit"
>
{props.isPending ? (
<Spinner className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
Run
</Button>
</div>
<Button
size="sm"
className="gap-2 rounded-lg lg:hidden"
type="submit"
disabled={props.isPending}
>
{props.isPending ? (
<Spinner className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
Run
</Button>
</div>
</div>
);
}
function RequestConfigSection(props: {
parameters: BlueprintParameter[];
form: ParametersForm;
domain: string;
path: string;
supportedChainIds: number[];
}) {
const { pathVariables, queryParams, filterQueryParams } = useMemo(() => {
const pathVariables: OpenAPIV3.ParameterObject[] = [];
const queryParams: OpenAPIV3.ParameterObject[] = [];
const filterQueryParams: OpenAPIV3.ParameterObject[] = [];
for (const param of props.parameters) {
if (param.in === "path") {
pathVariables.push(param);
}
if (param.in === "query") {
if (param.name.startsWith("filter_")) {
filterQueryParams.push(param);
} else {
queryParams.push(param);
}
}
}
return {
pathVariables,
queryParams,
filterQueryParams,
};
}, [props.parameters]);
const showError =
!props.form.formState.isValid &&
props.form.formState.isDirty &&
props.form.formState.isSubmitted;
return (
<div className="flex grow flex-col overflow-hidden">
<div className="flex min-h-[60px] items-center justify-between gap-2 border-b p-4 text-sm">
<div className="flex items-center gap-2">
<ArrowUpRightIcon className="size-5" />
Request
</div>
{showError && <Badge variant="destructive">Invalid Request</Badge>}
</div>
<ScrollShadow className="flex-1" scrollableClassName="max-h-full">
{pathVariables.length > 0 && (
<ParameterSection
parameters={pathVariables}
className="border-b"
title="Path Variables"
form={props.form}
domain={props.domain}
path={props.path}
supportedChainIds={props.supportedChainIds}
/>
)}
{queryParams.length > 0 && (
<ParameterSection
className="border-b"
parameters={queryParams}
title="Query Parameters"
form={props.form}
domain={props.domain}
path={props.path}
supportedChainIds={props.supportedChainIds}
/>
)}
{filterQueryParams.length > 0 && (
<ParameterSection
parameters={filterQueryParams}
title="Filter Query Parameters"
form={props.form}
domain={props.domain}
path={props.path}
supportedChainIds={props.supportedChainIds}
/>
)}
</ScrollShadow>
</div>
);
}
type ParametersForm = UseFormReturn<{
[x: string]: string | number;
}>;
function ParameterSection(props: {
parameters: BlueprintParameter[];
title: string;
form: ParametersForm;
domain: string;
path: string;
supportedChainIds: number[];
className?: string;
}) {
const url = `${props.domain}${props.path}`;
return (
<div className={cn("p-4 py-6", props.className)}>
<h3 className="mb-3 font-medium text-sm"> {props.title} </h3>
<div className="overflow-hidden rounded-lg border">
{props.parameters.map((param, i) => {
const description =
param.schema && "type" in param.schema
? param.schema.description
: undefined;
const example =
param.schema && "type" in param.schema
? param.schema.example
: undefined;
const exampleToShow =
typeof example === "string" || typeof example === "number"
? example
: undefined;
const showTip = description !== undefined || example !== undefined;
const hasError = !!props.form.formState.errors[param.name];
const placeholder = url.includes(`{${param.name}}`)
? `{${param.name}}`
: url.includes(`:${param.name}`)
? `:${param.name}`
: "Value";
return (
<FormField
key={param.name}
control={props.form.control}
name={param.name}
render={({ field }) => (
<FormItem
className={cn(
"space-y-0",
i !== props.parameters.length - 1 && "border-b",
)}
>
<div
key={param.name}
className={cn(
"grid items-center",
param.name === "chain"
? "grid-cols-1 lg:grid-cols-2"
: "grid-cols-2",
)}
>
<div className="flex h-full flex-row flex-wrap items-center justify-between gap-1 border-r px-3 py-2">
<div className="font-medium font-mono text-sm">
{param.name}
</div>
{param.required && (
<Badge
className="px-2 text-muted-foreground"
variant="secondary"
>
Required
</Badge>
)}
</div>
<div className="relative">
{param.name === "chain" ? (
<SingleNetworkSelector
chainId={
field.value ? Number(field.value) : undefined
}
onChange={(chainId) => {
field.onChange({
target: { value: chainId.toString() },
});
}}
className="rounded-none border-0 border-t lg:border-none"
popoverContentClassName="min-w-[calc(100vw-20px)] lg:min-w-[500px]"
chainIds={
props.supportedChainIds.length > 0
? props.supportedChainIds
: undefined
}
/>
) : (
<>
<ParameterInput
param={param}
field={field}
showTip={showTip}
hasError={hasError}
placeholder={placeholder}
/>
{showTip && (
<ToolTipLabel
hoverable
contentClassName="max-w-[100vw] break-all"
label={
<div className="flex flex-col gap-2">
{description && (
<p className="text-foreground">
{description}
</p>
)}
{exampleToShow !== undefined && (
<div>
<p className="mb-1 text-muted-foreground">
Example:{" "}
<span className="font-mono">
{exampleToShow}
</span>
</p>
</div>
)}
</div>
}
>
<Button
asChild
variant="ghost"
className="-translate-y-1/2 absolute top-1/2 right-2 hidden h-auto w-auto p-1.5 text-muted-foreground opacity-50 hover:opacity-100 lg:flex"
>
<div>
<InfoIcon className="size-4" />
</div>
</Button>
</ToolTipLabel>
)}
</>
)}
</div>
</div>
<FormMessage className="mt-0 border-destructive-text border-t px-3 py-2" />
</FormItem>
)}
/>
);
})}
</div>
</div>
);
}
function ParameterInput(props: {
param: OpenAPIV3.ParameterObject;
field: ControllerRenderProps<
{
[x: string]: string | number;
},
string
>;
showTip: boolean;
hasError: boolean;
placeholder: string;
}) {
const { param, field, showTip, hasError, placeholder } = props;
if (param.schema && "type" in param.schema && param.schema.enum) {
const { value, onChange, ...restField } = field;
return (
<Select
{...restField}
value={value.toString()}
onValueChange={(v) => {
onChange({ target: { value: v } });
}}
>
<SelectTrigger
className={cn(
"border-none bg-transparent pr-10 font-mono focus:ring-0 focus:ring-offset-0",
value === "" && "text-muted-foreground",
)}
chevronClassName="hidden"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent className="font-mono">
{param.schema.enum.map((val) => {
return (
<SelectItem value={val} key={val}>
{val}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
return (
<Input
{...field}
className={cn(
"h-auto truncate rounded-none border-0 bg-transparent py-3 font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0",
showTip && "lg:pr-10",
hasError && "text-destructive-text",
)}
placeholder={placeholder}
/>
);
}
function formatMilliseconds(ms: number) {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
function ResponseSection(props: {
isPending: boolean;
response:
| { data: undefined | string; status?: number; time: number }
| undefined;
abortRequest: () => void;
}) {
const formattedData = useMemo(() => {
if (!props.response?.data) return undefined;
try {
return JSON.stringify(JSON.parse(props.response.data), null, 2);
} catch {
return props.response.data;
}
}, [props.response]);
return (
<div className="flex h-full grow flex-col">
<div className="flex min-h-[60px] items-center justify-between gap-2 border-b p-4 text-sm">
<div className="flex items-center gap-2">
<ArrowDownLeftIcon className="size-5" />
Response
{props.isPending && <ElapsedTimeCounter />}
{props.response?.time && !props.isPending && (
<span className="text-muted-foreground">
{formatMilliseconds(props.response.time)}
</span>
)}
</div>
{!props.isPending && props.response?.status && (
<Badge
variant={
props.response.status >= 200 && props.response.status < 300
? "success"
: "destructive"
}
>
{props.response.status}
</Badge>
)}
</div>
{props.isPending && (
<div className="flex grow flex-col items-center justify-center gap-4">
<Spinner className="size-14 text-muted-foreground" />
<Button variant="ghost" onClick={props.abortRequest} size="sm">
Cancel
</Button>
</div>
)}
{!props.isPending && !props.response && (
<div className="flex grow flex-col items-center justify-center p-4">
<div>
<div className="flex justify-center">
<div className="rounded-xl border p-1">
<div className="rounded-lg border bg-card p-1.5">
<PlayIcon className="size-5 text-muted-foreground" />
</div>
</div>
</div>
<p className="mt-3 text-center">Click Run to start a request</p>
</div>
</div>
)}
{!props.isPending && props.response && (
<CodeClient
loader={<CodeLoading />}
lang="json"
code={formattedData || ""}
className="rounded-none border-none bg-transparent"
scrollableContainerClassName="h-full"
scrollableClassName="h-full"
// shadowColor="hsl(var(--muted)/50%)"
/>
)}
</div>
);
}
function openAPIV3ParamToZodFormSchema(
schema: BlueprintParameter["schema"],
isRequired: boolean,
): z.ZodTypeAny | undefined {
if (!schema) {
return;
}
if ("anyOf" in schema) {
const anyOf = schema.anyOf;
if (!anyOf) {
return;
}
const anySchemas = anyOf
.map((s) => openAPIV3ParamToZodFormSchema(s, isRequired))
.filter((x) => !!x);
// @ts-expect-error - Its ok, z.union is expecting tuple type but we have array
return z.union(anySchemas);
}
if (!("type" in schema)) {
return;
}
// if enum values
const enumValues = schema.enum;
if (enumValues) {
const enumSchema = z.enum(
// @ts-expect-error - Its correct
enumValues,
);
if (isRequired) {
return enumSchema;
}
return enumSchema.or(z.literal(""));
}
switch (schema.type) {
case "integer": {
const intSchema = z.coerce
.number({
message: "Must be an integer",
})
.int({
message: "Must be an integer",
});
return isRequired
? intSchema.min(1, {
message: "Required",
})
: intSchema.optional();
}
case "number": {
const numberSchema = z.coerce.number();
return isRequired
? numberSchema.min(1, {
message: "Required",
})
: numberSchema.optional();
}
case "boolean": {
const booleanSchema = z.coerce.boolean();
return isRequired ? booleanSchema : booleanSchema.optional();
}
// everything else - just accept it as a string;
default: {
const stringSchema = z.string();
return isRequired
? stringSchema.min(1, {
message: "Required",
})
: stringSchema.optional();
}
}
}
function createParametersFormSchema(parameters: BlueprintParameter[]) {
const shape: z.ZodRawShape = {};
for (const param of parameters) {
const paramSchema = openAPIV3ParamToZodFormSchema(
param.schema,
!!param.required,
);
if (paramSchema) {
shape[param.name] = paramSchema;
} else {
shape[param.name] = param.required
? z.string().min(1, { message: "Required" })
: z.string();
}
}
return z.object(shape);
}
function createBlueprintUrl(options: {
parameters: BlueprintParameter[];
values: Record<string, string>;
clientId: string;
domain: string;
path: string;
intent: "copy" | "run";
}) {
const { parameters, domain, path, values, clientId } = options;
let url = `${domain}${path}`;
// loop over the values and replace {x} or :x with the actual values for paths
// and add query parameters
const pathVariables = parameters.filter((param) => param.in === "path");
const queryParams = parameters.filter((param) => param.in === "query");
for (const parameter of pathVariables) {
const value = values[parameter.name];
if (value) {
url = url.replace(`{${parameter.name}}`, value);
url = url.replace(`:${parameter.name}`, value);
}
}
const searchParams = new URLSearchParams();
for (const parameter of queryParams) {
const value = values[parameter.name];
if (value) {
searchParams.append(parameter.name, value);
}
}
// add client Id search param
if (options.intent === "run") {
searchParams.append("clientId", clientId);
} else {
searchParams.append("clientId", "YOUR_THIRDWEB_CLIENT_ID");
}
if (searchParams.toString()) {
url = `${url}?${searchParams.toString()}`;
}
return url;
}
function ElapsedTimeCounter() {
const [ms, setMs] = useState(0);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const internal = 100;
const id = setInterval(() => {
setMs((prev) => prev + internal);
}, internal);
return () => clearInterval(id);
}, []);
return (
<span className="fade-in-0 animate-in text-muted-foreground text-sm duration-300">
{formatMilliseconds(ms)}
</span>
);
}
function isOpenAPIV3ParameterObject(
x: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject,
): x is OpenAPIV3.ParameterObject {
return !("$ref" in x);
}
@@ -0,0 +1,77 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { redirect } from "next/navigation";
import { THIRDWEB_CLIENT } from "../../../lib/client";
import { fetchBlueprintSpec } from "../utils";
import { BlueprintPlayground } from "./blueprint-playground.client";
export default async function Page(props: {
params: Promise<{
blueprint_slug: string;
}>;
searchParams: Promise<{ path: string }>;
}) {
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
// invalid url
if (!searchParams.path) {
redirect("/insight");
}
const [blueprintSpec] = await Promise.all([
fetchBlueprintSpec({
blueprintId: params.blueprint_slug,
}),
]);
const pathMetadata = blueprintSpec.openapiJson.paths[searchParams.path]?.get;
// invalid url
if (!pathMetadata) {
redirect("/insight");
}
const supportedChainIds =
blueprintSpec.openapiJson.servers?.[0]?.variables?.chainId?.enum?.map(
Number,
) || [];
const title = pathMetadata.summary || "";
return (
<div>
<Breadcrumbs />
<h1 className="mt-3 mb-6 font-semibold text-2xl tracking-tight lg:text-3xl">
{title}
</h1>
<BlueprintPlayground
key={searchParams.path}
metadata={pathMetadata}
backLink={"/insight"}
clientId={THIRDWEB_CLIENT.clientId}
path={searchParams.path}
supportedChainIds={supportedChainIds}
/>
</div>
);
}
function Breadcrumbs() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/insight">Insight Blueprints</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
</BreadcrumbList>
</Breadcrumb>
);
}
@@ -0,0 +1,21 @@
import type React from "react";
import { APIHeader } from "../../components/blocks/APIHeader";
export default function Layout(props: {
children: React.ReactNode;
}) {
return (
<div>
<APIHeader
title="Insight"
description={
<>Simple & customizable endpoints for querying rich blockchain data</>
}
docsLink="https://portal.thirdweb.com/insight"
heroLink="/insight-hero.avif"
/>
{props.children}
</div>
);
}
@@ -0,0 +1,86 @@
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import {} from "lucide-react";
import Link from "next/link";
import { fetchAllBlueprints } from "./utils";
export default async function Page() {
const blueprints = await fetchAllBlueprints();
return (
<div className="pb-20">
<h2 className="mb-2 font-semibold text-2xl tracking-tight">Blueprints</h2>
<p className="mb-5 text-muted-foreground">
A blueprint is an API that provides access to on-chain data in a
user-friendly format. <br /> There is no need for ABIs, decoding, RPC,
or web3 knowledge to fetch blockchain data.{" "}
<Link
href="https://portal.thirdweb.com/insight/blueprints"
target="_blank"
className="underline decoration-muted-foreground/50 decoration-dotted underline-offset-[5px] hover:decoration-solid"
>
Learn more about Insight Blueprints{" "}
</Link>
</p>
<div className="flex flex-col gap-8">
{blueprints.map((blueprint) => {
const paths = Object.keys(blueprint.openapiJson.paths);
return (
<BlueprintSection
key={blueprint.id}
blueprintId={blueprint.id}
title={blueprint.name}
blueprints={paths.map((pathName) => {
const pathObj = blueprint.openapiJson.paths[pathName];
if (!pathObj) {
throw new Error(`Path not found: ${pathName}`);
}
return {
name: pathObj.get?.summary || "Unknown",
link: `/insight/${blueprint.id}?path=${pathName}`,
};
})}
/>
);
})}
</div>
</div>
);
}
function BlueprintSection(props: {
title: string;
blueprintId: string;
blueprints: { name: string; link: string }[];
}) {
return (
<div className="overflow-hidden rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b bg-accent/20 px-6 py-4">
<h2 className="font-semibold text-lg tracking-tight">{props.title}</h2>
</div>
<Table>
<TableBody>
{props.blueprints.map((item) => (
<TableRow
key={item.link}
className="group hover:bg-accent/50"
linkBox
>
<TableCell>
<span className="flex items-center gap-3">
<Link
href={item.link}
className="before:absolute before:inset-0"
>
{item.name}
</Link>
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,71 @@
import "server-only";
import type { OpenAPIV3 } from "openapi-types";
import { isProd } from "../../lib/env";
type BlueprintListItem = {
id: string;
name: string;
description: string;
slug: string;
};
const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb";
async function fetchBlueprintList() {
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/blueprints`,
);
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to fetch blueprints: ${text}`);
}
const json = (await res.json()) as { data: BlueprintListItem[] };
return json.data;
}
export type BlueprintParameter = OpenAPIV3.ParameterObject;
export type BlueprintPathMetadata = OpenAPIV3.PathItemObject;
type BlueprintSpec = {
id: string;
name: string;
description: string;
openapiJson: OpenAPIV3.Document;
};
export async function fetchBlueprintSpec(params: {
blueprintId: string;
}) {
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/blueprints/${params.blueprintId}`,
);
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to fetch blueprint: ${text}`);
}
const json = (await res.json()) as { data: BlueprintSpec };
return json.data;
}
export async function fetchAllBlueprints() {
// fetch list
const blueprintSpecs = await fetchBlueprintList();
// fetch all blueprints
const blueprints = await Promise.all(
blueprintSpecs.map((spec) =>
fetchBlueprintSpec({
blueprintId: spec.id,
}),
),
);
return blueprints;
}
+16 -9
View File
@@ -6,7 +6,9 @@ import Script from "next/script";
import { AppSidebar } from "./AppSidebar";
import { Providers } from "./providers";
import "./globals.css";
import NextTopLoader from "nextjs-toploader";
import { MobileHeader } from "./MobileHeader";
import { getSidebarLinks } from "./navLinks";
const sansFont = Inter({
subsets: ["latin"],
@@ -26,11 +28,12 @@ export const metadata: Metadata = {
description: "thirdweb playground",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const sidebarLinks = await getSidebarLinks();
return (
<html lang="en" suppressHydrationWarning>
<head>
@@ -49,14 +52,18 @@ export default function RootLayout({
monoFont.variable,
)}
>
<MobileHeader />
<div className="relative">
<div className="flex flex-col lg:flex-row">
<AppSidebar />
<div className="flex grow flex-col">
<div className="container relative grow px-4 md:px-6">
<Providers>{children}</Providers>
</div>
<NextTopLoader
color="hsl(var(--foreground))"
height={2}
shadow={false}
showSpinner={false}
/>
<MobileHeader links={sidebarLinks} />
<div className="flex flex-col lg:h-dvh lg:flex-row">
<AppSidebar links={sidebarLinks} />
<div className="flex grow flex-col lg:overflow-auto">
<div className="container relative grow px-4 md:px-6">
<Providers>{children}</Providers>
</div>
</div>
</div>
+153 -117
View File
@@ -1,140 +1,176 @@
import type { SidebarLink } from "../components/ui/sidebar";
import { fetchAllBlueprints } from "./insight/utils";
export const navLinks: SidebarLink[] = [
export const staticSidebarLinks: SidebarLink[] = [
{
name: "Sign in",
expanded: true,
name: "Connect",
isCollapsible: false,
links: [
{
name: "Button",
href: "/connect/sign-in/button",
name: "Connect Wallet",
expanded: false,
links: [
{
name: "Button",
href: "/connect/sign-in/button",
},
{
name: "Embed",
href: "/connect/sign-in/embed",
},
{
name: "Headless",
href: "/connect/sign-in/headless",
},
],
},
{
name: "Embed",
href: "/connect/sign-in/embed",
name: "Account Abstraction",
expanded: false,
links: [
{
name: "Connect",
href: "/connect/account-abstraction/connect",
},
{
name: "Sponsor Gas",
href: "/connect/account-abstraction/sponsor",
},
{
name: "Native AA (zkSync)",
href: "/connect/account-abstraction/native-aa",
},
],
},
{
name: "Headless",
href: "/connect/sign-in/headless",
},
],
},
{
name: "Account Abstraction",
expanded: false,
links: [
{
name: "Connect",
href: "/connect/account-abstraction/connect",
name: "In-App Wallet",
expanded: false,
links: [
{
name: "Any Auth",
href: "/connect/in-app-wallet",
},
{
name: "Ecosystems",
href: "/connect/in-app-wallet/ecosystem",
},
{
name: "Sponsor Gas",
href: "/connect/in-app-wallet/sponsor",
},
],
},
{
name: "Sponsor Gas",
href: "/connect/account-abstraction/sponsor",
name: "Pay",
expanded: false,
links: [
{
name: "Top up",
href: "/connect/pay",
},
{
name: "Commerce",
href: "/connect/pay/commerce",
},
{
name: "Transactions",
href: "/connect/pay/transactions",
},
],
},
{
name: "Native AA (zkSync)",
href: "/connect/account-abstraction/native-aa",
},
],
},
{
name: "In-App Wallet",
expanded: false,
links: [
{
name: "Any Auth",
href: "/connect/in-app-wallet",
name: "Auth",
href: "/connect/auth",
},
{
name: "Ecosystems",
href: "/connect/in-app-wallet/ecosystem",
name: "Social",
href: "/connect/social",
},
{
name: "Sponsor Gas",
href: "/connect/in-app-wallet/sponsor",
},
],
},
{
name: "Pay",
expanded: false,
links: [
{
name: "Top up",
href: "/connect/pay",
name: "Blockchain API",
href: "/connect/blockchain-api",
},
{
name: "Commerce",
href: "/connect/pay/commerce",
},
{
name: "Transactions",
href: "/connect/pay/transactions",
},
],
},
{
name: "Engine",
expanded: false,
links: [
{
name: "Airdrop",
href: "/engine/airdrop",
},
{
name: "Minting",
href: "/engine/minting",
},
{
name: "Webhooks",
href: "/engine/webhooks",
},
// {
// name: "Session Keys",
// href: "/engine/account-abstraction/session-keys",
// },
// {
// name: "Smart Backend Wallets",
// href: "/engine/account-abstraction/smart-backend-wallets",
// },
],
},
{
name: "Auth",
href: "/connect/auth",
},
{
name: "Social",
href: "/connect/social",
},
{
name: "Blockchain API",
href: "/connect/blockchain-api",
},
{
name: "Headless UI",
expanded: true,
links: [
{
name: "Account",
href: "/connect/ui",
},
{
name: "NFT",
href: "/connect/ui/nft",
},
{
name: "Token",
href: "/connect/ui/token",
},
{
name: "Chain",
href: "/connect/ui/chain",
},
{
name: "Wallet",
href: "/connect/ui/wallet",
name: "Headless UI",
expanded: false,
links: [
{
name: "Account",
href: "/connect/ui",
},
{
name: "NFT",
href: "/connect/ui/nft",
},
{
name: "Token",
href: "/connect/ui/token",
},
{
name: "Chain",
href: "/connect/ui/chain",
},
{
name: "Wallet",
href: "/connect/ui/wallet",
},
],
},
],
},
];
const engineSidebarLinks: SidebarLink = {
name: "Engine",
isCollapsible: false,
expanded: false,
links: [
{
name: "Airdrop",
href: "/engine/airdrop",
},
{
name: "Minting",
href: "/engine/minting",
},
{
name: "Webhooks",
href: "/engine/webhooks",
},
],
};
export async function getSidebarLinks() {
const insightBlueprints = await fetchAllBlueprints();
const insightLinks: SidebarLink[] = insightBlueprints.map((blueprint) => {
const paths = Object.keys(blueprint.openapiJson.paths);
return {
name: blueprint.name,
expanded: false,
links: paths.map((pathName) => {
const pathObj = blueprint.openapiJson.paths[pathName];
if (!pathObj) {
throw new Error(`Path not found: ${pathName}`);
}
return {
name: pathObj.get?.summary || pathName,
href: `/insight/${blueprint.id}?path=${pathName}`,
};
}),
};
});
const sidebarLinks: SidebarLink[] = [
...staticSidebarLinks,
{
name: "Insight",
isCollapsible: false,
expanded: false,
links: insightLinks,
},
engineSidebarLinks,
];
return sidebarLinks;
}
+2 -92
View File
@@ -1,95 +1,5 @@
import {
PayIcon,
WalletsAuthIcon,
WalletsConnectIcon,
WalletsInAppIcon,
WalletsSmartIcon,
} from "@/icons";
import { CodeIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
export default function Page() {
return (
<main className="container flex-1">
<WalletsSection />
</main>
);
}
function WalletsSection() {
return (
<section className="my-12">
<h2 className="break-words font-semibold text-2xl tracking-tight md:text-3xl">
Connect
</h2>
<div className="my-4 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<ArticleCardIndex
href="/connect/sign-in"
title="Sign In"
description="Integrate 500+ web3 wallets, in-app wallets, and smart accounts"
icon={WalletsConnectIcon}
/>
<ArticleCardIndex
href="/connect/account-abstraction"
title="Account abstraction"
description="Complete toolkit for Account Abstraction"
icon={WalletsSmartIcon}
/>
<ArticleCardIndex
title="In-App Wallet"
description="Onboard users with an email, social logins, passkey or your own auth system"
href="/connect/in-app-wallet"
icon={WalletsInAppIcon}
/>
<ArticleCardIndex
href="/connect/auth"
title="Auth"
description="Authenticate users with their wallets"
icon={WalletsAuthIcon}
/>
<ArticleCardIndex
href="/connect/pay"
title="Pay"
description="Add fiat and cross-chain crypto payments in your apps"
icon={PayIcon}
/>
<ArticleCardIndex
href="/connect/blockchain-api"
title="Blockchain API"
description="Performant, and reliable blockchain API"
icon={CodeIcon}
/>
<ArticleCardIndex
href="/connect/ui"
title="Headless UI"
description="Modular & composable set of UIs for your web3 applications"
icon={CodeIcon}
/>
</div>
</section>
);
}
/***
* This component is only for the index page
*/
function ArticleCardIndex(props: {
title: string;
description: string;
href: string;
icon?: React.FC<{ className?: string }>;
}) {
return (
<Link
href={props.href}
className="flex min-h-32 items-center gap-4 rounded-lg border p-4 transition-colors hover:border-ring hover:bg-ring/20"
>
{props.icon && <props.icon className="size-10 shrink-0" />}
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{props.title}</h3>
<p className="font-medium text-muted-foreground">{props.description}</p>
</div>
</Link>
);
redirect("/connect/sign-in/button");
}
@@ -0,0 +1,42 @@
"use client";
import { cn } from "@/lib/utils";
import { type ReactNode, useEffect, useState } from "react";
interface ClientOnlyProps {
/**
* Use this to server render a skeleton or loading state
*/
ssr: ReactNode;
className?: string;
children: ReactNode;
}
export const ClientOnly: React.FC<ClientOnlyProps> = ({
children,
ssr,
className,
}) => {
const hasMounted = useIsClientMounted();
if (!hasMounted) {
return <> {ssr} </>;
}
return (
<div className={cn("fade-in-0 animate-in duration-300", className)}>
{children}
</div>
);
};
function useIsClientMounted() {
const [hasMounted, setHasMounted] = useState(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
setHasMounted(true);
}, []);
return hasMounted;
}
@@ -10,7 +10,7 @@ export function APIHeader(props: {
}) {
return (
<div
className="mb-12 flex flex-col-reverse items-center justify-between gap-6 rounded-3xl px-6 py-8 lg:flex-row lg:px-14 lg:py-14"
className="mb-10 flex flex-col-reverse items-center justify-between gap-6 rounded-3xl px-6 py-8 lg:flex-row lg:px-10"
style={{
background:
"linear-gradient(to top, hsl(var(--secondary)/80%), transparent)",
@@ -21,7 +21,7 @@ export function APIHeader(props: {
<h1 className="mb-2 font-bold text-4xl tracking-tighter md:text-5xl">
{props.title}
</h1>
<p className="mb-5 max-w-[700px] text-balance text-muted-foreground">
<p className="mb-7 max-w-[700px] text-balance text-muted-foreground leading-relaxed">
{props.description}
</p>
@@ -0,0 +1,48 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { cn } from "@/lib/utils";
import { Img } from "../ui/Img";
const fallbackChainIcon =
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIHZpZXdCb3g9IjAgMCA5NiA5NiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTY4LjE1MTkgNzUuNzM3MkM2Mi4yOTQzIDc5Ljk5MyA1NS4yMzk3IDgyLjI4NTIgNDcuOTk5MyA4Mi4yODUyQzQwLjc1ODkgODIuMjg1MiAzMy43MDQzIDc5Ljk5MyAyNy44NDY2IDc1LjczNzJNNjMuMDI5MSAxNy4xODM3QzY5LjUzNjggMjAuMzU3NyA3NC44NzI2IDI1LjUxMDQgNzguMjcxOCAzMS45MDMzQzgxLjY3MDkgMzguMjk2MiA4Mi45NTkgNDUuNjAxMiA4MS45NTEzIDUyLjc3MTFNMTQuMDQ3NiA1Mi43NzA4QzEzLjAzOTkgNDUuNjAwOCAxNC4zMjggMzguMjk1OSAxNy43MjcxIDMxLjkwM0MyMS4xMjYzIDI1LjUxMDEgMjYuNDYyMSAyMC4zNTczIDMyLjk2OTggMTcuMTgzM000Ni4wNTk4IDI5LjM2NzVMMjkuMzY3MyA0Ni4wNkMyOC42ODg1IDQ2LjczODkgMjguMzQ5IDQ3LjA3ODMgMjguMjIxOCA0Ny40Njk3QzI4LjExIDQ3LjgxNCAyOC4xMSA0OC4xODQ5IDI4LjIyMTggNDguNTI5MkMyOC4zNDkgNDguOTIwNiAyOC42ODg1IDQ5LjI2MDEgMjkuMzY3MyA0OS45MzlMNDYuMDU5OCA2Ni42MzE0QzQ2LjczODcgNjcuMzEwMyA0Ny4wNzgxIDY3LjY0OTcgNDcuNDY5NSA2Ny43NzY5QzQ3LjgxMzggNjcuODg4OCA0OC4xODQ3IDY3Ljg4ODggNDguNTI5IDY3Ljc3NjlDNDguOTIwNCA2Ny42NDk3IDQ5LjI1OTkgNjcuMzEwMyA0OS45Mzg4IDY2LjYzMTRMNjYuNjMxMiA0OS45MzlDNjcuMzEwMSA0OS4yNjAxIDY3LjY0OTUgNDguOTIwNiA2Ny43NzY3IDQ4LjUyOTJDNjcuODg4NiA0OC4xODQ5IDY3Ljg4ODYgNDcuODE0IDY3Ljc3NjcgNDcuNDY5N0M2Ny42NDk1IDQ3LjA3ODMgNjcuMzEwMSA0Ni43Mzg5IDY2LjYzMTIgNDYuMDZMNDkuOTM4OCAyOS4zNjc1QzQ5LjI1OTkgMjguNjg4NyA0OC45MjA0IDI4LjM0OTIgNDguNTI5IDI4LjIyMkM0OC4xODQ3IDI4LjExMDIgNDcuODEzOCAyOC4xMTAyIDQ3LjQ2OTUgMjguMjIyQzQ3LjA3ODEgMjguMzQ5MiA0Ni43Mzg3IDI4LjY4ODcgNDYuMDU5OCAyOS4zNjc1WiIgc3Ryb2tlPSIjNDA0MDQwIiBzdHJva2Utd2lkdGg9IjYuODU3MTQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K";
import { resolveScheme } from "thirdweb/storage";
import { THIRDWEB_CLIENT } from "../../lib/client";
type ImageProps = React.ComponentProps<"img">;
type ChainIconProps = ImageProps & {
ipfsSrc?: string;
};
export const ChainIcon = ({ ipfsSrc, ...restProps }: ChainIconProps) => {
const src = ipfsSrc ? replaceIpfsUrl(ipfsSrc) : fallbackChainIcon;
return (
<Img
{...restProps}
// render different image element if src changes to avoid showing old image while loading new one
key={src}
className={cn("object-contain", restProps.className)}
src={src}
loading={restProps.loading || "lazy"}
alt=""
fallback={<img src={fallbackChainIcon} alt="" />}
skeleton={<div className="animate-pulse rounded-full bg-border" />}
/>
);
};
function replaceIpfsUrl(uri: string) {
try {
// eslint-disable-next-line no-restricted-syntax
return resolveScheme({
uri,
client: THIRDWEB_CLIENT,
});
} catch (err) {
console.error("error resolving ipfs url", uri, err);
return uri;
}
}
@@ -0,0 +1,112 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { useCallback, useMemo } from "react";
import { useAllChainsData } from "../../app/hooks/chains";
import { ChainIcon } from "./ChainIcon";
import { SelectWithSearch } from "./select-with-search";
function cleanChainName(chainName: string) {
return chainName.replace("Mainnet", "");
}
type Option = { label: string; value: string };
export function SingleNetworkSelector(props: {
chainId: number | undefined;
onChange: (chainId: number) => void;
className?: string;
popoverContentClassName?: string;
// if specified - only these chains will be shown
chainIds?: number[];
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
}) {
const { data } = useAllChainsData();
const { allChains, idToChain } = data;
const chainsToShow = useMemo(() => {
if (!props.chainIds) {
return allChains;
}
const chainIdSet = new Set(props.chainIds);
return allChains.filter((chain) => chainIdSet.has(chain.chainId));
}, [allChains, props.chainIds]);
const options = useMemo(() => {
return chainsToShow.map((chain) => {
return {
label: chain.name,
value: String(chain.chainId),
};
});
}, [chainsToShow]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = idToChain.get(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());
},
[idToChain],
);
const renderOption = useCallback(
(option: Option) => {
const chain = idToChain.get(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">
<ChainIcon
className="size-5"
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
{cleanChainName(chain.name)}
</span>
{!props.disableChainId && (
<Badge variant="outline" className="gap-2 max-sm:hidden">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain, props.disableChainId],
);
const isLoadingChains = allChains.length === 0;
return (
<SelectWithSearch
searchPlaceholder="Search by Name or Chain ID"
value={String(props.chainId)}
options={options}
onValueChange={(chainId) => {
props.onChange(Number(chainId));
}}
closeOnSelect={true}
placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
popoverContentClassName={props.popoverContentClassName}
disabled={isLoadingChains}
side={props.side}
align={props.align}
/>
);
}
@@ -0,0 +1,213 @@
/* eslint-disable no-restricted-syntax */
"use client";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronDown, SearchIcon } from "lucide-react";
import React, { useRef, useMemo, useEffect } from "react";
import { useShowMore } from "../../app/hooks/useShowMore";
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
import { Input } from "../ui/input";
interface SelectWithSearchProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
options: {
label: string;
value: string;
}[];
value: string | undefined;
onValueChange: (value: string) => void;
placeholder: string;
searchPlaceholder?: string;
className?: string;
overrideSearchFn?: (
option: { value: string; label: string },
searchTerm: string,
) => boolean;
renderOption?: (option: { value: string; label: string }) => React.ReactNode;
popoverContentClassName?: string;
side?: "left" | "right" | "top" | "bottom";
align?: "center" | "start" | "end";
closeOnSelect?: boolean;
}
export const SelectWithSearch = React.forwardRef<
HTMLButtonElement,
SelectWithSearchProps
>(
(
{
options,
onValueChange,
placeholder,
className,
value,
renderOption,
overrideSearchFn,
popoverContentClassName,
searchPlaceholder,
closeOnSelect,
...props
},
ref,
) => {
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [searchValue, setSearchValue] = React.useState("");
const selectedOption = useMemo(
() => options.find((option) => option.value === value),
[options, value],
);
// show 50 initially and then 20 more when reaching the end
const { itemsToShow, lastItemRef } = useShowMore<HTMLButtonElement>(50, 20);
const optionsToShow = useMemo(() => {
const filteredOptions: {
label: string;
value: string;
}[] = [];
const searchValLowercase = searchValue.toLowerCase();
for (let i = 0; i <= options.length - 1; i++) {
if (filteredOptions.length >= itemsToShow) {
break;
}
const option = options[i];
if (!option) {
continue;
}
if (overrideSearchFn) {
if (overrideSearchFn(option, searchValLowercase)) {
filteredOptions.push(option);
}
} else {
if (option.label.toLowerCase().includes(searchValLowercase)) {
filteredOptions.push(option);
}
}
}
return filteredOptions;
}, [options, searchValue, itemsToShow, overrideSearchFn]);
// scroll to top when options change
const popoverElRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
const scrollContainer =
popoverElRef.current?.querySelector("[data-scrollable]");
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
});
}
}, [searchValue]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={() => setIsPopoverOpen(true)}
className={cn(
"flex w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
className,
)}
>
<div className="flex w-full items-center justify-between gap-2">
<span
className={cn(
"truncate text-muted-foreground text-sm",
selectedOption && "text-foreground",
)}
>
{selectedOption?.label || placeholder}
</span>
<ChevronDown className="size-4 cursor-pointer text-muted-foreground" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={cn("z-[10001] p-0", popoverContentClassName)}
align={props.align || "center"}
side={props.side}
sideOffset={10}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
style={{
width: "var(--radix-popover-trigger-width)",
maxHeight: "var(--radix-popover-content-available-height)",
}}
ref={popoverElRef}
>
<div>
{/* Search */}
<div className="relative">
<Input
placeholder={searchPlaceholder || "Search"}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="!h-auto rounded-b-none border-0 border-border border-b py-3 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
</div>
<ScrollShadow
scrollableClassName="max-h-[min(calc(var(--radix-popover-content-available-height)-60px),350px)] p-1"
className="rounded"
>
{/* List */}
<div>
{optionsToShow.length === 0 && (
<div className="flex justify-center py-10">
No results found
</div>
)}
{optionsToShow.map((option, i) => {
const isSelected = value === option.value;
return (
<Button
key={option.value}
role="option"
aria-selected={isSelected}
onClick={() => {
onValueChange(option.value);
if (closeOnSelect) {
setIsPopoverOpen(false);
}
}}
variant="ghost"
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
ref={
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
>
<div className="flex size-4 items-center justify-center">
{isSelected && <CheckIcon className="size-4" />}
</div>
<div className="min-w-0 grow">
{renderOption ? renderOption(option) : option.label}
</div>
</Button>
);
})}
</div>
</ScrollShadow>
</div>
</PopoverContent>
</Popover>
);
},
);
SelectWithSearch.displayName = "SelectWithSearch";
@@ -7,17 +7,21 @@ export function RenderCode(props: {
html: string;
className?: string;
scrollableClassName?: string;
scrollableContainerClassName?: string;
}) {
return (
<div
className={cn(
"group relative max-w-full rounded-xl border bg-code",
"group relative max-w-full overflow-hidden rounded-xl border bg-code",
props.className,
)}
>
<ScrollShadow
scrollableClassName={cn("p-4 lg:p-6", props.scrollableClassName)}
className="text-xs md:text-sm [&_*]:leading-relaxed"
className={cn(
"text-xs md:text-sm [&_*]:leading-relaxed",
props.scrollableContainerClassName,
)}
shadowColor="hsl(var(--code))"
>
<div
@@ -1,5 +1,6 @@
import type { JSX } from "react";
import type { BundledLanguage } from "shiki";
import { ClientOnly } from "../ClientOnly";
import { Code } from "./code";
export type CodeExampleProps = {
@@ -23,7 +24,7 @@ export const CodeExample: React.FC<CodeExampleProps> = ({
/>
</div>
<div className="relative grid h-full min-h-[300px] place-items-center bg-secondary/10 py-8">
{preview}
<ClientOnly ssr={null}>{preview}</ClientOnly>
<BackgroundPattern />
</div>
</div>
@@ -13,6 +13,7 @@ export type CodeProps = {
loader: React.ReactNode;
className?: string;
scrollableClassName?: string;
scrollableContainerClassName?: string;
};
export function CodeLoading() {
@@ -29,6 +30,7 @@ export const CodeClient: React.FC<CodeProps> = ({
loader,
className,
scrollableClassName,
scrollableContainerClassName,
}) => {
const codeQuery = useQuery({
queryKey: ["html", code],
@@ -46,6 +48,7 @@ export const CodeClient: React.FC<CodeProps> = ({
html={codeQuery.data.html}
className={className}
scrollableClassName={scrollableClassName}
scrollableContainerClassName={scrollableContainerClassName}
/>
);
};
@@ -1,14 +1,29 @@
import * as parserBabel from "prettier/plugins/babel";
import * as estree from "prettier/plugins/estree";
import { format } from "prettier/standalone";
import { codeToHtml } from "shiki";
import { type BundledLanguage, codeToHtml } from "shiki";
export async function getCodeHtml(code: string, lang: string) {
const formattedCode = await format(code, {
parser: "babel-ts",
plugins: [parserBabel, estree],
printWidth: 60,
});
function isPrettierSupportedLang(lang: BundledLanguage) {
return (
lang === "js" ||
lang === "jsx" ||
lang === "ts" ||
lang === "tsx" ||
lang === "javascript" ||
lang === "typescript"
);
}
export async function getCodeHtml(code: string, lang: BundledLanguage) {
const formattedCode = isPrettierSupportedLang(lang)
? await format(code, {
parser: "babel-ts",
plugins: [parserBabel, estree],
printWidth: 60,
}).catch(() => {
return code;
})
: code;
const html = await codeToHtml(formattedCode, {
lang: lang,
@@ -1,20 +1,17 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { ethereum } from "thirdweb/chains";
import {
AccountAddress,
AccountAvatar,
AccountBalance,
type AccountBalanceInfo,
AccountBlobbie,
AccountName,
AccountProvider,
} from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import { CodeExample } from "../code/code-example";
const vitalikAddress = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045";
import {
AccountAddressBasicPreview,
AccountAddressFormatPreview,
AccountAvatarBasicPreview,
AccountBalanceBasicPreview,
AccountBalanceCustomTokenPreview,
AccountBalanceFormatPreview,
AccountBalanceUSDPreview,
AccountBlobbieBasicPreview,
AccountNameBasicPreview,
AccountNameCustomPreview,
ConnectDetailsButtonClonePreview,
} from "./account-previews";
/**
* Show full wallet address with AccountAddress
@@ -32,11 +29,7 @@ export function AccountAddressBasic() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAddress />
</AccountProvider>
}
preview={<AccountAddressBasicPreview />}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
function App() {
@@ -63,11 +56,7 @@ export function AccountAddressFormat() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAddress formatFn={shortenAddress} />
</AccountProvider>
}
preview={<AccountAddressFormatPreview />}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
@@ -98,11 +87,7 @@ export function AccountNameBasic() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountName loadingComponent={<span>Loading...</span>} />
</AccountProvider>
}
preview={<AccountNameBasicPreview />}
code={`import { AccountProvider, AccountName } from "thirdweb/react";
function App() {
@@ -126,14 +111,7 @@ export function AccountNameCustom() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountName
socialType="lens"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
preview={<AccountNameCustomPreview />}
code={`import { AccountProvider, AccountName } from "thirdweb/react";
function App() {
@@ -164,21 +142,14 @@ export function AccountBalanceBasic() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
preview={<AccountBalanceBasicPreview />}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBalance
chain={ethereum}
<AccountBalance
chain={ethereum}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
@@ -202,15 +173,7 @@ export function AccountBalanceCustomToken() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
tokenAddress="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
preview={<AccountBalanceCustomTokenPreview />}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
@@ -218,9 +181,9 @@ const USDC_ETHEREUM = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBalance
chain={ethereum}
tokenAddress={USDC_ETHEREUM}
<AccountBalance
chain={ethereum}
tokenAddress={USDC_ETHEREUM}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
@@ -244,23 +207,13 @@ export function AccountBalanceFormat() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
formatFn={(props: AccountBalanceInfo) =>
`${Math.ceil(props.balance * 1000) / 1000} ${props.symbol}`
}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
preview={<AccountBalanceFormatPreview />}
code={`import { AccountProvider, AccountAddress, type AccountBalanceFormatParams } from "thirdweb/react";
function App() {
return <AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBalance
chain={ethereum}
<AccountBalance
chain={ethereum}
loadingComponent={<span>Loading...</span>}
formatFn={(props: AccountBalanceInfo) => \`\${Math.ceil(props.balance * 1000) / 1000} \${props.symbol}\`} />
</AccountProvider>
@@ -284,23 +237,15 @@ export function AccountBalanceUSD() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
showBalanceInFiat="USD"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
preview={<AccountBalanceUSDPreview />}
code={`import { AccountProvider, AccountAddress } from "thirdweb/react";
function App() {
return (
<AccountProvider address="0xd8da6bf26964af9d7eed9e03e53415d37aa96045" client={thirdwebClient}>
<AccountBalance
chain={ethereum}
showBalanceInFiat="USD"
<AccountBalance
chain={ethereum}
showBalanceInFiat="USD"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
@@ -328,14 +273,7 @@ export function AccountAvatarBasic() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAvatar
className="h-20 w-20 rounded-full"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
}
preview={<AccountAvatarBasicPreview />}
code={`import { AccountProvider, AccountAvatar } from "thirdweb/react";
function App() {
@@ -362,11 +300,7 @@ export function AccountBlobbieBasic() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBlobbie className="h-20 w-20 rounded-full" />
</AccountProvider>
}
preview={<AccountBlobbieBasicPreview />}
code={`import { AccountProvider, AccountBlobbie } from "thirdweb/react";
function App() {
@@ -394,58 +328,7 @@ export function ConnectDetailsButtonClone() {
</div>
<CodeExample
preview={
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<button
type="button"
className="flex min-w-[200px] flex-row items-center gap-2 rounded-md border bg-gray-800 px-4 py-2"
>
<AccountAvatar
className="h-10 w-10 rounded-full"
loadingComponent={
<AccountBlobbie className="h-10 w-10 rounded-full" />
}
fallbackComponent={
<AccountBlobbie className="h-10 w-10 rounded-full" />
}
/>
<div className="flex flex-col gap-1">
<AccountName
className="text-left"
loadingComponent={
<AccountAddress
formatFn={shortenAddress}
className="text-left"
/>
}
fallbackComponent={
<AccountAddress
formatFn={shortenAddress}
className="text-left"
/>
}
/>
<div className="flex flex-row items-center gap-2">
<AccountBalance
className="text-left text-sm"
chain={ethereum}
loadingComponent={
<span className="text-left text-sm">Loading...</span>
}
fallbackComponent={
<span className="text-left text-sm">Loading...</span>
}
/>
<AccountBalance
className="text-left text-sm"
chain={ethereum}
showBalanceInFiat="USD"
/>
</div>
</div>
</button>
</AccountProvider>
}
preview={<ConnectDetailsButtonClonePreview />}
code={`import { AccountProvider, AccountAvatar, AccountName, AccountBalance, AccountAddress, AccountBlobbie } from "thirdweb/react";
function App() {
@@ -499,7 +382,7 @@ function App() {
</div>
</div>
</button>
</AccountProvider>
</AccountProvider>
)
}`}
lang="tsx"
@@ -0,0 +1,168 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { ethereum } from "thirdweb/chains";
import {
AccountAddress,
AccountAvatar,
AccountBalance,
type AccountBalanceInfo,
AccountBlobbie,
AccountName,
AccountProvider,
} from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
const vitalikAddress = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045";
export function AccountAddressBasicPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAddress />
</AccountProvider>
);
}
export function AccountAddressFormatPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAddress formatFn={shortenAddress} />
</AccountProvider>
);
}
export function AccountNameBasicPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountName loadingComponent={<span>Loading...</span>} />
</AccountProvider>
);
}
export function AccountNameCustomPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountName
socialType="lens"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
);
}
export function AccountBalanceBasicPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
);
}
export function AccountBalanceCustomTokenPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
tokenAddress="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
);
}
export function AccountBalanceFormatPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
formatFn={(props: AccountBalanceInfo) =>
`${Math.ceil(props.balance * 1000) / 1000} ${props.symbol}`
}
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
);
}
export function AccountBalanceUSDPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBalance
chain={ethereum}
showBalanceInFiat="USD"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
);
}
export function AccountAvatarBasicPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountAvatar
className="h-20 w-20 rounded-full"
loadingComponent={<span>Loading...</span>}
/>
</AccountProvider>
);
}
export function AccountBlobbieBasicPreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<AccountBlobbie className="h-20 w-20 rounded-full" />
</AccountProvider>
);
}
export function ConnectDetailsButtonClonePreview() {
return (
<AccountProvider address={vitalikAddress} client={THIRDWEB_CLIENT}>
<button
type="button"
className="flex min-w-[200px] flex-row items-center gap-2 rounded-md border bg-zinc-900 px-4 py-2"
>
<AccountAvatar
className="h-10 w-10 rounded-full"
loadingComponent={
<AccountBlobbie className="h-10 w-10 rounded-full" />
}
fallbackComponent={
<AccountBlobbie className="h-10 w-10 rounded-full" />
}
/>
<div className="flex flex-col gap-1">
<AccountName
className="text-left"
loadingComponent={
<AccountAddress formatFn={shortenAddress} className="text-left" />
}
fallbackComponent={
<AccountAddress formatFn={shortenAddress} className="text-left" />
}
/>
<div className="flex flex-row items-center gap-2">
<AccountBalance
className="text-left text-sm"
chain={ethereum}
loadingComponent={
<span className="text-left text-sm">Loading...</span>
}
fallbackComponent={
<span className="text-left text-sm">Loading...</span>
}
/>
<AccountBalance
className="text-left text-sm"
chain={ethereum}
showBalanceInFiat="USD"
/>
</div>
</div>
</button>
</AccountProvider>
);
}
@@ -1,9 +1,5 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { avalanche } from "thirdweb/chains";
import { ChainIcon, ChainName, ChainProvider } from "thirdweb/react";
import { CodeExample } from "../code/code-example";
import { ChainIconPreview, ChainNamePreview } from "./chain-previews";
export function ChainIconBasic() {
return (
@@ -18,15 +14,7 @@ export function ChainIconBasic() {
</div>
<CodeExample
preview={
<ChainProvider chain={avalanche}>
<ChainIcon
client={THIRDWEB_CLIENT}
className="h-auto w-20 rounded-full"
loadingComponent={<span>Loading...</span>}
/>
</ChainProvider>
}
preview={<ChainIconPreview />}
code={`import { ChainProvider, ChainIcon } from "thirdweb/react";
function App() {
@@ -57,11 +45,7 @@ export function ChainNameBasic() {
</div>
<CodeExample
preview={
<ChainProvider chain={avalanche}>
<ChainName loadingComponent={<span>Loading...</span>} />
</ChainProvider>
}
preview={<ChainNamePreview />}
code={`import { ChainProvider, ChainName } from "thirdweb/react";
function App() {
@@ -0,0 +1,25 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { avalanche } from "thirdweb/chains";
import { ChainIcon, ChainName, ChainProvider } from "thirdweb/react";
export function ChainIconPreview() {
return (
<ChainProvider chain={avalanche}>
<ChainIcon
client={THIRDWEB_CLIENT}
className="h-auto w-20 rounded-full"
loadingComponent={<span>Loading...</span>}
/>
</ChainProvider>
);
}
export function ChainNamePreview() {
return (
<ChainProvider chain={avalanche}>
<ChainName loadingComponent={<span>Loading...</span>} />
</ChainProvider>
);
}
@@ -1,16 +1,11 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { getContract } from "thirdweb";
import { ethereum } from "thirdweb/chains";
import { NFTDescription, NFTMedia, NFTName, NFTProvider } from "thirdweb/react";
import { CodeExample } from "../code/code-example";
const nftContract = getContract({
address: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8",
chain: ethereum,
client: THIRDWEB_CLIENT,
});
import {
NftCardDemoPreview,
NftDescriptionBasicPreview,
NftMediaBasicPreview,
NftMediaOverridePreview,
NftNameBasicPreview,
} from "./nft-previews";
export function NftMediaBasic() {
return (
@@ -25,14 +20,7 @@ export function NftMediaBasic() {
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTMedia
className="h-40 w-40 rounded-md"
loadingComponent={<span>Loading...</span>}
/>
</NFTProvider>
}
preview={<NftMediaBasicPreview />}
code={`import { NFTProvider, NFTMedia } from "thirdweb/react";
function App() {
@@ -60,18 +48,7 @@ export function NftMediaOverride() {
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTMedia
className="h-40 w-40 rounded-md border"
mediaResolver={{
src: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4",
poster:
"ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png",
}}
/>
</NFTProvider>
}
preview={<NftMediaOverridePreview />}
code={`import { NFTProvider, NFTMedia } from "thirdweb/react";
function App() {
@@ -104,11 +81,7 @@ export function NftNameBasic() {
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTName loadingComponent={<span>Loading...</span>} />
</NFTProvider>
}
preview={<NftNameBasicPreview />}
code={`import { NFTProvider, NFTName } from "thirdweb/react";
function App() {
@@ -135,14 +108,7 @@ export function NftDescriptionBasic() {
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTDescription
className="text-center"
loadingComponent={<span>Loading...</span>}
/>
</NFTProvider>
}
preview={<NftDescriptionBasicPreview />}
code={`import { NFTProvider, NFTDescription } from "thirdweb/react";
function App() {
@@ -170,24 +136,13 @@ export function NftCardDemo() {
</div>
<CodeExample
preview={
<NFTProvider tokenId={0n} contract={nftContract}>
<div className="flex w-[230px] flex-col gap-3 rounded-lg border bg-gray-900 px-1 py-3">
<NFTMedia className="rounded-md px-2 text-center" />
<NFTName className="px-2 font-bold" />
<NFTDescription
className="px-2 text-sm"
loadingComponent={<span>Loading...</span>}
/>
</div>
</NFTProvider>
}
preview={<NftCardDemoPreview />}
code={`import { NFTProvider, NFTDescription, NFTName, NFTMedia } from "thirdweb/react";
function App() {
return (
<NFTProvider tokenId={0n} contract={nftContract}>
<div className="flex w-[230px] flex-col gap-3 rounded-lg border bg-gray-900 px-1 py-3">
<div className="flex w-[230px] flex-col gap-3 rounded-lg border bg-zinc-900 px-1 py-3">
<NFTMedia className="rounded-md px-2 text-center" />
<NFTName className="px-2 font-bold" />
<NFTDescription
@@ -0,0 +1,71 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { getContract } from "thirdweb";
import { ethereum } from "thirdweb/chains";
import { NFTDescription, NFTMedia, NFTName, NFTProvider } from "thirdweb/react";
const nftContract = getContract({
address: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8",
chain: ethereum,
client: THIRDWEB_CLIENT,
});
export function NftMediaBasicPreview() {
return (
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTMedia
className="h-40 w-40 rounded-md"
loadingComponent={<span>Loading...</span>}
/>
</NFTProvider>
);
}
export function NftMediaOverridePreview() {
return (
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTMedia
className="h-40 w-40 rounded-md border"
mediaResolver={{
src: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4",
poster: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png",
}}
/>
</NFTProvider>
);
}
export function NftNameBasicPreview() {
return (
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTName loadingComponent={<span>Loading...</span>} />
</NFTProvider>
);
}
export function NftDescriptionBasicPreview() {
return (
<NFTProvider tokenId={0n} contract={nftContract}>
<NFTDescription
className="block px-6 text-center"
loadingComponent={<span>Loading...</span>}
/>
</NFTProvider>
);
}
export function NftCardDemoPreview() {
return (
<NFTProvider tokenId={0n} contract={nftContract}>
<div className="flex w-[230px] flex-col gap-3 rounded-lg border bg-zinc-900 px-1 py-3">
<NFTMedia className="rounded-md px-2 text-center" />
<NFTName className="px-2 font-bold" />
<NFTDescription
className="px-2 text-sm"
loadingComponent={<span>Loading...</span>}
/>
</div>
</NFTProvider>
);
}
@@ -1,17 +1,11 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { ethereum } from "thirdweb/chains";
import {
TokenIcon,
TokenName,
TokenProvider,
TokenSymbol,
} from "thirdweb/react";
import { CodeExample } from "../code/code-example";
const USDC_ADDRESS = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
import {
TokenCardPreview,
TokenImageBasicPreview,
TokenImageOverridePreview,
TokenNameBasicPreview,
TokenSymbolBasicPreview,
} from "./token-previews";
export function TokenImageBasic() {
return (
@@ -26,15 +20,7 @@ export function TokenImageBasic() {
</div>
<CodeExample
preview={
<TokenProvider
address={NATIVE_TOKEN_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<TokenIcon className="h-auto w-20 rounded-full" />
</TokenProvider>
}
preview={<TokenImageBasicPreview />}
code={`import { TokenProvider, TokenIcon } from "thirdweb/react";
function App() {
@@ -68,18 +54,7 @@ export function TokenImageOverride() {
</div>
<CodeExample
preview={
<TokenProvider
address={USDC_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<TokenIcon
className="h-auto w-20 rounded-full"
iconResolver="/usdc.svg"
/>
</TokenProvider>
}
preview={<TokenImageOverridePreview />}
code={`import { TokenProvider, TokenIcon } from "thirdweb/react";
function App() {
@@ -114,15 +89,7 @@ export function TokenNameBasic() {
</div>
<CodeExample
preview={
<TokenProvider
address={USDC_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<TokenName loadingComponent={<span>Loading...</span>} />
</TokenProvider>
}
preview={<TokenNameBasicPreview />}
code={`import { TokenProvider, TokenName } from "thirdweb/react";
function App() {
@@ -153,15 +120,7 @@ export function TokenSymbolBasic() {
</div>
<CodeExample
preview={
<TokenProvider
address={USDC_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<TokenSymbol loadingComponent={<span>Loading...</span>} />
</TokenProvider>
}
preview={<TokenSymbolBasicPreview />}
code={`import { TokenProvider, TokenSymbol } from "thirdweb/react";
function App() {
@@ -194,27 +153,7 @@ export function TokenCard() {
</div>
<CodeExample
preview={
<TokenProvider
address={USDC_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<div className="flex flex-row items-center gap-2 rounded-lg border bg-slate-950 px-4 py-1">
<TokenIcon className="h-10 w-10" iconResolver="/usdc.svg" />
<div className="flex flex-col">
<TokenName
className="font-bold"
loadingComponent={<span>Loading...</span>}
/>
<TokenSymbol
className="text-gray-500"
loadingComponent={<span>Loading...</span>}
/>
</div>
</div>
</TokenProvider>
}
preview={<TokenCardPreview />}
code={`import { TokenProvider, TokenSymbol } from "thirdweb/react";
function App() {
@@ -0,0 +1,88 @@
"use client";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { ethereum } from "thirdweb/chains";
import {
TokenIcon,
TokenName,
TokenProvider,
TokenSymbol,
} from "thirdweb/react";
const USDC_ADDRESS = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
export function TokenImageBasicPreview() {
return (
<TokenProvider
address={NATIVE_TOKEN_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<TokenIcon className="h-auto w-20 rounded-full" />
</TokenProvider>
);
}
export function TokenImageOverridePreview() {
return (
<TokenProvider
address={USDC_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<TokenIcon
className="h-auto w-20 rounded-full"
iconResolver="/usdc.svg"
/>
</TokenProvider>
);
}
export function TokenNameBasicPreview() {
return (
<TokenProvider
address={USDC_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<TokenName loadingComponent={<span>Loading...</span>} />
</TokenProvider>
);
}
export function TokenSymbolBasicPreview() {
return (
<TokenProvider
address={USDC_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<TokenSymbol loadingComponent={<span>Loading...</span>} />
</TokenProvider>
);
}
export function TokenCardPreview() {
return (
<TokenProvider
address={USDC_ADDRESS}
client={THIRDWEB_CLIENT}
chain={ethereum}
>
<div className="flex flex-row items-center gap-2 rounded-lg border bg-slate-950 px-4 py-1">
<TokenIcon className="h-10 w-10" iconResolver="/usdc.svg" />
<div className="flex flex-col">
<TokenName
className="font-bold"
loadingComponent={<span>Loading...</span>}
/>
<TokenSymbol
className="text-gray-500"
loadingComponent={<span>Loading...</span>}
/>
</div>
</div>
</TokenProvider>
);
}
@@ -1,7 +1,9 @@
"use client";
import { WalletIcon, WalletName, WalletProvider } from "thirdweb/react";
import { CodeExample } from "../code/code-example";
import {
WalletIconBasicPreview,
WalletNameBasicPreview,
WalletNameFormatPreview,
} from "./wallet-previews";
export function WalletIconBasic() {
return (
@@ -16,14 +18,7 @@ export function WalletIconBasic() {
</div>
<CodeExample
preview={
<WalletProvider id="io.metamask">
<WalletIcon
className="h-20 w-20 rounded-full"
loadingComponent={<span>Loading...</span>}
/>
</WalletProvider>
}
preview={<WalletIconBasicPreview />}
code={`import { WalletProvider, WalletIcon } from "thirdweb/react";
function App() {
@@ -55,11 +50,7 @@ export function WalletNameBasic() {
</div>
<CodeExample
preview={
<WalletProvider id="io.metamask">
<WalletName loadingComponent={<span>Loading...</span>} />
</WalletProvider>
}
preview={<WalletNameBasicPreview />}
code={`import { WalletProvider, WalletName } from "thirdweb/react";
function App() {
@@ -85,18 +76,14 @@ export function WalletNameFormat() {
</div>
<CodeExample
preview={
<WalletProvider id="io.metamask">
<WalletName formatFn={(str: string) => `${str} Wallet`} />
</WalletProvider>
}
preview={<WalletNameFormatPreview />}
code={`import { WalletProvider, WalletName } from "thirdweb/react";
function App() {
return (
<WalletProvider id="io.metamask">
<WalletName
loadingComponent={<span>Loading...</span>}
<WalletName
loadingComponent={<span>Loading...</span>}
formatFn={(str: string) => \`\${str} Wallet\`}
/>
</WalletProvider>
@@ -0,0 +1,33 @@
"use client";
import { WalletIcon, WalletName, WalletProvider } from "thirdweb/react";
export function WalletIconBasicPreview() {
return (
<WalletProvider id="io.metamask">
<WalletIcon
className="h-20 w-20 rounded-full"
loadingComponent={<span>Loading...</span>}
/>
</WalletProvider>
);
}
export function WalletNameBasicPreview() {
return (
<WalletProvider id="io.metamask">
<WalletName loadingComponent={<span>Loading...</span>} />
</WalletProvider>
);
}
export function WalletNameFormatPreview() {
return (
<WalletProvider id="io.metamask">
<WalletName
formatFn={(str: string) => `${str} Wallet`}
loadingComponent={<span>Loading...</span>}
/>
</WalletProvider>
);
}
@@ -10,7 +10,7 @@ export function StyledPayEmbedPreview() {
const { theme } = useTheme();
return (
<>
<div className="flex flex-col items-center justify-center">
<StyledConnectButton />
<div className="h-10" />
<PayEmbed
@@ -27,6 +27,6 @@ export function StyledPayEmbedPreview() {
},
}}
/>
</>
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { cn } from "@/lib/utils";
import { Link as LinkIcon } from "lucide-react";
export function Anchor(props: {
id: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"group/anchor flex scroll-mt-offset-top-mobile items-center gap-2 xl:scroll-mt-offset-top",
props.className,
)}
id={props.id}
>
{props.children}
{props.id && (
<a
href={`#${props.id}`}
className="text-muted-foreground no-underline opacity-0 transition-opacity group-hover/anchor:opacity-100"
onClick={(e) => {
e.stopPropagation();
}}
>
<LinkIcon className="size-4" />
</a>
)}
</div>
);
}
@@ -3,9 +3,11 @@
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { Anchor } from "./Anchor";
import { DynamicHeight } from "./DynamicHeight";
type CustomAccordionProps = {
id?: string;
chevronPosition?: "left" | "right";
trigger: React.ReactNode;
children: React.ReactNode;
@@ -18,52 +20,73 @@ type CustomAccordionProps = {
export function CustomAccordion(props: CustomAccordionProps) {
const [isOpen, setIsOpen] = useState(props.defaultOpen || false);
const elRef = useRef<HTMLDivElement>(null);
const contentId = useId();
const buttonId = useId();
const accordionContentRef = useRef<HTMLDivElement>(null);
// when another accordion is opened, close this one
const accordionAnchorId = props.id;
// if window hash matches current accordion's id, open it
const accordionIdMatchChecked = useRef(false);
useEffect(() => {
if (!isOpen) {
if (accordionIdMatchChecked.current) {
return;
}
accordionIdMatchChecked.current = true;
const hash = window.location.hash;
if (hash && hash === `#${accordionAnchorId}`) {
setTimeout(() => {
setIsOpen(true);
setTimeout(() => {
accordionContentRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, 500);
}, 500);
}
}, [accordionAnchorId]);
// if window hash matches any child accordion's id, open it
const accordionContentChecked = useRef(false);
useEffect(() => {
const accordionContentEl = accordionContentRef.current;
if (!accordionContentEl || accordionContentChecked.current) {
return;
}
const selfEl = elRef.current;
accordionContentChecked.current = true;
if (!selfEl) {
const hash = window.location.hash;
if (!hash) {
return;
}
const allAccordions = selfEl.parentElement?.querySelectorAll(
"[data-custom-accordion]",
);
// if any child element has an anchor tag with href that matches the hash, open the accordion
const containsMatchingAnchor = Array.from(
accordionContentEl.querySelectorAll("a[href^='#']"),
).find((childAccordion) => {
const href = childAccordion.getAttribute("href");
return href === hash;
});
if (!allAccordions) {
return;
if (containsMatchingAnchor) {
setTimeout(() => {
setIsOpen(true);
setTimeout(() => {
containsMatchingAnchor.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, 500);
}, 500);
}
for (const accordion of allAccordions) {
if (!(accordion instanceof HTMLElement)) {
continue;
}
if (accordion === selfEl) {
continue;
}
accordion.addEventListener("click", () => {
if (isOpen) {
setIsOpen(false);
}
});
}
}, [isOpen]);
}, []);
return (
<div
className={cn("border-b", props.containerClassName)}
data-custom-accordion
ref={elRef}
>
<button
type="button"
@@ -91,7 +114,11 @@ export function CustomAccordion(props: CustomAccordionProps) {
)}
/>
{props.trigger}
{props.id ? (
<Anchor id={props.id}> {props.trigger} </Anchor>
) : (
props.trigger
)}
</button>
<DynamicHeight>
@@ -107,7 +134,9 @@ export function CustomAccordion(props: CustomAccordionProps) {
!isOpen && "hidden",
)}
>
<div className="pt-1 pb-2">{props.children}</div>
<div className="pt-1 pb-4" ref={accordionContentRef}>
{props.children}
</div>
</div>
</div>
</DynamicHeight>
+66 -11
View File
@@ -1,29 +1,84 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { useState } from "react";
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import { cn } from "../../lib/utils";
type imgElementProps = React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>;
> & {
skeleton?: React.ReactNode;
fallback?: React.ReactNode;
src: string | undefined;
};
export function Img(props: imgElementProps) {
const [isLoading, setIsLoading] = useState(true);
const [_status, setStatus] = useState<"pending" | "fallback" | "loaded">(
"pending",
);
const status =
props.src === undefined
? "pending"
: props.src === ""
? "fallback"
: _status;
const { className, fallback, skeleton, ...restProps } = props;
const defaultSkeleton = <div className="animate-pulse bg-accent" />;
const defaultFallback = <div className="bg-accent" />;
const imgRef = useRef<HTMLImageElement>(null);
useLayoutEffect(() => {
const imgEl = imgRef.current;
if (!imgEl) {
return;
}
if (imgEl.complete) {
setStatus("loaded");
} else {
function handleLoad() {
setStatus("loaded");
}
imgEl.addEventListener("load", handleLoad);
return () => {
imgEl.removeEventListener("load", handleLoad);
};
}
}, []);
return (
<div className="relative">
{/* biome-ignore lint/a11y/useAltText: <explanation> */}
<img
{...props}
onLoad={() => {
setIsLoading(false);
{...restProps}
// avoid setting empty src string to prevent request to the entire page
src={restProps.src || undefined}
ref={imgRef}
onError={() => {
setStatus("fallback");
}}
style={{
opacity: isLoading ? 0 : 1,
opacity: status === "loaded" ? 1 : 0,
...restProps.style,
}}
alt={restProps.alt || ""}
className={cn(
"fade-in-0 object-cover transition-opacity duration-300",
className,
)}
decoding="async"
/>
{isLoading && (
<div className="absolute inset-0 flex animate-pulse items-center justify-center rounded-lg bg-accent" />
{status !== "loaded" && (
<div
style={restProps.style}
className={cn(
"fade-in-0 absolute inset-0 overflow-hidden transition-opacity duration-300 [&>*]:h-full [&>*]:w-full",
className,
)}
>
{status === "pending" && (skeleton || defaultSkeleton)}
{status === "fallback" && (fallback || defaultFallback)}
</div>
)}
</div>
);
@@ -1,7 +1,7 @@
.scrollShadowY {
position: absolute;
left: 0;
z-index: 1;
z-index: 10;
width: 100%;
height: 40px;
pointer-events: none;
@@ -10,7 +10,7 @@
.scrollShadowX {
position: absolute;
top: 0;
z-index: 1;
z-index: 10;
width: 40px;
height: 100%;
pointer-events: none;
@@ -10,6 +10,7 @@ export function ScrollShadow(props: {
scrollableClassName?: string;
disableTopShadow?: boolean;
shadowColor?: string;
shadowClassName?: string;
}) {
const scrollableEl = useRef<HTMLDivElement>(null);
const shadowTopEl = useRef<HTMLDivElement>(null);
@@ -30,20 +31,21 @@ export function ScrollShadow(props: {
return;
}
const contentScrollHeight = content.scrollHeight - wrapper.offsetHeight;
const contentScrollWidth = content.scrollWidth - wrapper.offsetWidth;
function handleScroll() {
if (
!content ||
!shadowTop ||
!shadowBottom ||
!shadowLeft ||
!shadowRight
!shadowRight ||
!wrapper
) {
return;
}
const contentScrollHeight = content.scrollHeight - wrapper.offsetHeight;
const contentScrollWidth = content.scrollWidth - wrapper.offsetWidth;
if (contentScrollHeight > 10) {
const currentScroll = content.scrollTop / contentScrollHeight;
shadowTop.style.opacity = `${currentScroll}`;
@@ -63,25 +65,40 @@ export function ScrollShadow(props: {
}
}
handleScroll();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
handleScroll();
});
});
content.addEventListener("scroll", handleScroll);
const resizeObserver = new ResizeObserver(() => {
handleScroll();
});
resizeObserver.observe(content);
return () => {
content.removeEventListener("scroll", handleScroll);
resizeObserver.disconnect();
};
}, []);
return (
<div
className={cn(props.className, "relative z-base overflow-hidden")}
className={cn(props.className, "relative overflow-hidden")}
ref={wrapperEl}
style={
{
"--shadow": props.shadowColor || "hsl(var(--background))",
"--shadow": props.shadowColor || "hsl(var(--muted))",
} as React.CSSProperties
}
>
<div
className={cn(styles.scrollShadowTop, styles.scrollShadowY)}
className={cn(
styles.scrollShadowTop,
styles.scrollShadowY,
props.shadowClassName,
)}
ref={shadowTopEl}
style={{
opacity: "0",
@@ -89,32 +106,42 @@ export function ScrollShadow(props: {
}}
/>
<div
className={cn(styles.scrollShadowBottom, styles.scrollShadowY)}
className={cn(
styles.scrollShadowBottom,
styles.scrollShadowY,
props.shadowClassName,
)}
ref={shadowBottomEl}
style={{
opacity: "0",
}}
/>
<div
className={cn(styles.scrollShadowLeft, styles.scrollShadowX)}
className={cn(
styles.scrollShadowLeft,
styles.scrollShadowX,
props.shadowClassName,
)}
ref={shadowLeftEl}
style={{
opacity: "0",
}}
/>
<div
className={cn(styles.scrollShadowRight, styles.scrollShadowX)}
className={cn(
styles.scrollShadowRight,
styles.scrollShadowX,
props.shadowClassName,
)}
ref={shadowRightEl}
style={{
opacity: "0",
}}
/>
<div
className={cn("overflow-auto", props.scrollableClassName)}
style={{
scrollbarWidth: "none",
}}
className={cn("no-scrollbar overflow-auto", props.scrollableClassName)}
ref={scrollableEl}
data-scrollable
>
{props.children}
</div>
@@ -0,0 +1,39 @@
.loader {
border-radius: 50%;
position: relative;
animation: rotate 1s linear infinite;
animation: rotate 2s linear infinite;
}
.loader circle {
content: "";
box-sizing: border-box;
position: absolute;
inset: 0px;
border-radius: 50%;
border: 4px solid #fff;
animation: prixClipFix 2s linear infinite;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
@@ -0,0 +1,21 @@
import { cn } from "../../../lib/utils";
import style from "./Spinner.module.css";
export function Spinner(props: { className?: string }) {
return (
<svg
viewBox="0 0 50 50"
className={cn(style.loader, props.className || "size-4")}
>
<title>loading</title>
<circle
cx="25"
cy="25"
r="20"
fill="none"
stroke="currentColor"
strokeWidth="4"
/>
</svg>
);
}
@@ -0,0 +1,63 @@
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"bg-card relative w-full rounded-lg border border-border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "text-foreground",
destructive: "text-destructive-text [&>svg]:text-destructive-text",
info: "text-link-foreground [&>svg]:text-link-foreground",
warning: "text-warning-text [&>svg]:text-warning-text",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("font-medium leading-tight tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"mt-1.5 text-muted-foreground text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
@@ -4,17 +4,20 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border border-border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 leading-4",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
default: "border-transparent bg-primary/20 text-primary",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border-transparent bg-accent text-accent-foreground hover:bg-accent/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
"border-transparent dark:bg-red-950 dark:text-red-400 bg-red-500/20 text-red-800",
warning:
"border-transparent dark:bg-yellow-600/20 dark:text-yellow-500 bg-yellow-500/20 text-yellow-900",
outline: "text-foreground",
success:
"border-transparent dark:bg-green-950/50 dark:text-green-400 bg-green-200 text-green-950",
},
},
defaultVariants: {
@@ -0,0 +1,116 @@
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-muted-foreground text-sm sm:gap-2.5",
className,
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
// biome-ignore lint/a11y/useFocusableInteractive: required
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -5,26 +5,29 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
primary:
"bg-primary hover:bg-primary/90 text-semibold text-primary-foreground ",
default: "bg-foreground text-background hover:bg-foreground/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-destructive hover:bg-destructive/90 text-semibold text-destructive-foreground ",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground text-semibold",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
"bg-secondary hover:bg-secondary/80 text-semibold text-secondary-foreground ",
ghost: "hover:bg-accent text-semibold hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline text-semibold",
upsell:
"bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
@@ -43,10 +46,13 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const btnOnlyProps =
Comp === "button" ? { type: "button" as const } : undefined;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...btnOnlyProps}
{...props}
/>
);
@@ -0,0 +1,204 @@
import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { ToolTipLabel } from "./tooltip";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive-text", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const RequiredFormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<ToolTipLabel label="Required">
<Label
ref={ref}
className={cn(error && "text-destructive-text", "relative", className)}
htmlFor={formItemId}
{...props}
>
{props.children}
<span className="-top-1.5 -right-2 absolute text-destructive-text">
•
</span>
</Label>
</ToolTipLabel>
);
});
RequiredFormLabel.displayName = "RequiredFormLabel";
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("font-medium text-destructive-text text-sm", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
RequiredFormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
@@ -14,58 +14,60 @@ const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
chevronClassName?: string;
}
>(({ className, children, chevronClassName, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
<ChevronDown className={cn("h-4 w-4 opacity-50", chevronClassName)} />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
// const SelectScrollUpButton = React.forwardRef<
// React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
// React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
// >(({ className, ...props }, ref) => (
// <SelectPrimitive.ScrollUpButton
// ref={ref}
// className={cn(
// "flex cursor-default items-center justify-center py-1",
// className,
// )}
// {...props}
// >
// <ChevronUp className="h-4 w-4" />
// </SelectPrimitive.ScrollUpButton>
// ));
// SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
// const SelectScrollDownButton = React.forwardRef<
// React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
// React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
// >(({ className, ...props }, ref) => (
// <SelectPrimitive.ScrollDownButton
// ref={ref}
// className={cn(
// "flex cursor-default items-center justify-center py-1",
// className,
// )}
// {...props}
// >
// <ChevronDown className="h-4 w-4" />
// </SelectPrimitive.ScrollDownButton>
// ));
// SelectScrollDownButton.displayName =
// SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
@@ -75,7 +77,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
position === "popper" &&
"data-[side=left]:-translate-x-1 data-[side=top]:-translate-y-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1",
className,
@@ -83,7 +85,7 @@ const SelectContent = React.forwardRef<
position={position}
{...props}
>
<SelectScrollUpButton />
{/* <SelectScrollUpButton /> */}
<SelectPrimitive.Viewport
className={cn(
"p-1",
@@ -93,7 +95,7 @@ const SelectContent = React.forwardRef<
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
{/* <SelectScrollDownButton /> */}
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
@@ -155,6 +157,6 @@ export {
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
// SelectScrollUpButton,
// SelectScrollDownButton,
};
@@ -3,8 +3,9 @@
import { cn } from "@/lib/utils";
import clsx from "clsx";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useRef } from "react";
import { ClientOnly } from "../ClientOnly";
import { CustomAccordion } from "./CustomAccordion";
export type LinkMeta = {
@@ -33,11 +34,13 @@ type ReferenceSideBarProps = {
export function Sidebar(props: ReferenceSideBarProps) {
return (
<ul className="transform-gpu pb-10">
<ul className="transform-gpu">
{props.links.map((link, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<li key={i}>
<SidebarItem link={link} onLinkClick={props.onLinkClick} />
<Suspense fallback={null}>
<SidebarItem link={link} onLinkClick={props.onLinkClick} />
</Suspense>
</li>
))}
</ul>
@@ -45,15 +48,13 @@ export function Sidebar(props: ReferenceSideBarProps) {
}
function SidebarItem(props: { link: SidebarLink; onLinkClick?: () => void }) {
const pathname = usePathname();
const href = useCurrentHref();
if ("separator" in props.link) {
return <hr className="my-2 border-t" />;
}
const isActive = props.link.href
? isSamePage(pathname, props.link.href)
: false;
const isActive = props.link.href ? isSamePage(href, props.link.href) : false;
const { link } = props;
if ("links" in link) {
@@ -93,13 +94,13 @@ function DocSidebarNonCollapsible(props: {
linkGroup: LinkGroup;
onLinkClick?: () => void;
}) {
const pathname = usePathname();
const currentHref = useCurrentHref();
const { href, name, links } = props.linkGroup;
const isCategoryActive = href ? isSamePage(pathname, href) : false;
const isCategoryActive = href ? isSamePage(currentHref, href) : false;
return (
<div className="my-4">
<div className="mb-2 flex items-center gap-2">
<div className="mb-4">
<div className="mb-1 flex items-center gap-2">
{href ? (
<Link
className={cn(
@@ -111,10 +112,10 @@ function DocSidebarNonCollapsible(props: {
{name}
</Link>
) : (
<div className="font-semibold lg:text-base">{name}</div>
<div className="font-medium text-lg">{name}</div>
)}
</div>
<ul className="flex flex-col">
<ul className="mb-8 flex flex-col">
{links.map((link, i) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
@@ -128,13 +129,21 @@ function DocSidebarNonCollapsible(props: {
);
}
function useCurrentHref() {
const pathname = usePathname();
const searchParams = useSearchParams();
return decodeURIComponent(
`${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`,
);
}
function SidebarCategory(props: {
linkGroup: LinkGroup;
onLinkClick?: () => void;
}) {
const pathname = usePathname();
const currentPageHref = useCurrentHref();
const { href, name, links, expanded } = props.linkGroup;
const isCategoryActive = href ? isSamePage(pathname, href) : false;
const isCategoryActive = href ? isSamePage(currentPageHref, href) : false;
const hasActiveHref = containsActiveHref(
{
@@ -142,8 +151,9 @@ function SidebarCategory(props: {
links: links,
href: href,
},
pathname,
currentPageHref,
);
const defaultOpen = isCategoryActive || !!(hasActiveHref || expanded);
const triggerRef = useRef<HTMLDivElement>(null);
@@ -152,7 +162,7 @@ function SidebarCategory(props: {
<div
className={cn(
isCategoryActive && "!font-semibold !text-foreground",
"text-muted-foreground",
"group-hover:!text-foreground text-muted-foreground",
)}
>
<div className="flex gap-2 py-1 font-medium" ref={triggerRef}>
@@ -170,24 +180,26 @@ function SidebarCategory(props: {
);
return (
<CustomAccordion
defaultOpen={defaultOpen}
containerClassName="border-none"
triggerContainerClassName="lg:text-base"
trigger={triggerEl}
chevronPosition="right"
>
<ul className="flex flex-col border-l-2 pl-4">
{links.map((link, i) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<li key={i}>
<SidebarItem link={link} onLinkClick={props.onLinkClick} />
</li>
);
})}
</ul>
</CustomAccordion>
<ClientOnly ssr={triggerEl}>
<CustomAccordion
defaultOpen={defaultOpen}
containerClassName="border-none"
triggerContainerClassName="lg:text-base group"
trigger={triggerEl}
chevronPosition="right"
>
<ul className="flex flex-col border-l pl-4">
{links.map((link, i) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<li key={i}>
<SidebarItem link={link} onLinkClick={props.onLinkClick} />
</li>
);
})}
</ul>
</CustomAccordion>
</ClientOnly>
);
}
@@ -210,20 +222,6 @@ function containsActiveHref(
return false;
}
function isSamePage(pathname: string, pathOrHref: string): boolean {
try {
if (pathOrHref === pathname) {
return true;
}
const u1 = new URL(pathname, window.location.href);
const u2 = new URL(pathOrHref, window.location.href);
if (u1.pathname === u2.pathname && u1.origin === u2.origin) {
return true;
}
} catch {
// ignore
}
return false;
function isSamePage(currentPageHref: string, testHref: string): boolean {
return currentPageHref === testHref;
}
+58 -15
View File
@@ -1,18 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { ScrollShadow } from "./ScrollShadow/ScrollShadow";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
<table
ref={ref}
className={cn(
"w-full caption-bottom border-collapse align-top text-sm lining-nums tabular-nums",
className,
)}
{...props}
/>
));
Table.displayName = "Table";
@@ -20,8 +22,17 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead
ref={ref}
className={cn(
"border-border border-b bg-background",
"sticky top-0 z-20 border-none before:absolute before:inset-0 before:border-border before:border-b",
className,
)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
@@ -43,7 +54,7 @@ const TableFooter = React.forwardRef<
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
"border-border border-t bg-card font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
@@ -53,12 +64,19 @@ TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLTableRowElement> & {
/**
* Contain the absolutely position elements inside the row with position:relative + transform:translate(0)
* transform:translate(0) is required because position:relative on tr element does not work on webkit
*/
linkBox?: boolean;
}
>(({ className, linkBox, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
"border-border border-b last:border-0 data-[state=selected]:bg-muted",
linkBox && "relative translate-x-0 translate-y-0",
className,
)}
{...props}
@@ -73,7 +91,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"px-6 py-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase tracking-wider [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
@@ -87,7 +105,10 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
className={cn(
"px-6 py-4 text-start align-middle text-sm [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
@@ -99,12 +120,33 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-muted-foreground text-sm", className)}
className={cn("py-4 text-muted-foreground text-sm", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
function TableContainer(props: {
children: React.ReactNode;
className?: string;
scrollableContainerClassName?: string;
}) {
return (
<ScrollShadow
shadowClassName="z-30"
disableTopShadow
className={cn(
"relative whitespace-nowrap rounded-lg border border-border bg-card",
props.className,
)}
scrollableClassName={props.scrollableContainerClassName}
shadowColor="hsl(var(--muted))"
>
{props.children}
</ScrollShadow>
);
}
export {
Table,
TableHeader,
@@ -114,4 +156,5 @@ export {
TableRow,
TableCell,
TableCaption,
TableContainer,
};
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 animate-in overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-popover-foreground text-sm shadow-md data-[state=closed]:animate-out",
"fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 animate-in overflow-hidden rounded-md border border-border bg-popover px-3 py-1.5 text-popover-foreground text-sm shadow-md data-[state=closed]:animate-out",
className,
)}
{...props}
@@ -34,6 +34,10 @@ export function ToolTipLabel(props: {
label: React.ReactNode;
rightIcon?: React.ReactNode;
leftIcon?: React.ReactNode;
hoverable?: boolean;
contentClassName?: string;
side?: "top" | "right" | "bottom" | "left";
align?: "center" | "start" | "end";
}) {
if (!props.label) {
return props.children;
@@ -41,13 +45,18 @@ export function ToolTipLabel(props: {
return (
<TooltipProvider>
<Tooltip delayDuration={100} disableHoverableContent={true}>
<Tooltip delayDuration={100} disableHoverableContent={!props.hoverable}>
<TooltipTrigger asChild className="!pointer-events-auto">
{props.children}
</TooltipTrigger>
<TooltipContent
side={props.side}
align={props.align}
sideOffset={10}
className="max-w-[400px] leading-relaxed"
className={cn(
"max-w-[400px] whitespace-normal leading-relaxed",
props.contentClassName,
)}
>
<div className="flex items-center gap-1.5 p-2 text-sm">
{props.leftIcon}
+3
View File
@@ -0,0 +1,3 @@
export const isProd =
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
"production";
+15
View File
@@ -16,6 +16,7 @@ module.exports = {
extend: {
colors: {
border: "hsl(var(--border))",
"active-border": "hsl(var(--active-border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
@@ -31,6 +32,7 @@ module.exports = {
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
text: "hsl(var(--destructive-text))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
@@ -48,9 +50,22 @@ module.exports = {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
success: {
text: "hsl(var(--success-text))",
},
inverted: {
DEFAULT: "hsl(var(--inverted))",
foreground: "hsl(var(--inverted-foreground))",
},
code: {
DEFAULT: "hsl(var(--code))",
},
warning: {
text: "hsl(var(--warning-text))",
},
link: {
foreground: "hsl(var(--link-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
+13
View File
@@ -0,0 +1,13 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19 10.4399C18.3096 10.4399 17.75 10.9996 17.75 11.6899C17.75 12.3803 18.3096 12.9399 19 12.9399C19.6904 12.9399 20.25 12.3803 20.25 11.6899C20.25 10.9996 19.6904 10.4399 19 10.4399ZM16.25 11.6899C16.25 10.1712 17.4812 8.93994 19 8.93994C20.5188 8.93994 21.75 10.1712 21.75 11.6899C21.75 13.2087 20.5188 14.4399 19 14.4399C17.4812 14.4399 16.25 13.2087 16.25 11.6899Z" fill="white" stroke="white" stroke-width="0.25" stroke-miterlimit="10"/>
<path d="M12 13.9341C13.1046 13.9341 14 13.0387 14 11.9341C14 10.8295 13.1046 9.93408 12 9.93408C10.8954 9.93408 10 10.8295 10 11.9341C10 13.0387 10.8954 13.9341 12 13.9341Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 10.4399C4.30964 10.4399 3.75 10.9996 3.75 11.6899C3.75 12.3803 4.30964 12.9399 5 12.9399C5.69036 12.9399 6.25 12.3803 6.25 11.6899C6.25 10.9996 5.69036 10.4399 5 10.4399ZM2.25 11.6899C2.25 10.1712 3.48122 8.93994 5 8.93994C6.51878 8.93994 7.75 10.1712 7.75 11.6899C7.75 13.2087 6.51878 14.4399 5 14.4399C3.48122 14.4399 2.25 13.2087 2.25 11.6899Z" fill="white" stroke="white" stroke-width="0.25" stroke-miterlimit="10"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.7277 14.182C4.24985 16.0934 4.54726 17.6287 5.45936 18.5408C6.37146 19.4529 7.90677 19.7503 9.8182 19.2725C11.7127 18.7989 13.8394 17.5854 15.7124 15.7124C16.5051 14.9198 17.1796 14.0817 17.7299 13.235C18.0753 13.5193 18.5178 13.6901 19.0001 13.6901C19.0756 13.6901 19.1502 13.6859 19.2235 13.6777C18.5704 14.7489 17.7476 15.7985 16.7731 16.7731C14.7409 18.8053 12.3822 20.1776 10.182 20.7277C7.99867 21.2735 5.82974 21.0325 4.3987 19.6015C2.96766 18.1705 2.72666 16.0015 3.27249 13.8182C3.33854 13.554 3.41645 13.2875 3.50594 13.0196C3.8447 13.4 4.32658 13.6502 4.86709 13.6857C4.81514 13.8527 4.76865 14.0182 4.7277 14.182ZM6.5215 10.3918C7.02185 9.67265 7.61187 8.9637 8.28779 8.28779C10.1608 6.41476 12.2875 5.20134 14.182 4.7277C16.0934 4.24985 17.6287 4.54726 18.5408 5.45936C19.436 6.35453 19.7391 7.84998 19.2983 9.71214C19.8482 9.79435 20.325 10.1005 20.6329 10.5348C20.6668 10.4168 20.6984 10.2992 20.7277 10.182C21.2735 7.99867 21.0325 5.82974 19.6015 4.3987C18.1705 2.96766 16.0015 2.72666 13.8182 3.27249C11.618 3.82255 9.25934 5.19491 7.22713 7.22713C6.44286 8.01139 5.75687 8.84427 5.17848 9.6979C5.71512 9.74534 6.19088 10.0047 6.5215 10.3918Z" fill="white"/>
<mask id="mask0_4_23" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="2" y="2" width="21" height="21">
<path d="M22.0034 2.00342H2.00342V22.0034H22.0034V2.00342Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.7277 14.182C4.24985 16.0934 4.54726 17.6287 5.45936 18.5408C6.37146 19.4529 7.90677 19.7503 9.8182 19.2725C11.7127 18.7989 13.8394 17.5854 15.7124 15.7124C16.5051 14.9198 17.1796 14.0817 17.7299 13.235C18.0753 13.5193 18.5178 13.6901 19.0001 13.6901C19.0756 13.6901 19.1502 13.6859 19.2235 13.6777C18.5704 14.7489 17.7476 15.7985 16.7731 16.7731C14.7409 18.8053 12.3822 20.1776 10.182 20.7277C7.99867 21.2735 5.82974 21.0325 4.3987 19.6015C2.96766 18.1705 2.72666 16.0015 3.27249 13.8182C3.33854 13.554 3.41645 13.2875 3.50594 13.0196C3.8447 13.4 4.32658 13.6502 4.86709 13.6857C4.81514 13.8527 4.76865 14.0182 4.7277 14.182ZM6.5215 10.3918C7.02185 9.67265 7.61187 8.9637 8.28779 8.28779C10.1608 6.41476 12.2875 5.20134 14.182 4.7277C16.0934 4.24985 17.6287 4.54726 18.5408 5.45936C19.436 6.35453 19.7391 7.84998 19.2983 9.71214C19.8482 9.79435 20.325 10.1005 20.6329 10.5348C20.6668 10.4168 20.6984 10.2992 20.7277 10.182C21.2735 7.99867 21.0325 5.82974 19.6015 4.3987C18.1705 2.96766 16.0015 2.72666 13.8182 3.27249C11.618 3.82255 9.25934 5.19491 7.22713 7.22713C6.44286 8.01139 5.75687 8.84427 5.17848 9.6979C5.71512 9.74534 6.19088 10.0047 6.5215 10.3918Z" fill="black"/>
</mask>
<g mask="url(#mask0_4_23)">
<path d="M17.7299 13.235L17.8888 13.042L17.6728 12.8642L17.5203 13.0988L17.7299 13.235ZM19.2235 13.6777L19.437 13.8079L19.7022 13.373L19.1959 13.4292L19.2235 13.6777ZM3.50594 13.0196L3.69265 12.8533L3.40557 12.5309L3.26882 12.9404L3.50594 13.0196ZM4.86709 13.6857L5.1058 13.76L5.20009 13.457L4.88347 13.4362L4.86709 13.6857ZM6.5215 10.3918L6.3314 10.5542L6.54175 10.8004L6.72672 10.5346L6.5215 10.3918ZM19.2983 9.71212L19.055 9.65453L18.9924 9.91917L19.2613 9.95937L19.2983 9.71212ZM20.6329 10.5348L20.4289 10.6793L20.7295 11.1034L20.8731 10.6039L20.6329 10.5348ZM5.17848 9.69788L4.97152 9.55765L4.73312 9.90949L5.15647 9.94691L5.17848 9.69788ZM5.63614 18.3641C4.81019 17.5381 4.50387 16.1081 4.97024 14.2426L4.48517 14.1214C3.99582 16.0788 4.28432 17.7193 5.28258 18.7176L5.63614 18.3641ZM9.75756 19.03C7.8921 19.4963 6.46208 19.19 5.63614 18.3641L5.28258 18.7176C6.28085 19.7159 7.92144 20.0044 9.87883 19.515L9.75756 19.03ZM15.5356 15.5356C13.6891 17.3821 11.6012 18.5691 9.75756 19.03L9.87883 19.515C11.8243 19.0287 13.9896 17.7888 15.8892 15.8892L15.5356 15.5356ZM17.5203 13.0988C16.9797 13.9305 16.3162 14.755 15.5356 15.5356L15.8892 15.8892C16.6939 15.0845 17.3795 14.2328 17.9395 13.3712L17.5203 13.0988ZM19.0001 13.4401C18.5778 13.4401 18.1911 13.2908 17.8888 13.042L17.571 13.428C17.9596 13.7478 18.4577 13.9401 19.0001 13.9401V13.4401ZM19.1959 13.4292C19.1317 13.4364 19.0664 13.4401 19.0001 13.4401V13.9401C19.0849 13.9401 19.1687 13.9354 19.2512 13.9262L19.1959 13.4292ZM16.9498 16.9498C17.9379 15.9617 18.7732 14.8966 19.437 13.8079L19.0101 13.5476C18.3676 14.6013 17.5573 15.6353 16.5963 16.5963L16.9498 16.9498ZM10.2426 20.9702C12.4938 20.4074 14.8911 19.0086 16.9498 16.9498L16.5963 16.5963C14.5906 18.602 12.2706 19.9479 10.1214 20.4852L10.2426 20.9702ZM4.22192 19.7783C5.7397 21.296 8.01447 21.5273 10.2426 20.9702L10.1214 20.4852C7.98287 21.0198 5.91978 20.769 4.57548 19.4247L4.22192 19.7783ZM3.02996 13.7576C2.47291 15.9857 2.70415 18.2605 4.22192 19.7783L4.57548 19.4247C3.23117 18.0804 2.9804 16.0173 3.51503 13.8788L3.02996 13.7576ZM3.26882 12.9404C3.17736 13.2142 3.09764 13.4869 3.02996 13.7576L3.51503 13.8788C3.57946 13.6211 3.65554 13.3608 3.74306 13.0987L3.26882 12.9404ZM3.31923 13.1858C3.69996 13.6134 4.24222 13.8952 4.85072 13.9352L4.88347 13.4362C4.41095 13.4052 3.98945 13.1866 3.69265 12.8533L3.31923 13.1858ZM4.97024 14.2426C5.01001 14.0836 5.0552 13.9226 5.1058 13.76L4.62839 13.6114C4.57507 13.7827 4.5273 13.9528 4.48517 14.1214L4.97024 14.2426ZM6.72672 10.5346C7.21849 9.82774 7.79896 9.13015 8.46456 8.46454L8.11101 8.11099C7.42478 8.79722 6.82521 9.51752 6.31628 10.249L6.72672 10.5346ZM8.46456 8.46454C10.3111 6.61805 12.399 5.43112 14.2426 4.97022L14.1214 4.48515C12.1759 4.97152 10.0106 6.21142 8.11101 8.11099L8.46456 8.46454ZM14.2426 4.97022C16.1081 4.50385 17.5381 4.81017 18.3641 5.63612L18.7176 5.28256C17.7193 4.2843 16.0788 3.9958 14.1214 4.48515L14.2426 4.97022ZM18.3641 5.63612C19.1747 6.44678 19.4848 7.83893 19.055 9.65453L19.5416 9.76971C19.9934 7.861 19.6973 6.26225 18.7176 5.28256L18.3641 5.63612ZM19.2613 9.95937C19.7418 10.0312 20.1592 10.2988 20.4289 10.6793L20.8368 10.3902C20.4909 9.90213 19.9547 9.55746 19.3353 9.46487L19.2613 9.95937ZM20.8731 10.6039C20.9078 10.4831 20.9402 10.3627 20.9702 10.2426L20.4852 10.1214C20.4566 10.2356 20.4257 10.3504 20.3926 10.4657L20.8731 10.6039ZM20.9702 10.2426C21.5273 8.01445 21.296 5.73968 19.7783 4.2219L19.4247 4.57546C20.769 5.91976 21.0198 7.98285 20.4852 10.1214L20.9702 10.2426ZM19.7783 4.2219C18.2605 2.70413 15.9857 2.47289 13.7576 3.02994L13.8788 3.51501C16.0173 2.98038 18.0804 3.23115 19.4247 4.57546L19.7783 4.2219ZM13.7576 3.02994C11.5064 3.59273 9.10909 4.99159 7.05035 7.05033L7.4039 7.40388C9.40959 5.39819 11.7296 4.05232 13.8788 3.51501L13.7576 3.02994ZM7.05035 7.05033C6.25512 7.84556 5.55897 8.69064 4.97152 9.55765L5.38545 9.83812C5.95476 8.99787 6.6306 8.17719 7.4039 7.40388L7.05035 7.05033ZM6.71159 10.2294C6.34004 9.7944 5.80474 9.50226 5.20049 9.44885L5.15647 9.94691C5.6255 9.98837 6.04171 10.215 6.3314 10.5542L6.71159 10.2294Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -64,4 +64,19 @@ Options for configuring timeouts for fetching data. Useful for fine-tuning reque
TimeoutOptions(int? storage = null, int? rpc = null, int? other = null)
```
### rpcOverrides (optional)
Overrides for the default RPC endpoints. Will use your custom endpoints instead of thirdweb RPC endpoints.
```csharp
var client = ThirdwebClient.Create(
...,
rpcOverrides: new()
{
{ 1, "https://eth.llamarpc.com" },
{ 42161, "https://arbitrum.llamarpc.com" }
}
);
```
</Details>
@@ -0,0 +1,100 @@
import { Details, createMetadata } from "@doc";
export const metadata = createMetadata({
title: "ThirdwebContract.Create | Thirdweb .NET SDK",
description:
"Instantiate a ThirdwebContract to interact with smart contracts.",
});
# [Nebula AI](https://thirdweb.com/nebula) .NET Integration
The last piece of the puzzle required to create .NET apps and games leveraging a blockchain-powered AI assistant or NPC that also has the power to fully execute transactions.
## Read, Write, Reason.
The best way to understand is to look at examples.
We'll prepare some context for the AI - here, we'll generate a basic `PrivateKeyWallet` and upgrade it to a `SmartWallet` on Sepolia.
```csharp
// Prepare some context
var myChain = 11155111; // Sepolia
var mySigner = await PrivateKeyWallet.Generate(client);
var myWallet = await SmartWallet.Create(mySigner, myChain);
var myContractAddress = "0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8"; // DropERC1155
var usdcAddress = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; // Sepolia USDC
```
A one liner creates a Nebula session.
```csharp
// Create a Nebula session
var nebula = await ThirdwebNebula.Create(client);
```
You can **Chat** with Nebula. The Chat method accepts any `IThirdwebWallet` as an optional parameter, context will automatically be updated.
```csharp
// Chat, passing wallet context
var response1 = await nebula.Chat(message: "What is my wallet address?", wallet: myWallet);
Console.WriteLine($"Response 1: {response1.Message}");
```
You may also pass it smart contract context.
```csharp
// Chat, passing contract context
var response2 = await nebula.Chat(
message: "What's the total supply of token id 0 for this contract?",
context: new NebulaContext(contractAddresses: new List<string> { myContractAddress }, chainIds: new List<BigInteger> { myChain })
);
Console.WriteLine($"Response 2: {response2.Message}");
```
You can have a full on conversation with it with our **Chat** override accepting multiple messages.
```csharp
// Chat, passing multiple messages and context
var response3 = await nebula.Chat(
messages: new List<NebulaChatMessage>
{
new($"Tell me the name of this contract: {myContractAddress}", NebulaChatRole.User),
new("The name of the contract is CatDrop", NebulaChatRole.Assistant),
new("What's the symbol of this contract?", NebulaChatRole.User),
},
context: new NebulaContext(contractAddresses: new List<string> { myContractAddress }, chainIds: new List<BigInteger> { myChain })
);
Console.WriteLine($"Response 3: {response3.Message}");
```
Chatting is cool, but what if I just want it to _**do**_ things and stop talking so much?
You can **Execute** transactions directly with a simple prompt.
```csharp
// Execute, this directly sends transactions
var executionResult = await nebula.Execute("Approve 1 USDC to vitalik.eth", wallet: myWallet, context: new NebulaContext(contractAddresses: new List<string>() { usdcAddress }));
if (executionResult.TransactionReceipts != null && executionResult.TransactionReceipts.Count > 0)
{
Console.WriteLine($"Receipt: {executionResult.TransactionReceipts[0]}");
}
else
{
Console.WriteLine($"Message: {executionResult.Message}");
}
```
Similarly, **Execute** can take in multiple messages.
```csharp
// Batch execute
var batchExecutionResult = await nebula.Execute(
new List<NebulaChatMessage>
{
new("What's the address of vitalik.eth", NebulaChatRole.User),
new("The address of vitalik.eth is 0xd8dA6BF26964aF8E437eEa5e3616511D7G3a3298", NebulaChatRole.Assistant),
new("Approve 1 USDC to them", NebulaChatRole.User),
},
wallet: myWallet,
context: new NebulaContext(contractAddresses: new List<string>() { usdcAddress })
);
if (batchExecutionResult.TransactionReceipts != null && batchExecutionResult.TransactionReceipts.Count > 0)
{
Console.WriteLine($"Receipts: {JsonConvert.SerializeObject(batchExecutionResult.TransactionReceipts, Formatting.Indented)}");
}
else
{
Console.WriteLine($"Message: {batchExecutionResult.Message}");
}
```
+35 -13
View File
@@ -1,6 +1,6 @@
import type { SideBar } from "@/components/Layouts/DocLayout";
import type { SidebarLink } from "@/components/others/Sidebar";
import { ZapIcon } from "lucide-react";
import { CodeIcon, ExternalLink, ZapIcon } from "lucide-react";
const walletProviders: SidebarLink = (() => {
const parentSlug = "/dotnet/wallets/providers";
@@ -261,16 +261,29 @@ export const sidebar: SideBar = {
icon: <ZapIcon />,
},
{
name: "Godot Integration",
href: "/dotnet/godot",
name: "API Reference",
href: "https://thirdweb-dev.github.io/dotnet/index.html",
isCollapsible: false,
icon: <ExternalLink />,
},
{
name: "Unity Integration",
href: "/unity/v5",
},
{
name: "MAUI Integration",
href: "/dotnet/maui",
name: "Integrations",
isCollapsible: true,
icon: <CodeIcon />,
links: [
{
name: "Unity",
href: "/unity/v5",
},
{
name: "Godot",
href: "/dotnet/godot",
},
{
name: "MAUI",
href: "/dotnet/maui",
},
],
},
{
name: "Core",
@@ -291,7 +304,6 @@ export const sidebar: SideBar = {
isCollapsible: false,
links: [walletProviders, walletActions],
},
pay,
{
name: "Blockchain API",
isCollapsible: false,
@@ -304,10 +316,20 @@ export const sidebar: SideBar = {
},
],
},
{ separator: true },
{
name: "Full Reference",
href: "https://thirdweb-dev.github.io/dotnet/index.html",
name: "Nebula AI",
isCollapsible: false,
links: [
{
name: "Quickstart",
href: "/dotnet/nebula/quickstart",
},
{
name: "Nebula Full Reference",
href: "https://thirdweb-dev.github.io/dotnet/docs/Thirdweb.AI.ThirdwebNebula.html",
},
],
},
pay,
],
};
@@ -15,11 +15,13 @@ Create an instance of `EcosystemWallet` using a user's email, phone number or OA
Ecosystem Wallets support a variety of login methods:
- Email (OTP Login)
- Phone (OTP Login)
- Socials (Google, Apple, Facebook, Telegram, Farcaster, Line, Github, Twitch, Steam, etc.)
- SIWE (Sign-In with Ethereum)
- Socials (Google, Apple, Facebook, Telegram, Farcaster, Line, Github, Twitch, Steam etc.)
- Custom Auth (OIDC Compatible)
- Custom Auth (Generic Auth Endpoint)
- Guest (Onboard easily, link other accounts later)
- Backend (Server Wallets)
- Siwe (Login with a seperate wallet supported by the SDK)
- SiweExternal (Login with an external wallet that only supports web using a browser loading a static thirdweb React page temporarily)
## Usage
@@ -32,14 +34,18 @@ var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosyste
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", phoneNumber: "+1234567890");
// Google, Apple, Facebook, etc.
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", authProvider: AuthProvider.Google);
// SIWE
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", authProvider: AuthProvider.Siwe, siweSigner: anyExternalWallet);
// Custom Auth - JWT
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", authProvider: AuthProvider.JWT);
// Custom Auth - AuthEndpoint
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", authProvider: AuthProvider.AuthEndpoint);
// Guest Login
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", authProvider: AuthProvider.Guest);
// Server Login
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", authProvider: AuthProvider.Backend, walletSecret: "very-secret");
// Siwe
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", authProvider: AuthProvider.Siwe, siweSigner: anyExternalWallet);
// SiweExternal
var wallet = await EcosystemWallet.Create(client: client, ecosystemId: "ecosystem.my-ecosystem", authProvider: AuthProvider.SiweExternal);
// Session resuming supported for all methods
var isConnected = await wallet.IsConnected();
@@ -62,9 +68,6 @@ var address = await wallet.LoginWithOauth(
mobileRedirectScheme: "myBundleId://"
);
// SIWE (Wallet)
var address = await siweWallet.LoginWithSiwe(chainId: 1);
// Custom Auth (JWT)
var address = await wallet.LoginWithCustomAuth(jwt: "myjwt");
@@ -73,11 +76,26 @@ var address = await wallet.LoginWithAuthEndpoint(payload: "mypayload");
// Guest Login (Easy onboarding)
var address = await wallet.LoginWithGuest();
// Backend (Server Wallets)
var address = await wallet.LoginWithBackend();
// SIWE (Wallet)
var address = await siweWallet.LoginWithSiwe(chainId: 1);
// SiweExternal (React-only wallet)
var address = await wallet.LoginWithSiweExternal(
// Windows console app example, adaptable to any runtime
isMobile: false,
browserOpenAction: (url) =>
{
var psi = new ProcessStartInfo { FileName = url, UseShellExecute = true };
_ = Process.Start(psi);
},
forceWalletIds: new List<string> { "io.metamask", "com.coinbase.wallet", "xyz.abs" }
);
```
<Callout variant="info" title="Client-Side Use">
This wallet is designed for client-side use in applications where direct access to the user's private keys is not safe or necessary. It leverages OTP for secure authentication, allowing users to interact with blockchain applications seamlessly.
</Callout>
<Details summary="Parameters">
### client (required)
@@ -108,6 +126,18 @@ The OAuth provider to use for authentication. Supported values are `AuthProvider
The path to the directory where the wallet data is stored. Defaults to the application's data directory.
### siweSigner (optional)
An external wallet instance to use for SIWE authentication.
### legacyEncryptionKey (optional)
The encryption key that is no longer required but was used in the past. Only pass this if you had used custom auth before this was deprecated.
### walletSecret (optional)
The secret identifier to use when creating server-side wallets with backend authentication.
</Details>
<Details summary="Return Value">
@@ -246,6 +276,31 @@ if (!await ecosystemWallet.IsConnected())
**Note:** The `LoginWithOauth` API allows for custom browser handling, making it suitable for various application types and platforms.
## External Wallet Auth (Siwe & SiweExternal)
**LoginWithSiwe:** Initiate the login process by calling LoginWithSiwe on the EcosystemWallet instance. This will prompt the external wallet to sign a message instantly.
```csharp
var address = await siweWallet.LoginWithSiwe(chainId: 1);
```
**LoginWithSiweExternal:** Initiate the login process by calling LoginWithSiweExternal on the EcosystemWallet instance. This will initiate a browser-based login flow for external wallets that only support web platforms.
```csharp
var address = await wallet.LoginWithSiweExternal(
// Windows console app example, adaptable to any runtime
isMobile: false,
browserOpenAction: (url) =>
{
var psi = new ProcessStartInfo { FileName = url, UseShellExecute = true };
_ = Process.Start(psi);
},
forceWalletIds: new List<string> { "io.metamask", "com.coinbase.wallet", "xyz.abs" }
);
```
Note: The parameters are similar to the OAuth flow, with the addition of `forceWalletIds` to specify the wallet IDs to force the user to use. Using a single wallet id will skip the wallet selection screen and directly open the wallet.
## Unified Identity - Account Linking
EcosystemWallet supports linking multiple authentication methods to a single user account. This feature enables users to access their account using different authentication methods, such as email, phone, or OAuth, without creating separate accounts for each method.
@@ -15,11 +15,13 @@ Create an instance of `InAppWallet` using a user's email, phone number or OAuth.
In-App Wallets support a variety of login methods:
- Email (OTP Login)
- Phone (OTP Login)
- Socials (Google, Apple, Facebook, Telegram, Farcaster, Line, Github, Twitch etc.)
- SIWE (Sign-In with Ethereum)
- Socials (Google, Apple, Facebook, Telegram, Farcaster, Line, Github, Twitch, Steam etc.)
- Custom Auth (OIDC Compatible)
- Custom Auth (Generic Auth Endpoint)
- Guest (Onboard easily, link other accounts later)
- Backend (Server Wallets)
- Siwe (Login with a seperate wallet supported by the SDK)
- SiweExternal (Login with an external wallet that only supports web using a browser loading a static thirdweb React page temporarily)
## Usage
@@ -30,14 +32,18 @@ var wallet = await InAppWallet.Create(client: client, email: "userEmail");
var wallet = await InAppWallet.Create(client: client, phoneNumber: "+1234567890");
// Google, Apple, Facebook, etc.
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.Google);
// SIWE
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.Siwe, siweSigner: anyExternalWallet);
// Custom Auth - JWT
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.JWT);
// Custom Auth - AuthEndpoint
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.AuthEndpoint);
// Guest Login
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.Guest);
// Server Login
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.Backend, walletSecret: "very-secret");
// Siwe
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.Siwe, siweSigner: anyExternalWallet);
// SiweExternal
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.SiweExternal);
// Session resuming supported for all methods
var isConnected = await wallet.IsConnected();
@@ -60,9 +66,6 @@ var address = await wallet.LoginWithOauth(
mobileRedirectScheme: "myBundleId://"
);
// SIWE (Wallet)
var address = await siweWallet.LoginWithSiwe(chainId: 1);
// Custom Auth (JWT)
var address = await wallet.LoginWithCustomAuth(jwt: "myjwt");
@@ -71,11 +74,26 @@ var address = await wallet.LoginWithAuthEndpoint(payload: "mypayload");
// Guest Login (Easy onboarding)
var address = await wallet.LoginWithGuest();
// Backend (Server Wallets)
var address = await wallet.LoginWithBackend();
// SIWE (Wallet)
var address = await siweWallet.LoginWithSiwe(chainId: 1);
// SiweExternal (React-only wallet)
var address = await wallet.LoginWithSiweExternal(
// Windows console app example, adaptable to any runtime
isMobile: false,
browserOpenAction: (url) =>
{
var psi = new ProcessStartInfo { FileName = url, UseShellExecute = true };
_ = Process.Start(psi);
},
forceWalletIds: new List<string> { "io.metamask", "com.coinbase.wallet", "xyz.abs" }
);
```
<Callout variant="info" title="Client-Side Use">
This wallet is designed for client-side use in applications where direct access to the user's private keys is not safe or necessary. It leverages OTP for secure authentication, allowing users to interact with blockchain applications seamlessly.
</Callout>
<Details summary="Parameters">
### client (required)
@@ -98,6 +116,18 @@ The OAuth provider to use for authentication. Supported values are `AuthProvider
The path to the directory where the wallet data is stored. Defaults to the application's data directory.
### siweSigner (optional)
An external wallet instance to use for SIWE authentication.
### legacyEncryptionKey (optional)
The encryption key that is no longer required but was used in the past. Only pass this if you had used custom auth before this was deprecated.
### walletSecret (optional)
The secret identifier to use when creating server-side wallets with backend authentication.
</Details>
<Details summary="Return Value">
@@ -108,179 +138,7 @@ Returns an instance of InAppWallet, initialized for the user based on the provid
</Details>
## OTP Authentication Flow
## Authentication, Linking and more.
The OTP authentication flow involves sending an OTP to the user's email or phone and then verifying the OTP to complete authentication:
`InAppWallet` extends `EcosystemWallet`, refer to the [EcosystemWallet](/dotnet/wallets/providers/ecosystem-wallet) documentation for information about authentication, linking multiple auth providers to the same wallet, and more. All functionality outside of creation and third-party integrations is the same as `EcosystemWallet`.
**Send OTP:** Initiate the login process by calling SendOTP on the InAppWallet instance. This sends an OTP to the user's email or phone number.
```csharp
await wallet.SendOTP();
```
**Submit OTP:** Once the user receives the OTP, they submit it back to the application, which then calls LoginWithOtp on the InAppWallet instance to verify the OTP and complete the login process.
```csharp
var address = await wallet.LoginWithOtp("userEnteredOTP");
// If this fails, feel free to catch and take in another OTP and retry the login process
```
## Example
Here's an example of creating an `InAppWallet` with a user's email and completing the OTP authentication flow:
```csharp
// Create InAppWallet wallet as signer to unlock web2 auth
var inAppWallet = await InAppWallet.Create(client: client, email: "[email protected]"); // or email: null, phoneNumber: "+1234567890"
// Resume session (if `InAppWallet` wallet was not logged in)
if (!await inAppWallet.IsConnected())
{
await inAppWallet.SendOTP();
Console.WriteLine("Please submit the OTP.");
var otp = Console.ReadLine();
var inAppWalletAddress = await inAppWallet.LoginWithOtp(otp); // try catch and retry if needed
}
Console.WriteLine($"InAppWallet address: {await inAppWallet.GetAddress()}");
// Sign a message
var message = "Hello, Thirdweb!";
var signature = await wallet.PersonalSign(message);
Console.WriteLine($"Signature: {signature}");
```
**Note:** InAppWallet leverages the security of OTP-based authentication to ensure a secure and user-friendly experience in blockchain applications.
## OAuth Authentication Flow
**LoginWithOauth:** Initiate the login process by calling LoginWithOauth on the InAppWallet instance. This redirects the user to the OAuth provider's login page.
```csharp
// Windows console app example
var address = await inAppWallet.LoginWithOauth(
isMobile: false,
browserOpenAction: (url) =>
{
var psi = new ProcessStartInfo { FileName = url, UseShellExecute = true };
_ = Process.Start(psi);
},
);
// Godot standalone example
var address = await ThirdwebManager.Instance.InAppWallet.LoginWithOauth(
isMobile: OS.GetName() == "Android" || OS.GetName() == "iOS",
browserOpenAction: (url) => OS.ShellOpen(url),
mobileRedirectScheme: "thirdweb://"
);
```
<Details summary="Parameters">
### isMobile
A `bool` indicating whether the application is running on a mobile platform.
### browserOpenAction
An `Action<string>` that opens the OAuth provider's login page in a browser.
### mobileRedirectScheme
The redirect scheme to use for mobile platforms. Defaults to `"thirdweb://"`.
### browser
An instance of `IThirdwebBrowser` to use for the OAuth login process. Defaults to `null`.
### cancellationToken
A `CancellationToken` to cancel the operation. Defaults to `default`.
</Details>
<Details summary="Return Value">
### string
The InAppWallet address as a hexadecimal `string`.
</Details>
## Example
Here's an example of creating an `InAppWallet` using OAuth.
```csharp
// Create InAppWallet wallet as signer to unlock web2 auth
var inAppWallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.Google);
// Resume session (if `InAppWallet` wallet was not logged in)
if (!await inAppWallet.IsConnected())
{
try {
var address = await inAppWallet.LoginWithOauth(
isMobile: false,
browserOpenAction: (url) =>
{
var psi = new ProcessStartInfo { FileName = url, UseShellExecute = true };
_ = Process.Start(psi);
},
);
Console.WriteLine($"OAuth login successful. InAppWallet address: {address}");
} catch (Exception ex) {
Console.WriteLine($"OAuth login failed: {ex.Message}");
return;
}
}
```
**Note:** The `LoginWithOauth` API allows for custom browser handling, making it suitable for various application types and platforms.
## Unified Identity - Account Linking
InAppWallet supports linking multiple authentication methods to a single user account. This feature enables users to access their account using different authentication methods, such as email, phone, or OAuth, without creating separate accounts for each method.
### Linking Accounts
```csharp
var inAppWalletMain = await InAppWallet.Create(client: client, authProvider: AuthProvider.Google);
if (!await inAppWalletMain.IsConnected())
{
_ = await inAppWalletMain.LoginWithOauth(
isMobile: false,
(url) =>
{
var psi = new ProcessStartInfo { FileName = url, UseShellExecute = true };
_ = Process.Start(psi);
},
"thirdweb://",
new InAppWalletBrowser()
);
}
Console.WriteLine($"Main InAppWallet address: {await inAppWalletMain.GetAddress()}");
// Prepare Telegram
var socialWallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.Telegram);
// Link Telegram
_ = await inAppWalletMain.LinkAccount(walletToLink: socialWallet,);
// Prepare Phone
var phoneWallet = await InAppWallet.Create(client: client, phoneNumber: "+1234567890");
_ = await phoneWallet.SendOTP();
var otp = Console.ReadLine();
// Link Phone
_ = await inAppWalletMain.LinkAccount(walletToLink: phoneWallet, otp: otp);
```
### Getting Linked Accounts
```csharp
List<LinkedAccount> linkedAccounts = await inAppWalletMain.GetLinkedAccounts();
```
### Unlinking Accounts
```csharp
List<LinkedAccount> linkedAccounts = await inAppWallet.GetLinkedAccounts();
List<LinkedAccount> linkedAccountsAfterUnlinking = await inAppWallet.UnlinkAccount(linkedAccounts[0]);
```
+4
View File
@@ -12,6 +12,10 @@ export default async function Layout(props: { children: React.ReactNode }) {
export const metadata = createMetadata({
title: "thirdweb Nebula Documentation",
image: {
title: "Nebula Docs",
icon: "nebula",
},
description:
"thirdweb Nebula Docs : explore the Nebula API Reference and unlock the most powerful AI to interact with the blockchain yet.",
});
+29 -17
View File
@@ -1,5 +1,5 @@
import type { SideBar } from "@/components/Layouts/DocLayout";
import { ZapIcon } from "lucide-react";
import { CodeIcon, ZapIcon } from "lucide-react";
const sdkSlug = "/unity/v5";
const walletProvidersSlug = `${sdkSlug}/wallets`;
@@ -17,6 +17,12 @@ export const sidebar: SideBar = {
href: `${sdkSlug}/getting-started`,
icon: <ZapIcon />,
},
{
name: "API Reference",
href: "/dotnet",
isCollapsible: false,
icon: <CodeIcon />,
},
{
name: "Core",
isCollapsible: false,
@@ -61,6 +67,20 @@ export const sidebar: SideBar = {
},
],
},
{
name: "Blockchain API",
isCollapsible: false,
links: [
{
name: "Interacting with Contracts",
href: `${sdkSlug}/contracts`,
},
{
name: "Full Reference",
href: "https://thirdweb-dev.github.io/dotnet/index.html",
},
],
},
{
name: "Pay",
isCollapsible: false,
@@ -72,27 +92,19 @@ export const sidebar: SideBar = {
],
},
{
name: "Blockchain API",
name: "Nebula AI",
isCollapsible: false,
links: [
{
name: "Interacting with Contracts",
href: `${sdkSlug}/contracts`,
},
{ separator: true },
{
name: "Migrate from v4",
href: `${sdkSlug}/migration-guide`,
},
{
name: ".NET SDK Portal",
href: "/dotnet",
},
{
name: "Full Reference",
href: "https://thirdweb-dev.github.io/dotnet/index.html",
name: ".NET SDK QuickStart",
href: "/dotnet/nebula/quickstart",
},
],
},
{ separator: true },
{
name: "Migrate from v4",
href: `${sdkSlug}/migration-guide`,
},
],
};
@@ -44,7 +44,7 @@ This section allows you to define the default preferences for the SDK:
- `Initialize On Awake`: Whether the SDK should initialize on awake or not. If not, you can call `ThirdwebManager.Instance.Initialize()` to initialize it manually.
- `Show Debug Logs`: Whether to show thirdweb SDK debug logs.
- `Opt Out Usage Analytics`: Whether to opt out of Connect usage analytics, such as the number of wallets connected to your game.
- `Auto-Connect Last Wallet`: If enabled, we will automatically connect to the last connected wallet on initialization (this behavior does not apply to the WalletConnectWallet provider option). If a wallet was connected as a non smart wallet, then later upgraded, the smart wallet is saved as the last wallet, and the next session will autoconnect to the smart wallet. Any failure during this entire flow should not throw.
### Miscellaneous
@@ -52,8 +52,10 @@ This section allows you to define the default preferences for the SDK:
This section allows you to customize the SDK's behavior:
- `Supported Chains`: A list of chains that the wallets should be able to connect to by default when using `WalletConnect` as a wallet provider.
- `Redirect Page HTML Override`: Raw HTML to override the default redirect page when connecting to `InAppWallet` or `EcosystemWallet` providers.
- `RPC Overrides`: A list of RPC overrides to use when connecting to a chain. You can bypass using thirdweb's RPC that way (not recommended).
- `OAuth Redirect Page HTML Override`: Raw HTML to override the default redirect page when connecting to `InAppWallet` or `EcosystemWallet` providers.
- `WalletConnect Supported Chains`: A list of chains that the wallets should be able to connect to by default when using `WalletConnect` as a wallet provider.
- `WalletConnect Included Wallet IDs`: A list of wallet IDs that should be included in the WalletConnect modal by default (optional override).
### Debug
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -20,11 +20,13 @@ Ecosystem Wallets have a very similar API to the [In-App Wallet](/unity/v5/walle
Ecosystem Wallets support a variety of login methods:
- Email (OTP Login)
- Phone (OTP Login)
- Socials (Google, Apple, Facebook, Telegram, Farcaster, Line, Github, Twitch, Steam, etc.)
- SIWE (Sign-In with Ethereum)
- Socials (Google, Apple, Facebook, Telegram, Farcaster, Line, Github, Twitch, Steam etc.)
- Custom Auth (OIDC Compatible)
- Custom Auth (Generic Auth Endpoint)
- Guest (Onboard easily, link other accounts later)
- Backend (Server Wallets)
- Siwe (Login with a seperate wallet supported by the SDK)
- SiweExternal (Login with an external wallet that only supports web using a browser loading a static thirdweb React page temporarily)
### Login with Email
@@ -95,6 +97,29 @@ var wallet = await ThirdwebManager.Instance.ConnectWallet(options);
Will use the external wallet to sign a message and login to the EcosystemWallet.
### Login with SiweExternal
```csharp
var ecosystemWalletOptions = new EcosystemWalletOptions(ecosystemId: "ecosystem.your-ecosystem", authprovider: AuthProvider.SiweExternal)
var options = new WalletOptions(
provider: WalletProvider.EcosystemWallet,
chainId: 421614,
ecosystemWalletOptions: ecosystemWalletOptions
);
var wallet = await ConnectWallet(options);
```
Will open a browser and load a static thirdweb React page to authenticate the user and redirect back to the game.
You can pass `forceSiweExternalWalletIds` to force the page to use one or more wallets.
Example:
```csharp
var ecosystemWalletOptions = new EcosystemWalletOptions(
ecosystemId: "ecosystem.your-ecosystem",
authprovider: AuthProvider.SiweExternal,
forceSiweExternalWalletIds: new List<string> { "xyz.abs" });
```
### Login with Custom Auth - OIDC Compatible
```csharp
@@ -142,6 +167,22 @@ var options = new WalletOptions(
var wallet = await ThirdwebManager.Instance.ConnectWallet(options);
```
### Login with Backend - Server Wallets
```csharp
var ecosystemWalletOptions = new EcosystemWalletOptions(
ecosystemId: "ecosystem.your-ecosystem",
authprovider: AuthProvider.Backend,
walletSecret: "very-secret"
);
var options = new WalletOptions(
provider: WalletProvider.EcosystemWallet,
chainId: 1,
ecosystemWalletOptions: ecosystemWalletOptions
);
var wallet = await ThirdwebManager.Instance.ConnectWallet(options);
```
## Account Linking
EcosystemWallets support linking multiple authentication methods to a single wallet, for instance linking Google to your Email-based In-App-Wallet. This is useful to have a unified identity across platforms.
@@ -18,11 +18,13 @@ It makes for a fantastic [SmartWallet](/unity/v5/wallets/account-abstraction) ad
In-App Wallets support a variety of login methods:
- Email (OTP Login)
- Phone (OTP Login)
- Socials (Google, Apple, Facebook, Telegram, Farcaster, Line, Github, Twitch, Steam, etc.)
- SIWE (Sign-In with Ethereum)
- Socials (Google, Apple, Facebook, Telegram, Farcaster, Line, Github, Twitch, Steam etc.)
- Custom Auth (OIDC Compatible)
- Custom Auth (Generic Auth Endpoint)
- Guest (Onboard easily, link other accounts later)
- Backend (Server Wallets)
- Siwe (Login with a seperate wallet supported by the SDK)
- SiweExternal (Login with an external wallet that only supports web using a browser loading a static thirdweb React page temporarily)
### Login with Email
@@ -66,10 +68,10 @@ var wallet = await ThirdwebManager.Instance.ConnectWallet(options);
Will open a native browser or oauth session to authenticate the user and redirect back to the game.
### Login with SIWE
### Login with Siwe
```csharp
var inAppWalletOptions = new InAppWalletOptions(authprovider: AuthProvider.Siwe, siweSigner: anyExternalWallet);
var inAppWalletOptions = new InAppWalletOptions(authprovider: AuthProvider.Siwe, siweSigner: sdkSupportedExternalWallet);
var options = new WalletOptions(
provider: WalletProvider.InAppWallet,
chainId: 1,
@@ -78,7 +80,29 @@ var options = new WalletOptions(
var wallet = await ThirdwebManager.Instance.ConnectWallet(options);
```
Will use the external wallet to sign a message and login to the InAppWallet.
Will use a separate wallet to sign a message and login to the InAppWallet.
### Login with SiweExternal
```csharp
var inAppWalletOptions = new InAppWalletOptions(authprovider: AuthProvider.SiweExternal)
var options = new WalletOptions(
provider: WalletProvider.InAppWallet,
chainId: 421614,
inAppWalletOptions: inAppWalletOptions
);
var wallet = await ConnectWallet(inAppWalletOptions);
```
Will open a browser and load a static thirdweb React page to authenticate the user and redirect back to the game.
You can pass `forceSiweExternalWalletIds` to force the page to use one or more wallets.
Example:
```csharp
var inAppWalletOptions = new InAppWalletOptions(
authprovider: AuthProvider.SiweExternal,
forceSiweExternalWalletIds: new List<string> { "xyz.abs" });
```
### Login with Custom Auth - OIDC Compatible
@@ -122,6 +146,21 @@ var options = new WalletOptions(
var wallet = await ThirdwebManager.Instance.ConnectWallet(options);
```
### Login with Backend - Server Wallets
```csharp
var inAppWalletOptions = new InAppWalletOptions(
authprovider: AuthProvider.Backend,
walletSecret: "very-secret"
);
var options = new WalletOptions(
provider: WalletProvider.InAppWallet,
chainId: 1,
inAppWalletOptions: inAppWalletOptions
);
var wallet = await ThirdwebManager.Instance.ConnectWallet(options);
```
## Account Linking
InAppWallets support linking multiple authentication methods to a single wallet, for instance linking Google to your Email-based In-App-Wallet. This is useful to have a unified identity across platforms.
@@ -20,6 +20,7 @@ type DynamicImageOptions = {
| "storage"
| "changelog"
| "dotnet"
| "nebula"
| "unreal-engine"
| "insight";
};
+6
View File
@@ -1,4 +1,8 @@
coverage:
ignore:
- "**/__generated__/**"
- "**/contract/deployment/zksync/**"
- "*/thirdweb/src/contract/deployment/zksync/zkDeployContract.ts"
status:
project:
default:
@@ -10,6 +14,8 @@ coverage:
- packages
ignore:
- "**/__generated__/**"
- "**/contract/deployment/zksync/**"
- "*/thirdweb/src/contract/deployment/zksync/zkDeployContract.ts"
github_checks:
+14
View File
@@ -1,5 +1,19 @@
# @thirdweb-dev/service-utils
## 0.8.7
### Patch Changes
- [#6206](https://github.com/thirdweb-dev/js/pull/6206) [`8aeb4d6`](https://github.com/thirdweb-dev/js/commit/8aeb4d6308a2624a3e483aa4da408bbbdc8da688) Thanks [@arcoraven](https://github.com/arcoraven)! - Export usageV2 sources
## 0.8.6
### Patch Changes
- [#6172](https://github.com/thirdweb-dev/js/pull/6172) [`a8ac98b`](https://github.com/thirdweb-dev/js/commit/a8ac98bd493d1d6e824aad868dea1ec42ff22768) Thanks [@catalyst17](https://github.com/catalyst17)! - add `canCreatePublicChains` to TeamResponse
- [#6183](https://github.com/thirdweb-dev/js/pull/6183) [`18f0e57`](https://github.com/thirdweb-dev/js/commit/18f0e575c4a9764b3192931451cbd3f632a21e3c) Thanks [@arcoraven](https://github.com/arcoraven)! - [service-utils] Update list of usageV2 sources"
## 0.8.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@thirdweb-dev/service-utils",
"version": "0.8.5",
"version": "0.8.7",
"type": "module",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
+1
View File
@@ -51,6 +51,7 @@ export type TeamResponse = {
billingEmail: string | null;
billingStatus: "noPayment" | "validPayment" | "invalidPayment" | null;
growthTrialEligible: false;
canCreatePublicChains: boolean | null;
enabledScopes: ServiceName[];
};
+14 -7
View File
@@ -1,6 +1,17 @@
import type { ServiceName } from "./services.js";
export type UsageV2Source = ServiceName | "sdk";
export const USAGE_V2_SOURCES = [
"bundler",
"engine",
"insight",
"nebula",
"rpc",
"sdk",
"storage",
"wallet",
] as const;
export type UsageV2Source = (typeof USAGE_V2_SOURCES)[number];
export function getTopicName(source: UsageV2Source) {
return `usage_v2.raw_${source}`;
}
export interface UsageV2Event {
/**
@@ -54,7 +65,3 @@ export interface UsageV2Event {
*/
[key: string]: boolean | number | string | Date | null | undefined;
}
export function getTopicName(source: UsageV2Source) {
return `usage_v2.raw_${source}`;
}
+1
View File
@@ -46,6 +46,7 @@ export const validTeamResponse: TeamResponse = {
billingEmail: "[email protected]",
billingStatus: "noPayment",
growthTrialEligible: false,
canCreatePublicChains: false,
enabledScopes: ["storage", "rpc", "bundler"],
};
+82
View File
@@ -1,5 +1,87 @@
# thirdweb
## 5.88.1
### Patch Changes
- [#6204](https://github.com/thirdweb-dev/js/pull/6204) [`f1cd253`](https://github.com/thirdweb-dev/js/commit/f1cd2539d1be15eb18807b3f5f2b90509e3d58cf) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Update coinbase wallet sdk
## 5.88.0
### Minor Changes
- [#6194](https://github.com/thirdweb-dev/js/pull/6194) [`9663079`](https://github.com/thirdweb-dev/js/commit/966307906212ac99dc0a2a9be88e514c920d39c4) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Added session keys to smart wallet options
You can now pass a `sessionKey` to the `smartWallet` options function to immediately add a session key to the smart wallet upon connection.
This is great in combination with an engine backend wallet! Let's you act on behalf of the user from your backend, making executing transactions as easy as a REST API call. Also unblocks automations, like renewing a subscription, or paying for a service.
```ts
const wallet = smartWallet({
sessionKey: {
address: "0x...", // the session key address (ex: engine backend wallet)
permissions: {
approvedTargets: ["0x..."], // allowed contract addresses (or * for all)
nativeTokenLimitPerTransaction: 0.1, // max spend per transaction in ETH
permissionEndTimestamp: new Date(Date.now() + 1000 * 60 * 60), // expiration date
},
},
});
// this will connect the user wallet and add the session key if not already added
await wallet.connect({
client: TEST_CLIENT,
personalAccount,
});
```
You can also pass the `sessionKey` to the `ConnectButton`, `ConnectEmbed` components and `useConnect` hook.
```tsx
<ConnectButton
client={client}
accountAbstraction={{
chain,
sponsorGas: true,
sessionKey: {
address: "0x...",
permissions: {
approvedTargets: "*",
},
},
}}
/>
```
Also works for the `inAppWallet` `smartAccount` option!
```ts
const wallet = inAppWallet({
smartAccount: {
chain,
sponsorGas: true,
sessionKey: {
address: "0x...",
permissions: {
approvedTargets: "*",
},
},
},
});
```
### Patch Changes
- [#6193](https://github.com/thirdweb-dev/js/pull/6193) [`1f6bb7c`](https://github.com/thirdweb-dev/js/commit/1f6bb7c3294d70648b120a6a6a6cba13302a84fc) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Show fiat amount in PayEmbed main screen
- [#6192](https://github.com/thirdweb-dev/js/pull/6192) [`30e13e6`](https://github.com/thirdweb-dev/js/commit/30e13e6b9176265a2f4eddfa53578889abbcb750) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Allow editing pay amount mid flow
- [#6190](https://github.com/thirdweb-dev/js/pull/6190) [`2dfc245`](https://github.com/thirdweb-dev/js/commit/2dfc245d44dde86e42f6c799305db707316432aa) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Add fiat provider selection in PayEmbed
- [#6187](https://github.com/thirdweb-dev/js/pull/6187) [`ee57ded`](https://github.com/thirdweb-dev/js/commit/ee57ded902cb69da6fc171599a4a90776e650149) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Requery allowances when getting back to quote screen
- [#6188](https://github.com/thirdweb-dev/js/pull/6188) [`64d7bf3`](https://github.com/thirdweb-dev/js/commit/64d7bf358fe2014b684688d41d525a75e47f1b82) Thanks [@jnsdls](https://github.com/jnsdls)! - when explicitly passing `clientId` to `createThirdwebClient()` prefer it over computing the `clientId` from a passed `secretKey` option
## 5.87.4
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "thirdweb",
"version": "5.87.4",
"version": "5.88.1",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
@@ -207,7 +207,7 @@
"!tsconfig.build.json"
],
"dependencies": {
"@coinbase/wallet-sdk": "4.2.4",
"@coinbase/wallet-sdk": "4.3.0",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@google/model-viewer": "2.1.1",
+2 -2
View File
@@ -13,9 +13,9 @@ describe("client", () => {
expect(client.clientId).toBe(computeClientIdFromSecretKey("bar"));
expect(client.secretKey).toBe("bar");
});
it("should ignore clientId if secretKey is provided", () => {
it("should NOT ignore clientId if secretKey is provided", () => {
const client = createThirdwebClient({ clientId: "foo", secretKey: "bar" });
expect(client.clientId).toBe(computeClientIdFromSecretKey("bar"));
expect(client.clientId).toBe("foo");
expect(client.secretKey).toBe("bar");
});
it("should throw an error if neither clientId nor secretKey is provided", () => {
+2 -1
View File
@@ -114,7 +114,8 @@ export function createThirdwebClient(
throw new Error("clientId must be provided when using a JWT secretKey");
}
} else {
realClientId = computeClientIdFromSecretKey(secretKey);
// always PREFER the clientId if provided, only compute it from the secretKey if we don't have a clientId passed explicitly
realClientId = clientId ?? computeClientIdFromSecretKey(secretKey);
}
}
@@ -6,6 +6,7 @@ import { sendAndConfirmTransaction } from "../../../transaction/actions/send-and
import { prepareTransaction } from "../../../transaction/prepare-transaction.js";
import { normalizeFunctionParams } from "../../../utils/abi/normalizeFunctionParams.js";
import { CONTRACT_DEPLOYER_ADDRESS } from "../../../utils/any-evm/zksync/constants.js";
import { ensureBytecodePrefix } from "../../../utils/bytecode/prefix.js";
import type { Hex } from "../../../utils/encoding/hex.js";
import type { ClientAndChainAndAccount } from "../../../utils/types.js";
import { zkDeployContractDeterministic } from "./zkDeployDeterministic.js";
@@ -22,6 +23,8 @@ export async function zkDeployContract(
deploymentType?: "create" | "create2";
},
) {
const bytecode = ensureBytecodePrefix(options.bytecode);
if (options.salt !== undefined) {
// if a salt is provided, use the deterministic deployer
return zkDeployContractDeterministic(options);
@@ -29,7 +32,7 @@ export async function zkDeployContract(
const data = encodeDeployData({
abi: options.abi,
bytecode: options.bytecode,
bytecode,
deploymentType: options.deploymentType ?? "create",
args: normalizeFunctionParams(
options.abi.find((abi) => abi.type === "constructor"),
@@ -45,7 +48,7 @@ export async function zkDeployContract(
to: CONTRACT_DEPLOYER_ADDRESS,
data,
eip712: {
factoryDeps: [options.bytecode],
factoryDeps: [bytecode],
// TODO (zksync): allow passing in a paymaster
},
}),
@@ -1,9 +1,6 @@
import type { Hash } from "viem";
import { getCachedChain } from "../../chains/utils.js";
import type { ThirdwebClient } from "../../client/client.js";
import { getContract } from "../../contract/contract.js";
import { allowance } from "../../extensions/erc20/__generated__/IERC20/read/allowance.js";
import { approve } from "../../extensions/erc20/write/approve.js";
import type { PrepareTransactionOptions } from "../../transaction/prepare-transaction.js";
import { getClientFetch } from "../../utils/fetch.js";
import { stringify } from "../../utils/json.js";
@@ -151,7 +148,7 @@ type BuyWithCryptoQuoteRouteResponse = {
*/
export type BuyWithCryptoQuote = {
transactionRequest: PrepareTransactionOptions;
approval?: PrepareTransactionOptions;
approvalData?: QuoteApprovalInfo;
swapDetails: {
fromAddress: string;
@@ -254,28 +251,6 @@ export async function getBuyWithCryptoQuote(
// check if the fromAddress already has approval for the given amount
const approvalData = data.approval;
let approval = undefined;
if (approvalData) {
const contract = getContract({
client: params.client,
address: approvalData.tokenAddress,
chain: getCachedChain(approvalData.chainId),
});
const approvedAmount = await allowance({
contract,
spender: approvalData.spenderAddress,
owner: params.fromAddress,
});
if (approvedAmount < BigInt(approvalData.amountWei)) {
approval = approve({
contract,
spender: approvalData.spenderAddress,
amountWei: BigInt(approvalData.amountWei),
});
}
}
const swapRoute: BuyWithCryptoQuote = {
transactionRequest: {
@@ -287,7 +262,7 @@ export async function getBuyWithCryptoQuote(
gas: BigInt(data.transactionRequest.gasLimit),
gasPrice: undefined, // ignore gas price returned by the quote, we handle it ourselves
},
approval: approval,
approvalData,
swapDetails: {
fromAddress: data.fromAddress,
toAddress: data.toAddress,
@@ -1,8 +1,6 @@
import type { Hash } from "viem";
import { getCachedChain } from "../../chains/utils.js";
import type { ThirdwebClient } from "../../client/client.js";
import { getContract } from "../../contract/contract.js";
import { approve } from "../../extensions/erc20/write/approve.js";
import type { PrepareTransactionOptions } from "../../transaction/prepare-transaction.js";
import type { Address } from "../../utils/address.js";
import { getClientFetch } from "../../utils/fetch.js";
@@ -83,7 +81,7 @@ type BuyWithCryptoTransferResponse = {
*/
export type BuyWithCryptoTransfer = {
transactionRequest: PrepareTransactionOptions;
approval?: PrepareTransactionOptions;
approvalData?: QuoteApprovalInfo;
fromAddress: string;
toAddress: string;
paymentToken: QuotePaymentToken;
@@ -159,17 +157,7 @@ export async function getBuyWithCryptoTransfer(
value: BigInt(data.transactionRequest.value),
gas: BigInt(data.transactionRequest.gasLimit),
},
approval: data.approval
? approve({
contract: getContract({
client: params.client,
address: data.approval.tokenAddress,
chain: getCachedChain(data.approval.chainId),
}),
spender: data.approval.spenderAddress as Address,
amountWei: BigInt(data.approval.amountWei),
})
: undefined,
approvalData: data.approval,
fromAddress: data.fromAddress,
toAddress: data.toAddress,
paymentToken: data.paymentToken,
@@ -238,6 +238,11 @@ export type BuyWithFiatQuote = {
*
*/
onRampLink: string;
/**
* The provider that was used to get the quote.
*/
provider: FiatProvider;
};
/**
@@ -17,4 +17,6 @@ export type PayOnChainTransactionDetails = {
explorerLink?: string;
};
export type FiatProvider = "STRIPE" | "TRANSAK" | "KADO" | "COINBASE";
export type FiatProvider = (typeof FiatProviders)[number];
export const FiatProviders = ["COINBASE", "STRIPE", "TRANSAK", "KADO"] as const;
@@ -2,6 +2,7 @@ import type { AsyncStorage } from "../../../utils/storage/AsyncStorage.js";
import type { AuthArgsType } from "../../../wallets/in-app/core/authentication/types.js";
export const LAST_AUTH_PROVIDER_STORAGE_KEY = "lastAuthProvider";
export const PREFERRED_FIAT_PROVIDER_STORAGE_KEY = "preferredFiatProvider";
export async function setLastAuthProvider(
authProvider: AuthArgsType["strategy"],
@@ -3,24 +3,19 @@ import { useCallback, useMemo, useState } from "react";
import type { Chain } from "../../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../constants/addresses.js";
import type { GetBuyWithCryptoQuoteParams } from "../../../../../../pay/buyWithCrypto/getQuote.js";
import type { BuyWithCryptoStatus } from "../../../../../../pay/buyWithCrypto/getStatus.js";
import type { BuyWithFiatStatus } from "../../../../../../pay/buyWithFiat/getStatus.js";
import { isSwapRequiredPostOnramp } from "../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js";
import { formatNumber } from "../../../../../../utils/formatNumber.js";
import type { Account } from "../../../../../../wallets/interfaces/wallet.js";
import type { WalletId } from "../../../../../../wallets/wallet-types.js";
import {
type Theme,
fontSize,
spacing,
} from "../../../../../core/design-system/index.js";
import type {
FundWalletOptions,
PayUIOptions,
} from "../../../../../core/hooks/connection/ConnectButtonProps.js";
import { useWalletBalance } from "../../../../../core/hooks/others/useWalletBalance.js";
import { useBuyWithCryptoQuote } from "../../../../../core/hooks/pay/useBuyWithCryptoQuote.js";
import { useBuyWithFiatQuote } from "../../../../../core/hooks/pay/useBuyWithFiatQuote.js";
import { useActiveAccount } from "../../../../../core/hooks/wallets/useActiveAccount.js";
import { invalidateWalletBalance } from "../../../../../core/providers/invalidateWalletBalance.js";
import type { SupportedTokens } from "../../../../../core/utils/defaultTokens.js";
@@ -28,16 +23,10 @@ import { ErrorState } from "../../../../wallets/shared/ErrorState.js";
import { LoadingScreen } from "../../../../wallets/shared/LoadingScreen.js";
import type { PayEmbedConnectOptions } from "../../../PayEmbed.js";
import { ChainName } from "../../../components/ChainName.js";
import {
Drawer,
DrawerOverlay,
useDrawer,
} from "../../../components/Drawer.js";
import { Spacer } from "../../../components/Spacer.js";
import { Spinner } from "../../../components/Spinner.js";
import { SwitchNetworkButton } from "../../../components/SwitchNetwork.js";
import { Container, Line, ModalHeader } from "../../../components/basic.js";
import { Button } from "../../../components/buttons.js";
import { Input } from "../../../components/formElements.js";
import { Text } from "../../../components/text.js";
import { TokenSymbol } from "../../../components/token/TokenSymbol.js";
import { ConnectButton } from "../../ConnectButton.js";
@@ -47,13 +36,11 @@ import { TokenSelector } from "../TokenSelector.js";
import { WalletSwitcherConnectionScreen } from "../WalletSwitcherConnectionScreen.js";
import { type ERC20OrNativeToken, isNativeToken } from "../nativeToken.js";
import { DirectPaymentModeScreen } from "./DirectPaymentModeScreen.js";
import { EstimatedTimeAndFees } from "./EstimatedTimeAndFees.js";
import { PayTokenIcon } from "./PayTokenIcon.js";
import { PayWithCreditCard } from "./PayWIthCreditCard.js";
import { TransactionModeScreen } from "./TransactionModeScreen.js";
import { CurrencySelection } from "./fiat/CurrencySelection.js";
import { FiatFlow } from "./fiat/FiatFlow.js";
import type { CurrencyMeta } from "./fiat/currencies.js";
import { FiatScreenContent } from "./fiat/FiatScreenContent.js";
import type { SelectedScreen } from "./main/types.js";
import {
type PaymentMethods,
@@ -64,20 +51,16 @@ import {
useFromTokenSelectionStates,
useToTokenSelectionStates,
} from "./main/useUISelectionStates.js";
import { openOnrampPopup } from "./openOnRamppopup.js";
import { BuyTokenInput } from "./swap/BuyTokenInput.js";
import { FiatFees, SwapFees } from "./swap/Fees.js";
import { PayWithCryptoQuoteInfo } from "./swap/PayWithCrypto.js";
import { PaymentSelectionScreen } from "./swap/PaymentSelectionScreen.js";
import { SwapFlow } from "./swap/SwapFlow.js";
import { SwapScreenContent } from "./swap/SwapScreenContent.js";
import { TransferFlow } from "./swap/TransferFlow.js";
import { addPendingTx } from "./swap/pendingSwapTx.js";
import {
type SupportedChainAndTokens,
useBuySupportedDestinations,
useBuySupportedSources,
} from "./swap/useSwapSupportedChains.js";
import type { PayerInfo } from "./types.js";
import { usePayerSetup } from "./usePayerSetup.js";
export type BuyScreenProps = {
@@ -322,6 +305,7 @@ function BuyScreenContent(props: BuyScreenContentProps) {
});
}}
onSuccess={onSwapSuccess}
approvalAmount={screen.approvalAmount}
/>
);
}
@@ -536,6 +520,7 @@ function BuyScreenContent(props: BuyScreenContentProps) {
selectedChain={toChain}
selectedToken={toToken}
tokenAmount={tokenAmount}
setTokenAmount={setTokenAmount}
client={client}
onBack={() => {
if (
@@ -663,8 +648,17 @@ function SelectedTokenInfo(props: {
selectedToken: ERC20OrNativeToken;
selectedChain: Chain;
tokenAmount: string;
setTokenAmount: (amount: string) => void;
client: ThirdwebClient;
}) {
const getWidth = () => {
let chars = props.tokenAmount.replace(".", "").length;
const hasDot = props.tokenAmount.includes(".");
if (hasDot) {
chars += 0.3;
}
return `calc(${`${Math.max(1, chars)}ch + 2px`})`;
};
return (
<div>
<Container
@@ -676,9 +670,59 @@ function SelectedTokenInfo(props: {
}}
>
<Container flex="row" gap="xs" center="y">
<Text color="primaryText" data-testid="tokenAmount" size="xl">
{formatNumber(Number(props.tokenAmount), 6)}
</Text>
<Input
variant="outline"
pattern="^[0-9]*[.,]?[0-9]*$"
inputMode="decimal"
tabIndex={-1}
placeholder="0"
type="text"
data-placeholder={props.tokenAmount === ""}
value={props.tokenAmount || "0"}
disabled={false} // TODO: add disabled freeze amount
onClick={(e) => {
// put cursor at the end of the input
if (props.tokenAmount === "") {
e.currentTarget.setSelectionRange(
e.currentTarget.value.length,
e.currentTarget.value.length,
);
}
}}
onChange={(e) => {
let value = e.target.value;
if (value.startsWith(".")) {
value = `0${value}`;
}
if (value.length > 10) {
return;
}
const numValue = Number(value);
if (Number.isNaN(numValue)) {
return;
}
if (value.startsWith("0") && !value.startsWith("0.")) {
props.setTokenAmount(value.slice(1));
} else {
props.setTokenAmount(value);
}
}}
style={{
border: "none",
fontSize: fontSize.lg,
boxShadow: "none",
borderRadius: "0",
padding: "0",
paddingBlock: "2px",
fontWeight: 600,
textAlign: "left",
width: getWidth(),
}}
/>
<Container flex="row" gap="xxs" center="y">
<TokenSymbol
@@ -866,6 +910,7 @@ function TokenSelectedLayout(props: {
title: string;
children: React.ReactNode;
tokenAmount: string;
setTokenAmount: (amount: string) => void;
selectedToken: ERC20OrNativeToken;
selectedChain: Chain;
client: ThirdwebClient;
@@ -888,6 +933,7 @@ function TokenSelectedLayout(props: {
selectedToken={props.selectedToken}
selectedChain={props.selectedChain}
tokenAmount={props.tokenAmount}
setTokenAmount={props.setTokenAmount}
client={props.client}
/>
@@ -904,490 +950,6 @@ function TokenSelectedLayout(props: {
);
}
function SwapScreenContent(props: {
setScreen: (screen: SelectedScreen) => void;
tokenAmount: string;
toToken: ERC20OrNativeToken;
toChain: Chain;
fromChain: Chain;
fromToken: ERC20OrNativeToken;
showFromTokenSelector: () => void;
payer: PayerInfo;
client: ThirdwebClient;
payOptions: PayUIOptions;
isEmbed: boolean;
onDone: () => void;
connectOptions: PayEmbedConnectOptions | undefined;
connectLocale: ConnectLocale;
setPayer: (payer: PayerInfo) => void;
activeAccount: Account;
setTokenAmount: (amount: string) => void;
setHasEditedAmount: (hasEdited: boolean) => void;
disableTokenSelection: boolean;
}) {
const {
setScreen,
payer,
client,
toChain,
tokenAmount,
toToken,
fromChain,
fromToken,
payOptions,
disableTokenSelection,
} = props;
const defaultRecipientAddress = (
props.payOptions as Extract<PayUIOptions, { mode: "direct_payment" }>
)?.paymentInfo?.sellerAddress;
const receiverAddress =
defaultRecipientAddress || props.activeAccount.address;
const { drawerRef, drawerOverlayRef, isOpen, setIsOpen } = useDrawer();
const [drawerScreen, setDrawerScreen] = useState<
"fees" | "receiver" | "payer"
>("fees");
const fromTokenBalanceQuery = useWalletBalance({
address: payer.account.address,
chain: fromChain,
tokenAddress: isNativeToken(fromToken) ? undefined : fromToken.address,
client,
});
const fromTokenId = isNativeToken(fromToken)
? NATIVE_TOKEN_ADDRESS
: fromToken.address.toLowerCase();
const toTokenId = isNativeToken(toToken)
? NATIVE_TOKEN_ADDRESS
: toToken.address.toLowerCase();
const swapRequired =
!!tokenAmount &&
!(fromChain.id === toChain.id && fromTokenId === toTokenId);
const quoteParams: GetBuyWithCryptoQuoteParams | undefined = swapRequired
? {
// wallets
fromAddress: payer.account.address,
toAddress: receiverAddress,
// from
fromChainId: fromChain.id,
fromTokenAddress: isNativeToken(fromToken)
? NATIVE_TOKEN_ADDRESS
: fromToken.address,
// to
toChainId: toChain.id,
toTokenAddress: isNativeToken(toToken)
? NATIVE_TOKEN_ADDRESS
: toToken.address,
toAmount: tokenAmount,
client,
purchaseData: payOptions.purchaseData,
}
: undefined;
const quoteQuery = useBuyWithCryptoQuote(quoteParams, {
// refetch every 30 seconds
staleTime: 30 * 1000,
refetchInterval: 30 * 1000,
gcTime: 30 * 1000,
});
const sourceTokenAmount = swapRequired
? quoteQuery.data?.swapDetails.fromAmount
: tokenAmount;
const isNotEnoughBalance =
!!sourceTokenAmount &&
!!fromTokenBalanceQuery.data &&
Number(fromTokenBalanceQuery.data.displayValue) < Number(sourceTokenAmount);
const disableContinue =
(swapRequired && !quoteQuery.data) || isNotEnoughBalance;
const switchChainRequired =
props.payer.wallet.getChain()?.id !== fromChain.id;
const errorMsg =
!quoteQuery.isLoading && quoteQuery.error
? getErrorMessage(quoteQuery.error)
: undefined;
function showSwapFlow() {
if (
(props.payOptions.mode === "direct_payment" ||
props.payOptions.mode === "fund_wallet") &&
!isNotEnoughBalance &&
!swapRequired
) {
// same currency, just direct transfer
setScreen({
id: "transfer-flow",
});
} else if (
props.payOptions.mode === "transaction" &&
!isNotEnoughBalance &&
!swapRequired
) {
if (payer.account.address !== receiverAddress) {
// needs transfer from another wallet before executing the transaction
setScreen({
id: "transfer-flow",
});
} else {
// has enough balance to just do the transaction directly
props.onDone();
}
return;
}
if (!quoteQuery.data) {
return;
}
setScreen({
id: "swap-flow",
quote: quoteQuery.data,
});
}
function showFees() {
if (!quoteQuery.data) {
return;
}
setIsOpen(true);
setDrawerScreen("fees");
}
return (
<Container flex="column" gap="md" animate="fadein">
{isOpen && (
<>
<DrawerOverlay ref={drawerOverlayRef} />
<Drawer ref={drawerRef} close={() => setIsOpen(false)}>
{drawerScreen === "fees" && quoteQuery.data && (
<div>
<Text size="lg" color="primaryText">
Fees
</Text>
<Spacer y="lg" />
<SwapFees quote={quoteQuery.data} />
</div>
)}
</Drawer>
</>
)}
{/* Quote info */}
<div>
<PayWithCryptoQuoteInfo
value={sourceTokenAmount || ""}
chain={fromChain}
token={fromToken}
isLoading={quoteQuery.isLoading && !sourceTokenAmount}
client={client}
freezeChainAndTokenSelection={disableTokenSelection}
payerAccount={props.payer.account}
swapRequired={swapRequired}
/>
{swapRequired && (
<EstimatedTimeAndFees
quoteIsLoading={quoteQuery.isLoading}
estimatedSeconds={
quoteQuery.data?.swapDetails.estimated.durationSeconds
}
onViewFees={showFees}
/>
)}
<Spacer y="md" />
</div>
{/* Error message */}
{errorMsg && (
<div>
{errorMsg.data?.minimumAmountEth ? (
<Text color="danger" size="sm" center multiline>
Minimum amount is{" "}
{formatNumber(Number(errorMsg.data.minimumAmountEth), 6)}{" "}
<TokenSymbol
token={toToken}
chain={toChain}
size="sm"
inline
color="danger"
/>
</Text>
) : (
<Text color="danger" size="sm" center multiline>
{errorMsg.message || defaultMessage}
</Text>
)}
</div>
)}
{!errorMsg && isNotEnoughBalance && (
<div>
<Text color="danger" size="sm" center multiline>
Not enough funds.
</Text>
<Text color="danger" size="sm" center multiline>
Try a different wallet or token.
</Text>
</div>
)}
{/* Button */}
{errorMsg?.data?.minimumAmountEth ? (
<Button
variant="accent"
fullWidth
onClick={() => {
props.setTokenAmount(
formatNumber(
Number(errorMsg.data?.minimumAmountEth),
6,
).toString(),
);
props.setHasEditedAmount(true);
}}
>
Set Minimum
</Button>
) : switchChainRequired &&
!quoteQuery.isLoading &&
!isNotEnoughBalance &&
!quoteQuery.error ? (
<SwitchNetworkButton
variant="accent"
fullWidth
switchChain={async () => {
await props.payer.wallet.switchChain(fromChain);
}}
/>
) : (
<Button
variant={disableContinue ? "outline" : "accent"}
fullWidth
data-disabled={disableContinue}
disabled={disableContinue}
onClick={async () => {
if (!disableContinue) {
showSwapFlow();
}
}}
gap="xs"
>
{quoteQuery.isLoading ? (
<>
Getting price quote
<Spinner size="sm" color="accentText" />
</>
) : (
"Continue"
)}
</Button>
)}
</Container>
);
}
function FiatScreenContent(props: {
setScreen: (screen: SelectedScreen) => void;
tokenAmount: string;
toToken: ERC20OrNativeToken;
toChain: Chain;
selectedCurrency: CurrencyMeta;
showCurrencySelector: () => void;
payOptions: PayUIOptions;
theme: "light" | "dark" | Theme;
client: ThirdwebClient;
onDone: () => void;
isEmbed: boolean;
payer: PayerInfo;
setTokenAmount: (amount: string) => void;
setHasEditedAmount: (hasEdited: boolean) => void;
}) {
const {
toToken,
tokenAmount,
payer,
client,
setScreen,
toChain,
showCurrencySelector,
selectedCurrency,
} = props;
const defaultRecipientAddress = (
props.payOptions as Extract<PayUIOptions, { mode: "direct_payment" }>
)?.paymentInfo?.sellerAddress;
const receiverAddress =
defaultRecipientAddress || props.payer.account.address;
const { drawerRef, drawerOverlayRef, isOpen, setIsOpen } = useDrawer();
const [drawerScreen, setDrawerScreen] = useState<"fees">("fees");
const buyWithFiatOptions = props.payOptions.buyWithFiat;
const fiatQuoteQuery = useBuyWithFiatQuote(
buyWithFiatOptions !== false && tokenAmount
? {
fromCurrencySymbol: selectedCurrency.shorthand,
toChainId: toChain.id,
toAddress: receiverAddress,
toTokenAddress: isNativeToken(toToken)
? NATIVE_TOKEN_ADDRESS
: toToken.address,
toAmount: tokenAmount,
client,
isTestMode: buyWithFiatOptions?.testMode,
purchaseData: props.payOptions.purchaseData,
fromAddress: payer.account.address,
preferredProvider: buyWithFiatOptions?.preferredProvider,
}
: undefined,
);
function handleSubmit() {
if (!fiatQuoteQuery.data) {
return;
}
const hasTwoSteps = isSwapRequiredPostOnramp(fiatQuoteQuery.data);
let openedWindow: Window | null = null;
if (!hasTwoSteps) {
openedWindow = openOnrampPopup(
fiatQuoteQuery.data.onRampLink,
typeof props.theme === "string" ? props.theme : props.theme.type,
);
addPendingTx({
type: "fiat",
intentId: fiatQuoteQuery.data.intentId,
});
}
setScreen({
id: "fiat-flow",
quote: fiatQuoteQuery.data,
openedWindow,
});
}
function showFees() {
if (!fiatQuoteQuery.data) {
return;
}
setDrawerScreen("fees");
setIsOpen(true);
}
const disableSubmit = !fiatQuoteQuery.data;
const errorMsg =
!fiatQuoteQuery.isLoading && fiatQuoteQuery.error
? getErrorMessage(fiatQuoteQuery.error)
: undefined;
return (
<Container flex="column" gap="md" animate="fadein">
{isOpen && (
<>
<DrawerOverlay ref={drawerOverlayRef} />
<Drawer ref={drawerRef} close={() => setIsOpen(false)}>
{drawerScreen === "fees" && fiatQuoteQuery.data && (
<div>
<Text size="lg" color="primaryText">
Fees
</Text>
<Spacer y="lg" />
<FiatFees quote={fiatQuoteQuery.data} />
</div>
)}
</Drawer>
</>
)}
<div>
<PayWithCreditCard
isLoading={fiatQuoteQuery.isLoading}
value={fiatQuoteQuery.data?.fromCurrencyWithFees.amount}
client={client}
currency={selectedCurrency}
onSelectCurrency={showCurrencySelector}
/>
{/* Estimated time + View fees button */}
<EstimatedTimeAndFees
quoteIsLoading={fiatQuoteQuery.isLoading}
estimatedSeconds={fiatQuoteQuery.data?.estimatedDurationSeconds}
onViewFees={showFees}
/>
<Spacer y="md" />
</div>
{/* Error message */}
{errorMsg && (
<div>
{errorMsg.data?.minimumAmountEth ? (
<Text color="danger" size="sm" center multiline>
Minimum amount is{" "}
{formatNumber(Number(errorMsg.data.minimumAmountEth), 6)}{" "}
<TokenSymbol
token={toToken}
chain={toChain}
size="sm"
inline
color="danger"
/>
</Text>
) : (
<Text color="danger" size="sm" center multiline>
{errorMsg.message || defaultMessage}
</Text>
)}
</div>
)}
{errorMsg?.data?.minimumAmountEth ? (
<Button
variant="accent"
fullWidth
onClick={() => {
props.setTokenAmount(
formatNumber(
Number(errorMsg.data?.minimumAmountEth),
6,
).toString(),
);
props.setHasEditedAmount(true);
}}
>
Set Minimum
</Button>
) : (
<Button
variant={disableSubmit ? "outline" : "accent"}
data-disabled={disableSubmit}
disabled={disableSubmit}
fullWidth
onClick={handleSubmit}
gap="xs"
>
{fiatQuoteQuery.isLoading ? (
<>
Getting price quote
<Spinner size="sm" color="accentText" />
</>
) : (
"Continue"
)}
</Button>
)}
</Container>
);
}
function createSupportedTokens(
data: SupportedChainAndTokens,
payOptions: PayUIOptions,
@@ -1478,26 +1040,3 @@ function ChainSelectionScreen(props: {
/>
);
}
type ApiError = {
code: string;
message?: string;
data?: {
minimumAmountUSDCents?: string;
requestedAmountUSDCents?: string;
minimumAmountWei?: string;
minimumAmountEth?: string;
};
};
const defaultMessage = "Unable to get price quote";
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
function getErrorMessage(err: any): ApiError {
if (typeof err.error === "object") {
return err.error;
}
return {
code: "UNABLE_TO_GET_PRICE_QUOTE",
message: defaultMessage,
};
}
@@ -0,0 +1,307 @@
import { ChevronDownIcon } from "@radix-ui/react-icons";
import { useState } from "react";
import type { Chain } from "../../../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js";
import { isSwapRequiredPostOnramp } from "../../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js";
import type { FiatProvider } from "../../../../../../../pay/utils/commonTypes.js";
import { formatNumber } from "../../../../../../../utils/formatNumber.js";
import {
type Theme,
iconSize,
spacing,
} from "../../../../../../core/design-system/index.js";
import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js";
import { useBuyWithFiatQuote } from "../../../../../../core/hooks/pay/useBuyWithFiatQuote.js";
import { PREFERRED_FIAT_PROVIDER_STORAGE_KEY } from "../../../../../../core/utils/storage.js";
import {
defaultMessage,
getErrorMessage,
} from "../../../../../utils/errors.js";
import {
Drawer,
DrawerOverlay,
useDrawer,
} from "../../../../components/Drawer.js";
import { Spacer } from "../../../../components/Spacer.js";
import { Spinner } from "../../../../components/Spinner.js";
import { Container } from "../../../../components/basic.js";
import { Button } from "../../../../components/buttons.js";
import { Text } from "../../../../components/text.js";
import { TokenSymbol } from "../../../../components/token/TokenSymbol.js";
import { type ERC20OrNativeToken, isNativeToken } from "../../nativeToken.js";
import { EstimatedTimeAndFees } from "../EstimatedTimeAndFees.js";
import { PayWithCreditCard } from "../PayWIthCreditCard.js";
import type { SelectedScreen } from "../main/types.js";
import { openOnrampPopup } from "../openOnRamppopup.js";
import { FiatFees } from "../swap/Fees.js";
import { addPendingTx } from "../swap/pendingSwapTx.js";
import type { PayerInfo } from "../types.js";
import { Providers } from "./Providers.js";
import type { CurrencyMeta } from "./currencies.js";
export function FiatScreenContent(props: {
setScreen: (screen: SelectedScreen) => void;
tokenAmount: string;
toToken: ERC20OrNativeToken;
toChain: Chain;
selectedCurrency: CurrencyMeta;
showCurrencySelector: () => void;
payOptions: PayUIOptions;
theme: "light" | "dark" | Theme;
client: ThirdwebClient;
onDone: () => void;
isEmbed: boolean;
payer: PayerInfo;
setTokenAmount: (amount: string) => void;
setHasEditedAmount: (hasEdited: boolean) => void;
}) {
const {
toToken,
tokenAmount,
payer,
client,
setScreen,
toChain,
showCurrencySelector,
selectedCurrency,
} = props;
const defaultRecipientAddress = (
props.payOptions as Extract<PayUIOptions, { mode: "direct_payment" }>
)?.paymentInfo?.sellerAddress;
const receiverAddress =
defaultRecipientAddress || props.payer.account.address;
const { drawerRef, drawerOverlayRef, isOpen, setIsOpen } = useDrawer();
const [drawerScreen, setDrawerScreen] = useState<"fees" | "providers">(
"fees",
);
const buyWithFiatOptions = props.payOptions.buyWithFiat;
const [preferredProvider, setPreferredProvider] = useState<
FiatProvider | undefined
>(
buyWithFiatOptions !== false
? buyWithFiatOptions?.preferredProvider ||
((localStorage.getItem(
PREFERRED_FIAT_PROVIDER_STORAGE_KEY,
) as FiatProvider | null) ??
undefined)
: undefined,
);
const fiatQuoteQuery = useBuyWithFiatQuote(
buyWithFiatOptions !== false && tokenAmount
? {
fromCurrencySymbol: selectedCurrency.shorthand,
toChainId: toChain.id,
toAddress: receiverAddress,
toTokenAddress: isNativeToken(toToken)
? NATIVE_TOKEN_ADDRESS
: toToken.address,
toAmount: tokenAmount,
client,
isTestMode: buyWithFiatOptions?.testMode,
purchaseData: props.payOptions.purchaseData,
fromAddress: payer.account.address,
preferredProvider: preferredProvider,
}
: undefined,
);
function handleSubmit() {
if (!fiatQuoteQuery.data) {
return;
}
const hasTwoSteps = isSwapRequiredPostOnramp(fiatQuoteQuery.data);
let openedWindow: Window | null = null;
if (!hasTwoSteps) {
openedWindow = openOnrampPopup(
fiatQuoteQuery.data.onRampLink,
typeof props.theme === "string" ? props.theme : props.theme.type,
);
addPendingTx({
type: "fiat",
intentId: fiatQuoteQuery.data.intentId,
});
}
setScreen({
id: "fiat-flow",
quote: fiatQuoteQuery.data,
openedWindow,
});
}
function showFees() {
if (!fiatQuoteQuery.data) {
return;
}
setDrawerScreen("fees");
setIsOpen(true);
}
function showProviders() {
setDrawerScreen("providers");
setIsOpen(true);
}
const disableSubmit = !fiatQuoteQuery.data;
const errorMsg =
!fiatQuoteQuery.isLoading && fiatQuoteQuery.error
? getErrorMessage(fiatQuoteQuery.error)
: undefined;
return (
<Container flex="column" gap="md" animate="fadein">
{isOpen && (
<>
<DrawerOverlay ref={drawerOverlayRef} />
<Drawer ref={drawerRef} close={() => setIsOpen(false)}>
{drawerScreen === "fees" && fiatQuoteQuery.data && (
<div>
<Text size="lg" color="primaryText">
Fees
</Text>
<Spacer y="lg" />
<FiatFees quote={fiatQuoteQuery.data} />
</div>
)}
{drawerScreen === "providers" && (
<div>
<Text size="lg" color="primaryText">
Providers
</Text>
<Spacer y="lg" />
<Providers
preferredProvider={
preferredProvider || fiatQuoteQuery.data?.provider
}
onSelect={(provider) => {
setPreferredProvider(provider);
// save the pref in local storage
localStorage.setItem(
PREFERRED_FIAT_PROVIDER_STORAGE_KEY,
provider,
);
setIsOpen(false);
}}
/>
</div>
)}
</Drawer>
</>
)}
<div>
<PayWithCreditCard
isLoading={fiatQuoteQuery.isLoading}
value={fiatQuoteQuery.data?.fromCurrencyWithFees.amount}
client={client}
currency={selectedCurrency}
onSelectCurrency={showCurrencySelector}
/>
<Container
bg="tertiaryBg"
flex="row"
borderColor="borderColor"
style={{
paddingLeft: spacing.md,
justifyContent: "space-between",
alignItems: "center",
borderWidth: "1px",
borderStyle: "solid",
borderBottom: "none",
}}
>
<Text size="xs" color="secondaryText">
Provider
</Text>
<Button variant="ghost" onClick={showProviders}>
<Container flex="row" center="y" gap="xxs" color="secondaryText">
<Text size="xs">
{preferredProvider
? `${preferredProvider.charAt(0).toUpperCase() + preferredProvider.slice(1).toLowerCase()}`
: fiatQuoteQuery.data?.provider
? `${fiatQuoteQuery.data?.provider.charAt(0).toUpperCase() + fiatQuoteQuery.data?.provider.slice(1).toLowerCase()}`
: ""}
</Text>
<ChevronDownIcon width={iconSize.sm} height={iconSize.sm} />
</Container>
</Button>
</Container>
{/* Estimated time + View fees button */}
<EstimatedTimeAndFees
quoteIsLoading={fiatQuoteQuery.isLoading}
estimatedSeconds={fiatQuoteQuery.data?.estimatedDurationSeconds}
onViewFees={showFees}
/>
<Spacer y="md" />
</div>
{/* Error message */}
{errorMsg && (
<div>
{errorMsg.data?.minimumAmountEth ? (
<Text color="danger" size="sm" center multiline>
Minimum amount is{" "}
{formatNumber(Number(errorMsg.data.minimumAmountEth), 6)}{" "}
<TokenSymbol
token={toToken}
chain={toChain}
size="sm"
inline
color="danger"
/>
</Text>
) : (
<Text color="danger" size="sm" center multiline>
{errorMsg.message || defaultMessage}
</Text>
)}
</div>
)}
{errorMsg?.data?.minimumAmountEth ? (
<Button
variant="accent"
fullWidth
onClick={() => {
props.setTokenAmount(
formatNumber(
Number(errorMsg.data?.minimumAmountEth),
6,
).toString(),
);
props.setHasEditedAmount(true);
}}
>
Set Minimum
</Button>
) : (
<Button
variant={disableSubmit ? "outline" : "accent"}
data-disabled={disableSubmit}
disabled={disableSubmit}
fullWidth
onClick={handleSubmit}
gap="xs"
>
{fiatQuoteQuery.isLoading ? (
<>
Getting price quote
<Spinner size="sm" color="accentText" />
</>
) : (
"Continue"
)}
</Button>
)}
</Container>
);
}
@@ -1,6 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { getCachedChain } from "../../../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
import { getContract } from "../../../../../../../contract/contract.js";
import { allowance } from "../../../../../../../extensions/erc20/__generated__/IERC20/read/allowance.js";
import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js";
import type { BuyWithCryptoStatus } from "../../../../../../../pay/buyWithCrypto/getStatus.js";
import { getPostOnRampQuote } from "../../../../../../../pay/buyWithFiat/getPostOnRampQuote.js";
@@ -43,11 +46,38 @@ export function PostOnRampSwap(props: {
refetchOnWindowFocus: false,
});
const allowanceQuery = useQuery({
queryKey: [
"allowance",
props.payer.account.address,
postOnRampQuoteQuery.data?.approvalData,
],
queryFn: () => {
if (!postOnRampQuoteQuery.data?.approvalData) {
return null;
}
return allowance({
contract: getContract({
client: props.client,
address: postOnRampQuoteQuery.data.swapDetails.fromToken.tokenAddress,
chain: getCachedChain(
postOnRampQuoteQuery.data.swapDetails.fromToken.chainId,
),
}),
spender: postOnRampQuoteQuery.data.approvalData.spenderAddress,
owner: props.payer.account.address,
});
},
enabled: !!postOnRampQuoteQuery.data?.approvalData,
refetchOnMount: true,
});
useEffect(() => {
if (
postOnRampQuoteQuery.data &&
!lockedOnRampQuote &&
!postOnRampQuoteQuery.isRefetching
!postOnRampQuoteQuery.isRefetching &&
!allowanceQuery.isLoading
) {
setLockedOnRampQuote(postOnRampQuoteQuery.data);
}
@@ -55,6 +85,7 @@ export function PostOnRampSwap(props: {
postOnRampQuoteQuery.data,
lockedOnRampQuote,
postOnRampQuoteQuery.isRefetching,
allowanceQuery.isLoading,
]);
if (postOnRampQuoteQuery.isError) {
@@ -133,6 +164,7 @@ export function PostOnRampSwap(props: {
transactionMode={props.transactionMode}
isEmbed={props.isEmbed}
onSuccess={props.onSuccess}
approvalAmount={allowanceQuery.data ?? undefined}
/>
);
}
@@ -0,0 +1,57 @@
import {
type FiatProvider,
FiatProviders,
} from "../../../../../../../pay/utils/commonTypes.js";
import { Container } from "../../../../components/basic.js";
import { Button } from "../../../../components/buttons.js";
import { Link } from "../../../../components/text.js";
/**
* @internal
*/
export function Providers(props: {
preferredProvider?: FiatProvider;
onSelect: (provider: FiatProvider) => void;
}) {
return (
<Container
expand
flex="column"
gap="sm"
style={{
alignItems: "flex-start",
}}
>
{FiatProviders.map((provider) => {
return (
<Container
key={provider}
flex="row"
expand
style={{
justifyContent: "space-between",
}}
>
<Button
fullWidth
onClick={() => props.onSelect(provider)}
variant={"link"}
>
<Link
color={
props.preferredProvider === provider
? "primaryText"
: "secondaryText"
}
size="sm"
hoverColor="primaryText"
>
{provider.charAt(0).toUpperCase() +
provider.slice(1).toLowerCase()}
</Link>
</Button>
</Container>
);
})}
</Container>
);
}
@@ -34,6 +34,7 @@ export type SelectedScreen =
| {
id: "swap-flow";
quote: BuyWithCryptoQuote;
approvalAmount?: bigint;
}
| {
id: "fiat-flow";
@@ -19,6 +19,7 @@ import { Text } from "../../../../components/text.js";
import { TokenSymbol } from "../../../../components/token/TokenSymbol.js";
import type { ERC20OrNativeToken } from "../../nativeToken.js";
import { getBuyTokenAmountFontSize } from "../utils.js";
import { FiatValue } from "./FiatValue.js";
/**
* @internal
@@ -35,7 +36,6 @@ export function BuyTokenInput(props: {
freezeChainAndToken?: boolean;
}) {
const { name } = useChainName(props.chain);
const getWidth = () => {
let chars = props.value.replace(".", "").length;
const hasDot = props.value.includes(".");
@@ -122,9 +122,19 @@ export function BuyTokenInput(props: {
</Container>
</div>
<Container flex="row" center="both">
<FiatValue
tokenAmount={props.value}
token={props.token}
chain={props.chain}
client={props.client}
size="md"
/>
</Container>
{!props.hideTokenSelector && (
<>
<Spacer y="sm" />
<Spacer y="md" />
{/* Token / Chain selector */}
<Container flex="row" center="x">
@@ -3,6 +3,8 @@ import { useState } from "react";
import { trackPayEvent } from "../../../../../../../analytics/track/pay.js";
import type { Chain } from "../../../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
import { getContract } from "../../../../../../../contract/contract.js";
import { approve } from "../../../../../../../extensions/erc20/write/approve.js";
import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js";
import { sendTransaction } from "../../../../../../../transaction/actions/send-transaction.js";
import { waitForReceipt } from "../../../../../../../transaction/actions/wait-for-tx-receipt.js";
@@ -51,9 +53,13 @@ export function SwapConfirmationScreen(props: {
fromTokenSymbol: string;
isFiatFlow: boolean;
payer: PayerInfo;
preApprovedAmount?: bigint;
}) {
const isApprovalRequired = props.quote.approval !== undefined;
const initialStep = isApprovalRequired ? "approval" : "swap";
const needsApproval =
props.quote.approvalData &&
props.preApprovedAmount !== undefined &&
props.preApprovedAmount < BigInt(props.quote.approvalData.amountWei);
const initialStep = needsApproval ? "approval" : "swap";
const [step, setStep] = useState<"approval" | "swap">(initialStep);
const [status, setStatus] = useState<
@@ -136,7 +142,7 @@ export function SwapConfirmationScreen(props: {
<Spacer y="xl" />
{/* Show 2 steps - Approve and confirm */}
{isApprovalRequired && (
{needsApproval && (
<>
<Spacer y="sm" />
<Container
@@ -187,7 +193,7 @@ export function SwapConfirmationScreen(props: {
fullWidth
disabled={status === "pending"}
onClick={async () => {
if (step === "approval" && props.quote.approval) {
if (step === "approval" && props.quote.approvalData) {
try {
setStatus("pending");
@@ -204,13 +210,22 @@ export function SwapConfirmationScreen(props: {
dstChainId: props.quote.swapDetails.toToken.chainId,
});
const transaction = approve({
contract: getContract({
client: props.client,
address: props.quote.swapDetails.fromToken.tokenAddress,
chain: props.fromChain,
}),
spender: props.quote.approvalData.spenderAddress,
amountWei: BigInt(props.quote.approvalData.amountWei),
});
const tx = await sendTransaction({
account: props.payer.account,
transaction: props.quote.approval,
transaction,
});
await waitForReceipt({ ...tx, maxBlocksWaitTime: 50 });
// props.onQuoteFinalized(props.quote);
trackPayEvent({
event: "swap_approval_success",
@@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import type { Chain } from "../../../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
import { convertCryptoToFiat } from "../../../../../../../pay/convert/cryptoToFiat.js";
import { formatNumber } from "../../../../../../../utils/formatNumber.js";
import { fontSize } from "../../../../../../core/design-system/index.js";
import { Skeleton } from "../../../../components/Skeleton.js";
import { Text } from "../../../../components/text.js";
import type { TextProps } from "../../../../components/text.js";
import { useDebouncedValue } from "../../../../hooks/useDebouncedValue.js";
import type { ERC20OrNativeToken } from "../../nativeToken.js";
import { getTokenAddress } from "../../nativeToken.js";
export function FiatValue(
props: {
tokenAmount: string;
token: ERC20OrNativeToken;
chain: Chain;
client: ThirdwebClient;
} & TextProps,
) {
const deferredTokenAmount = useDebouncedValue(props.tokenAmount, 500);
const cryptoToFiatQuery = useQuery({
queryKey: [
"cryptoToFiat",
props.chain.id,
getTokenAddress(props.token),
deferredTokenAmount,
],
queryFn: () =>
convertCryptoToFiat({
client: props.client,
chain: props.chain,
fromTokenAddress: getTokenAddress(props.token),
fromAmount: Number(deferredTokenAmount),
to: "USD",
}),
});
if (cryptoToFiatQuery.isLoading) {
return <Skeleton width={"50px"} height={fontSize.lg} />;
}
return (
<Text {...props}>
{cryptoToFiatQuery.data?.result
? `$${formatNumber(cryptoToFiatQuery.data.result, 2)}`
: "$0.00"}
</Text>
);
}
@@ -22,6 +22,7 @@ type SwapFlowProps = {
transactionMode: boolean;
isEmbed: boolean;
onSuccess: ((status: BuyWithCryptoStatus) => void) | undefined;
approvalAmount?: bigint;
};
export function SwapFlow(props: SwapFlowProps) {
@@ -109,6 +110,7 @@ export function SwapFlow(props: SwapFlowProps) {
quote={quote}
isFiatFlow={props.isFiatFlow}
payer={props.payer}
preApprovedAmount={props.approvalAmount}
/>
);
}
@@ -0,0 +1,353 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { Chain } from "../../../../../../../chains/types.js";
import { getCachedChain } from "../../../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js";
import { getContract } from "../../../../../../../contract/contract.js";
import { allowance } from "../../../../../../../extensions/erc20/__generated__/IERC20/read/allowance.js";
import type { GetBuyWithCryptoQuoteParams } from "../../../../../../../pay/buyWithCrypto/getQuote.js";
import { formatNumber } from "../../../../../../../utils/formatNumber.js";
import type { Account } from "../../../../../../../wallets/interfaces/wallet.js";
import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js";
import { useWalletBalance } from "../../../../../../core/hooks/others/useWalletBalance.js";
import { useBuyWithCryptoQuote } from "../../../../../../core/hooks/pay/useBuyWithCryptoQuote.js";
import {
defaultMessage,
getErrorMessage,
} from "../../../../../utils/errors.js";
import type { PayEmbedConnectOptions } from "../../../../PayEmbed.js";
import {
Drawer,
DrawerOverlay,
useDrawer,
} from "../../../../components/Drawer.js";
import { Spacer } from "../../../../components/Spacer.js";
import { Spinner } from "../../../../components/Spinner.js";
import { SwitchNetworkButton } from "../../../../components/SwitchNetwork.js";
import { Container } from "../../../../components/basic.js";
import { Button } from "../../../../components/buttons.js";
import { Text } from "../../../../components/text.js";
import { TokenSymbol } from "../../../../components/token/TokenSymbol.js";
import type { ConnectLocale } from "../../../locale/types.js";
import { type ERC20OrNativeToken, isNativeToken } from "../../nativeToken.js";
import { EstimatedTimeAndFees } from "../EstimatedTimeAndFees.js";
import type { SelectedScreen } from "../main/types.js";
import type { PayerInfo } from "../types.js";
import { SwapFees } from "./Fees.js";
import { PayWithCryptoQuoteInfo } from "./PayWithCrypto.js";
export function SwapScreenContent(props: {
setScreen: (screen: SelectedScreen) => void;
tokenAmount: string;
toToken: ERC20OrNativeToken;
toChain: Chain;
fromChain: Chain;
fromToken: ERC20OrNativeToken;
showFromTokenSelector: () => void;
payer: PayerInfo;
client: ThirdwebClient;
payOptions: PayUIOptions;
isEmbed: boolean;
onDone: () => void;
connectOptions: PayEmbedConnectOptions | undefined;
connectLocale: ConnectLocale;
setPayer: (payer: PayerInfo) => void;
activeAccount: Account;
setTokenAmount: (amount: string) => void;
setHasEditedAmount: (hasEdited: boolean) => void;
disableTokenSelection: boolean;
}) {
const {
setScreen,
payer,
client,
toChain,
tokenAmount,
toToken,
fromChain,
fromToken,
payOptions,
disableTokenSelection,
} = props;
const defaultRecipientAddress = (
props.payOptions as Extract<PayUIOptions, { mode: "direct_payment" }>
)?.paymentInfo?.sellerAddress;
const receiverAddress =
defaultRecipientAddress || props.activeAccount.address;
const { drawerRef, drawerOverlayRef, isOpen, setIsOpen } = useDrawer();
const [drawerScreen, setDrawerScreen] = useState<
"fees" | "receiver" | "payer"
>("fees");
const fromTokenBalanceQuery = useWalletBalance({
address: payer.account.address,
chain: fromChain,
tokenAddress: isNativeToken(fromToken) ? undefined : fromToken.address,
client,
});
const fromTokenId = isNativeToken(fromToken)
? NATIVE_TOKEN_ADDRESS
: fromToken.address.toLowerCase();
const toTokenId = isNativeToken(toToken)
? NATIVE_TOKEN_ADDRESS
: toToken.address.toLowerCase();
const swapRequired =
!!tokenAmount &&
!(fromChain.id === toChain.id && fromTokenId === toTokenId);
const quoteParams: GetBuyWithCryptoQuoteParams | undefined = swapRequired
? {
// wallets
fromAddress: payer.account.address,
toAddress: receiverAddress,
// from
fromChainId: fromChain.id,
fromTokenAddress: isNativeToken(fromToken)
? NATIVE_TOKEN_ADDRESS
: fromToken.address,
// to
toChainId: toChain.id,
toTokenAddress: isNativeToken(toToken)
? NATIVE_TOKEN_ADDRESS
: toToken.address,
toAmount: tokenAmount,
client,
purchaseData: payOptions.purchaseData,
}
: undefined;
const quoteQuery = useBuyWithCryptoQuote(quoteParams, {
// refetch every 30 seconds
staleTime: 30 * 1000,
refetchInterval: 30 * 1000,
gcTime: 30 * 1000,
});
const allowanceQuery = useQuery({
queryKey: [
"allowance",
payer.account.address,
quoteQuery.data?.approvalData,
],
queryFn: () => {
if (!quoteQuery.data?.approvalData) {
return null;
}
return allowance({
contract: getContract({
client: props.client,
address: quoteQuery.data.swapDetails.fromToken.tokenAddress,
chain: getCachedChain(quoteQuery.data.swapDetails.fromToken.chainId),
}),
spender: quoteQuery.data.approvalData.spenderAddress,
owner: props.payer.account.address,
});
},
enabled: !!quoteQuery.data?.approvalData,
refetchOnMount: true,
});
const sourceTokenAmount = swapRequired
? quoteQuery.data?.swapDetails.fromAmount
: tokenAmount;
const isNotEnoughBalance =
!!sourceTokenAmount &&
!!fromTokenBalanceQuery.data &&
Number(fromTokenBalanceQuery.data.displayValue) < Number(sourceTokenAmount);
const disableContinue =
(swapRequired && !quoteQuery.data) ||
isNotEnoughBalance ||
allowanceQuery.isLoading;
const switchChainRequired =
props.payer.wallet.getChain()?.id !== fromChain.id;
const errorMsg =
!quoteQuery.isLoading && quoteQuery.error
? getErrorMessage(quoteQuery.error)
: undefined;
function showSwapFlow() {
if (
(props.payOptions.mode === "direct_payment" ||
props.payOptions.mode === "fund_wallet") &&
!isNotEnoughBalance &&
!swapRequired
) {
// same currency, just direct transfer
setScreen({
id: "transfer-flow",
});
} else if (
props.payOptions.mode === "transaction" &&
!isNotEnoughBalance &&
!swapRequired
) {
if (payer.account.address !== receiverAddress) {
// needs transfer from another wallet before executing the transaction
setScreen({
id: "transfer-flow",
});
} else {
// has enough balance to just do the transaction directly
props.onDone();
}
return;
}
if (!quoteQuery.data) {
return;
}
setScreen({
id: "swap-flow",
quote: quoteQuery.data,
approvalAmount: allowanceQuery.data ?? undefined,
});
}
function showFees() {
if (!quoteQuery.data) {
return;
}
setIsOpen(true);
setDrawerScreen("fees");
}
return (
<Container flex="column" gap="md" animate="fadein">
{isOpen && (
<>
<DrawerOverlay ref={drawerOverlayRef} />
<Drawer ref={drawerRef} close={() => setIsOpen(false)}>
{drawerScreen === "fees" && quoteQuery.data && (
<div>
<Text size="lg" color="primaryText">
Fees
</Text>
<Spacer y="lg" />
<SwapFees quote={quoteQuery.data} />
</div>
)}
</Drawer>
</>
)}
{/* Quote info */}
<div>
<PayWithCryptoQuoteInfo
value={sourceTokenAmount || ""}
chain={fromChain}
token={fromToken}
isLoading={quoteQuery.isLoading && !sourceTokenAmount}
client={client}
freezeChainAndTokenSelection={disableTokenSelection}
payerAccount={props.payer.account}
swapRequired={swapRequired}
/>
{swapRequired && (
<EstimatedTimeAndFees
quoteIsLoading={quoteQuery.isLoading}
estimatedSeconds={
quoteQuery.data?.swapDetails.estimated.durationSeconds
}
onViewFees={showFees}
/>
)}
<Spacer y="md" />
</div>
{/* Error message */}
{errorMsg && (
<div>
{errorMsg.data?.minimumAmountEth ? (
<Text color="danger" size="sm" center multiline>
Minimum amount is{" "}
{formatNumber(Number(errorMsg.data.minimumAmountEth), 6)}{" "}
<TokenSymbol
token={toToken}
chain={toChain}
size="sm"
inline
color="danger"
/>
</Text>
) : (
<Text color="danger" size="sm" center multiline>
{errorMsg.message || defaultMessage}
</Text>
)}
</div>
)}
{!errorMsg && isNotEnoughBalance && (
<div>
<Text color="danger" size="sm" center multiline>
Not enough funds.
</Text>
<Text color="danger" size="sm" center multiline>
Try a different wallet or token.
</Text>
</div>
)}
{/* Button */}
{errorMsg?.data?.minimumAmountEth ? (
<Button
variant="accent"
fullWidth
onClick={() => {
props.setTokenAmount(
formatNumber(
Number(errorMsg.data?.minimumAmountEth),
6,
).toString(),
);
props.setHasEditedAmount(true);
}}
>
Set Minimum
</Button>
) : switchChainRequired &&
!quoteQuery.isLoading &&
!allowanceQuery.isLoading &&
!isNotEnoughBalance &&
!quoteQuery.error ? (
<SwitchNetworkButton
variant="accent"
fullWidth
switchChain={async () => {
await props.payer.wallet.switchChain(fromChain);
}}
/>
) : (
<Button
variant={disableContinue ? "outline" : "accent"}
fullWidth
data-disabled={disableContinue}
disabled={disableContinue}
onClick={async () => {
if (!disableContinue) {
showSwapFlow();
}
}}
gap="xs"
>
{quoteQuery.isLoading ? (
<>
Getting price quote
<Spinner size="sm" color="accentText" />
</>
) : (
"Continue"
)}
</Button>
)}
</Container>
);
}
@@ -1,14 +1,18 @@
import { CheckCircledIcon } from "@radix-ui/react-icons";
import { useState } from "react";
import type { Chain } from "../../../../../../../chains/types.js";
import { getCachedChain } from "../../../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js";
import { getContract } from "../../../../../../../contract/contract.js";
import { allowance } from "../../../../../../../extensions/erc20/__generated__/IERC20/read/allowance.js";
import { approve } from "../../../../../../../extensions/erc20/write/approve.js";
import { transfer } from "../../../../../../../extensions/erc20/write/transfer.js";
import { getBuyWithCryptoTransfer } from "../../../../../../../pay/buyWithCrypto/getTransfer.js";
import { sendAndConfirmTransaction } from "../../../../../../../transaction/actions/send-and-confirm-transaction.js";
import { sendTransaction } from "../../../../../../../transaction/actions/send-transaction.js";
import { prepareTransaction } from "../../../../../../../transaction/prepare-transaction.js";
import type { Address } from "../../../../../../../utils/address.js";
import { toWei } from "../../../../../../../utils/units.js";
import { iconSize } from "../../../../../../core/design-system/index.js";
import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js";
@@ -248,13 +252,46 @@ export function TransferConfirmationScreen(
purchaseData: payOptions?.purchaseData,
});
if (transferResponse.approval) {
setStep("approve");
// approve the transfer
await sendAndConfirmTransaction({
account: props.payer.account,
transaction: transferResponse.approval,
if (transferResponse.approvalData) {
// check allowance
const prevAllowance = await allowance({
contract: getContract({
client: client,
address: transferResponse.approvalData.tokenAddress,
chain: getCachedChain(
transferResponse.approvalData.chainId,
),
}),
spender: transferResponse.approvalData
.spenderAddress as Address,
owner: payer.account.address,
});
if (
prevAllowance <
BigInt(transferResponse.approvalData.amountWei)
) {
setStep("approve");
const transaction = approve({
contract: getContract({
client: client,
address: transferResponse.approvalData.tokenAddress,
chain: getCachedChain(
transferResponse.approvalData.chainId,
),
}),
spender: transferResponse.approvalData
.spenderAddress as Address,
amountWei: BigInt(
transferResponse.approvalData.amountWei,
),
});
// approve the transfer
await sendAndConfirmTransaction({
account: props.payer.account,
transaction,
});
}
}
setStep("transfer");

Some files were not shown because too many files have changed in this diff Show More