Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fecd3a6c16 | ||
|
|
8aeb4d6308 | ||
|
|
f1cd2539d1 | ||
|
|
a322e16853 | ||
|
|
e50d77d28f | ||
|
|
7f3b91da37 | ||
|
|
41d50443d9 | ||
|
|
a8ac98bd49 | ||
|
|
0781da9dca | ||
|
|
c5dfad8dbb | ||
|
|
9663079062 | ||
|
|
1f6bb7c329 | ||
|
|
30e13e6b91 | ||
|
|
2dfc245d44 | ||
|
|
64d7bf358f | ||
|
|
a6e9c97c6f | ||
|
|
ee57ded902 | ||
|
|
6d3947ab0c | ||
|
|
632f1d138f | ||
|
|
18f0e575c4 | ||
|
|
e732b11e45 | ||
|
|
f77165e2d1 | ||
|
|
dfd64d2896 | ||
|
|
4e703fdb5d | ||
|
|
af88935680 | ||
|
|
c50c82aacd | ||
|
|
0fa8354dec | ||
|
|
769a927627 | ||
|
|
ceff873cba | ||
|
|
9a432af1cf | ||
|
|
ee6bd4f898 | ||
|
|
ccf6329810 | ||
|
|
821caa63d7 | ||
|
|
09e396d856 | ||
|
|
9573b2f9d2 | ||
|
|
b004a07e5d | ||
|
|
2396b4e461 | ||
|
|
ca6c913361 | ||
|
|
cf166e4c04 | ||
|
|
a51192ffbd | ||
|
|
6fd36aabc7 | ||
|
|
08cc48910d | ||
|
|
f03ded300f | ||
|
|
b651bae4ba | ||
|
|
66833a832c | ||
|
|
9d5828eeab | ||
|
|
b191658fcd | ||
|
|
a6b7e8d818 | ||
|
|
f4f93e6353 | ||
|
|
b693b78645 | ||
|
|
daceeed2ec | ||
|
|
5f7c726917 | ||
|
|
2405456e5f | ||
|
|
a76e6534c4 | ||
|
|
0fb586db52 | ||
|
|
f8ebbe54d9 | ||
|
|
ca756819db | ||
|
|
7217e774b2 | ||
|
|
5de72c5ecd | ||
|
|
60c5c26583 | ||
|
|
dc74c3b4b9 | ||
|
|
69a921d01f | ||
|
|
9abdb92028 | ||
|
|
6ed2fd13de | ||
|
|
a22457a69c | ||
|
|
ae675db8c8 | ||
|
|
54c9333fc9 | ||
|
|
541cf0b3e9 |
@@ -9,7 +9,8 @@ module.exports = [
|
||||
// -- connect
|
||||
"/connect",
|
||||
"/connect/sign-in",
|
||||
"/connect/pay",
|
||||
"/connect/account-abstraction",
|
||||
"/connect/universal-bridge",
|
||||
// -- nebula
|
||||
"/nebula",
|
||||
// --insight
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "1.1.7",
|
||||
"@sentry/nextjs": "8.52.0",
|
||||
"@sentry/nextjs": "8.53.0",
|
||||
"@shazow/whatsabi": "^0.19.0",
|
||||
"@tanstack/react-query": "5.65.1",
|
||||
"@tanstack/react-query": "5.66.0",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@vercel/functions": "^1.6.0",
|
||||
@@ -91,7 +91,7 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "^1.7.3",
|
||||
"sonner": "^1.7.4",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"swagger-ui-react": "^5.18.3",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
@@ -106,7 +106,7 @@
|
||||
"@chromatic-com/storybook": "3.2.4",
|
||||
"@next/bundle-analyzer": "15.1.6",
|
||||
"@next/eslint-plugin-next": "15.1.6",
|
||||
"@playwright/test": "1.50.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@storybook/addon-essentials": "8.5.2",
|
||||
"@storybook/addon-interactions": "8.5.2",
|
||||
"@storybook/addon-links": "8.5.2",
|
||||
@@ -117,7 +117,7 @@
|
||||
"@storybook/react": "8.5.2",
|
||||
"@storybook/test": "8.5.2",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.10.10",
|
||||
"@types/node": "22.13.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
@@ -331,6 +331,18 @@ async function redirects() {
|
||||
destination: "/templates/:slug",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /account-abstraction to /connect/account-abstraction
|
||||
{
|
||||
source: "/account-abstraction",
|
||||
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",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { API_SERVER_URL } from "../constants/env";
|
||||
export async function updateAccount(values: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string | null;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ export const engineKeys = {
|
||||
backendWallet: (address: string, chainId: number) =>
|
||||
["backendWallet", address, chainId] as const,
|
||||
backendWalletBalance: (address: string, chainId: number) =>
|
||||
[...engineKeys.backendWallet(address, chainId), "balance"] as const,
|
||||
["backendWallet", "balance", address, chainId] as const,
|
||||
backendWalletBalanceAll: () => ["backendWallet", "balance"] as const,
|
||||
corsUrls: (instance: string) =>
|
||||
[...engineKeys.all, instance, "corsUrls"] as const,
|
||||
ipAllowlist: (instance: string) =>
|
||||
|
||||
@@ -39,6 +39,7 @@ export type Account = {
|
||||
id: string;
|
||||
isStaff: boolean;
|
||||
creatorWalletAddress: string;
|
||||
image?: string | null;
|
||||
name?: string;
|
||||
email?: string;
|
||||
advancedEnabled: boolean;
|
||||
@@ -548,41 +549,6 @@ export function useResendEmailConfirmation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useApiKeys(props: {
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const address = useActiveAccount()?.address;
|
||||
return useQuery({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
queryFn: async () => {
|
||||
type Result = {
|
||||
data: ApiKey[];
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/keys",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
enabled: !!address && props.isLoggedIn,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
+225
-21
@@ -1,13 +1,32 @@
|
||||
"use client";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
TURNSTILE_SITE_KEY,
|
||||
} from "@/constants/env";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
|
||||
@@ -17,9 +36,21 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { toUnits } from "thirdweb";
|
||||
import {
|
||||
type ThirdwebClient,
|
||||
prepareTransaction,
|
||||
toUnits,
|
||||
toWei,
|
||||
} from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
|
||||
import type { Chain } from "thirdweb/chains";
|
||||
import {
|
||||
useActiveAccount,
|
||||
useActiveWalletChain,
|
||||
useSendTransaction,
|
||||
useSwitchActiveWalletChain,
|
||||
useWalletBalance,
|
||||
} from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
@@ -137,6 +168,35 @@ export function FaucetButton({
|
||||
|
||||
const form = useForm<z.infer<typeof claimFaucetSchema>>();
|
||||
|
||||
// loading state
|
||||
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2">
|
||||
Checking Faucet <Spinner className="size-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// faucet is empty
|
||||
if (isFaucetEmpty) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-3 text-center text-muted-foreground text-sm">
|
||||
Faucet is empty right now
|
||||
</div>
|
||||
<SendFundsToFaucetModalButton
|
||||
chain={definedChain}
|
||||
isLoggedIn={!!twAccount}
|
||||
client={client}
|
||||
chainMeta={chain}
|
||||
onFaucetRefill={() => {
|
||||
faucetWalletBalanceQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Force users to log in to claim the faucet
|
||||
if (!address || !twAccount) {
|
||||
return (
|
||||
@@ -164,24 +224,6 @@ export function FaucetButton({
|
||||
);
|
||||
}
|
||||
|
||||
// loading state
|
||||
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2">
|
||||
Checking Faucet <Spinner className="size-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// faucet is empty
|
||||
if (isFaucetEmpty) {
|
||||
return (
|
||||
<Button variant="outline" disabled className="!opacity-100 w-full">
|
||||
Faucet is empty right now
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Can not claim
|
||||
if (canClaimFaucetQuery.data && canClaimFaucetQuery.data.canClaim === false) {
|
||||
return (
|
||||
@@ -250,3 +292,165 @@ export function FaucetButton({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const faucetFormSchema = z.object({
|
||||
amount: z.coerce.number().refine((value) => value > 0, {
|
||||
message: "Amount must be greater than 0",
|
||||
}),
|
||||
});
|
||||
|
||||
function SendFundsToFaucetModalButton(props: {
|
||||
chain: Chain;
|
||||
isLoggedIn: boolean;
|
||||
client: ThirdwebClient;
|
||||
chainMeta: ChainMetadata;
|
||||
onFaucetRefill: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" className="w-full">
|
||||
Refill Faucet
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-2">
|
||||
<DialogTitle>Refill Faucet</DialogTitle>
|
||||
<DialogDescription>Send funds to faucet wallet</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<SendFundsToFaucetModalContent {...props} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SendFundsToFaucetModalContent(props: {
|
||||
chain: Chain;
|
||||
isLoggedIn: boolean;
|
||||
client: ThirdwebClient;
|
||||
chainMeta: ChainMetadata;
|
||||
onFaucetRefill: () => void;
|
||||
}) {
|
||||
const account = useActiveAccount();
|
||||
const activeChain = useActiveWalletChain();
|
||||
const switchActiveWalletChain = useSwitchActiveWalletChain();
|
||||
const sendTxMutation = useSendTransaction({
|
||||
payModal: false,
|
||||
});
|
||||
const switchChainMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await switchActiveWalletChain(props.chain);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof faucetFormSchema>>({
|
||||
resolver: zodResolver(faucetFormSchema),
|
||||
defaultValues: {
|
||||
amount: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof faucetFormSchema>) {
|
||||
const sendNativeTokenTx = prepareTransaction({
|
||||
chain: props.chain,
|
||||
client: props.client,
|
||||
to: THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
value: toWei(values.amount.toString()),
|
||||
});
|
||||
|
||||
const promise = sendTxMutation.mutateAsync(sendNativeTokenTx);
|
||||
|
||||
toast.promise(promise, {
|
||||
success: `Sent ${values.amount} ${props.chainMeta.nativeCurrency.symbol} to faucet`,
|
||||
error: `Failed to send ${values.amount} ${props.chainMeta.nativeCurrency.symbol} to faucet`,
|
||||
});
|
||||
|
||||
promise.then(() => {
|
||||
props.onFaucetRefill();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex min-w-0 flex-col gap-5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-foreground text-sm"> Faucet Wallet </p>
|
||||
<CopyTextButton
|
||||
copyIconPosition="right"
|
||||
variant="outline"
|
||||
className="w-full justify-between bg-card py-2 font-mono"
|
||||
textToCopy={THIRDWEB_ENGINE_FAUCET_WALLET}
|
||||
textToShow={THIRDWEB_ENGINE_FAUCET_WALLET}
|
||||
tooltip={undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
className="h-auto bg-card text-2xl md:text-2xl [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
<div className="-translate-y-1/2 absolute top-1/2 right-4 text-muted-foreground text-sm">
|
||||
{props.chainMeta.nativeCurrency.symbol}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!account && (
|
||||
<CustomConnectWallet
|
||||
chain={props.chain}
|
||||
loginRequired={false}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
connectButtonClassName="!w-full"
|
||||
detailsButtonClassName="!w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{account && activeChain && (
|
||||
<div>
|
||||
{activeChain.id === props.chain.id ? (
|
||||
<Button
|
||||
key="submit"
|
||||
type="submit"
|
||||
className="mt-4 w-full gap-2"
|
||||
disabled={sendTxMutation.isPending}
|
||||
>
|
||||
{sendTxMutation.isPending && <Spinner className="size-4" />}
|
||||
Send funds to faucet
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="switch"
|
||||
className="mt-4 w-full gap-2"
|
||||
disabled={switchChainMutation.isPending}
|
||||
onClick={() => switchChainMutation.mutate()}
|
||||
>
|
||||
Switch to {props.chainMeta.name}{" "}
|
||||
{switchChainMutation.isPending && (
|
||||
<Spinner className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
-6
@@ -39,12 +39,6 @@ export function getContractPageSidebarLinks(data: {
|
||||
href: `${layoutPrefix}/events`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
label: "Embed",
|
||||
href: `${layoutPrefix}/embed`,
|
||||
hide: data.metadata.embedType === null,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
label: "Analytics",
|
||||
href: `${layoutPrefix}/analytics`,
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { RedirectToContractOverview } from "../_components/redirect-contract-overview.client";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
import { EmbedSetup } from "./embed-setup";
|
||||
|
||||
export function EmbedSetupClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
twAccount: Account | undefined;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.data.embedType === null) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EmbedSetup
|
||||
contract={props.contract}
|
||||
ercOrMarketplace={metadataQuery.data.embedType}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-613
@@ -1,613 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import {
|
||||
type Account,
|
||||
useApiKeys,
|
||||
useCreateApiKey,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { Flex, FormControl, Input, Select } from "@chakra-ui/react";
|
||||
import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { StoredChain } from "stores/chainStores";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Card, FormHelperText, FormLabel, Heading, Text } from "tw-components";
|
||||
|
||||
interface EmbedSetupProps {
|
||||
contract: ThirdwebContract;
|
||||
ercOrMarketplace: string;
|
||||
twAccount: Account | undefined;
|
||||
}
|
||||
|
||||
// MAKE SURE THIS IS v1 embed hashes!!
|
||||
const IPFS_URI = "bafybeigdie2yyiazou7grjowoevmuip6akk33nqb55vrpezqdwfssrxyfy";
|
||||
const ERC721_IPFS_URI =
|
||||
"bafybeicd3qfzelz4su7ng6n523virdsgobrc5pcbarhwqv3dj3drh645pi";
|
||||
|
||||
interface IframeSrcOptions {
|
||||
clientId: string;
|
||||
chain: string;
|
||||
tokenId?: string;
|
||||
listingId?: string;
|
||||
listingType?: string;
|
||||
directListingId?: string;
|
||||
englishAuctionId?: string;
|
||||
relayUrl?: string;
|
||||
theme?: string;
|
||||
primaryColor?: string;
|
||||
secondaryColor?: string;
|
||||
biconomyApiKey?: string;
|
||||
biconomyApiId?: string;
|
||||
}
|
||||
|
||||
const colorOptions = [
|
||||
"purple",
|
||||
"blue",
|
||||
"orange",
|
||||
"pink",
|
||||
"green",
|
||||
"red",
|
||||
"teal",
|
||||
"cyan",
|
||||
"yellow",
|
||||
];
|
||||
|
||||
const isValidUrl = (url: string | undefined) => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildIframeSrc = (
|
||||
contract: ThirdwebContract,
|
||||
ercOrMarketplace?: string,
|
||||
options?: IframeSrcOptions,
|
||||
): string => {
|
||||
const contractPath =
|
||||
ercOrMarketplace === "erc721" ? "" : `${ercOrMarketplace}.html`;
|
||||
const contractEmbedHash =
|
||||
ercOrMarketplace === "erc721" ? ERC721_IPFS_URI : IPFS_URI;
|
||||
|
||||
if (!contract || !options || !contractEmbedHash) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const {
|
||||
clientId,
|
||||
chain,
|
||||
tokenId,
|
||||
listingId,
|
||||
listingType,
|
||||
directListingId,
|
||||
englishAuctionId,
|
||||
relayUrl,
|
||||
theme,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
biconomyApiKey,
|
||||
biconomyApiId,
|
||||
} = options;
|
||||
|
||||
const url = new URL(
|
||||
`https://embed.ipfscdn.io/ipfs/${contractEmbedHash}/${contractPath}`,
|
||||
);
|
||||
|
||||
url.searchParams.append("contract", contract.address);
|
||||
url.searchParams.append("chain", chain);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
|
||||
if (tokenId !== undefined && ercOrMarketplace === "erc1155") {
|
||||
url.searchParams.append("tokenId", tokenId.toString());
|
||||
}
|
||||
if (listingId !== undefined && ercOrMarketplace === "marketplace") {
|
||||
url.searchParams.append("listingId", listingId.toString());
|
||||
}
|
||||
if (
|
||||
directListingId !== undefined &&
|
||||
ercOrMarketplace === "marketplace-v3" &&
|
||||
listingType === "direct-listing"
|
||||
) {
|
||||
url.searchParams.append("directListingId", directListingId.toString());
|
||||
}
|
||||
if (
|
||||
englishAuctionId !== undefined &&
|
||||
ercOrMarketplace === "marketplace-v3" &&
|
||||
listingType === "english-auction"
|
||||
) {
|
||||
url.searchParams.append("englishAuctionId", englishAuctionId.toString());
|
||||
}
|
||||
if (isValidUrl(relayUrl)) {
|
||||
url.searchParams.append("relayUrl", relayUrl || "");
|
||||
}
|
||||
if (biconomyApiKey) {
|
||||
url.searchParams.append("biconomyApiKey", biconomyApiKey);
|
||||
}
|
||||
if (biconomyApiId) {
|
||||
url.searchParams.append("biconomyApiId", biconomyApiId);
|
||||
}
|
||||
if (theme) {
|
||||
url.searchParams.append("theme", theme);
|
||||
}
|
||||
if (primaryColor && primaryColor !== "default") {
|
||||
url.searchParams.append("primaryColor", primaryColor);
|
||||
}
|
||||
if (secondaryColor && secondaryColor !== "orange") {
|
||||
url.searchParams.append("secondaryColor", secondaryColor);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
// taken from @thirdweb-dev /chains
|
||||
// @internal
|
||||
function minimizeChain(
|
||||
chain: ChainMetadata,
|
||||
): Pick<
|
||||
ChainMetadata,
|
||||
| "name"
|
||||
| "chain"
|
||||
| "rpc"
|
||||
| "nativeCurrency"
|
||||
| "shortName"
|
||||
| "chainId"
|
||||
| "testnet"
|
||||
| "slug"
|
||||
| "icon"
|
||||
> {
|
||||
const [firstRpc] = chain.rpc;
|
||||
return {
|
||||
name: chain.name,
|
||||
chain: chain.chain,
|
||||
rpc: [firstRpc || ""],
|
||||
nativeCurrency: chain.nativeCurrency,
|
||||
shortName: chain.shortName,
|
||||
chainId: chain.chainId,
|
||||
testnet: chain.testnet,
|
||||
slug: chain.slug,
|
||||
icon: chain.icon,
|
||||
};
|
||||
}
|
||||
|
||||
// taken from @thirdweb-dev /chains
|
||||
// @internal
|
||||
type ChainConfiguration = {
|
||||
rpc?: string | string[];
|
||||
};
|
||||
|
||||
function configureChain(
|
||||
chain: ChainMetadata,
|
||||
chainConfig: ChainConfiguration,
|
||||
): ChainMetadata {
|
||||
let additionalRPCs: string[] = [];
|
||||
if (chainConfig?.rpc) {
|
||||
if (typeof chainConfig.rpc === "string") {
|
||||
additionalRPCs = [chainConfig.rpc];
|
||||
} else {
|
||||
additionalRPCs = chainConfig.rpc;
|
||||
}
|
||||
}
|
||||
// prepend additional RPCs to the chain's RPCs
|
||||
return { ...chain, rpc: [...additionalRPCs, ...chain.rpc] };
|
||||
}
|
||||
|
||||
export const EmbedSetup: React.FC<EmbedSetupProps> = ({
|
||||
contract,
|
||||
ercOrMarketplace,
|
||||
twAccount,
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const apiKeys = useApiKeys({
|
||||
isLoggedIn: !!twAccount,
|
||||
});
|
||||
const createKeyMutation = useCreateApiKey();
|
||||
|
||||
const validApiKey = (apiKeys.data || []).find(
|
||||
(apiKey) =>
|
||||
(apiKey.domains.includes("*") ||
|
||||
apiKey.domains.includes("embed.ipfscdn.io") ||
|
||||
apiKey.domains.includes("*.ipfscdn.io")) &&
|
||||
(apiKey.services || [])
|
||||
.find((service) => service.name === "storage")
|
||||
?.actions.includes("read") &&
|
||||
!!(apiKey.services || []).find((service) => service.name === "rpc"),
|
||||
);
|
||||
|
||||
const chainId = contract.chain.id;
|
||||
const { idToChain } = useAllChainsData();
|
||||
|
||||
const chain: StoredChain = (chainId ? idToChain.get(chainId) : undefined) || {
|
||||
name: "Unknown Chain",
|
||||
chainId: chainId || -1,
|
||||
rpc: [],
|
||||
chain: "unknown",
|
||||
nativeCurrency: {
|
||||
name: "Ethereum",
|
||||
symbol: "ETH",
|
||||
decimals: 18,
|
||||
},
|
||||
shortName: "unknown",
|
||||
slug: "unknown",
|
||||
testnet: false,
|
||||
stackType: "",
|
||||
};
|
||||
|
||||
const { register, watch } = useForm<{
|
||||
rpcUrl: string;
|
||||
relayUrl: string;
|
||||
tokenId: string;
|
||||
listingId: string;
|
||||
listingType: string;
|
||||
directListingId: string;
|
||||
englishAuctionId: string;
|
||||
theme: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
biconomyApiKey: string;
|
||||
biconomyApiId: string;
|
||||
gasless: string;
|
||||
}>({
|
||||
defaultValues: {
|
||||
rpcUrl: chain?.rpc[0],
|
||||
tokenId: "0",
|
||||
listingId: "0",
|
||||
directListingId: "0",
|
||||
englishAuctionId: "0",
|
||||
theme: theme === "light" ? "light" : "dark",
|
||||
primaryColor: "purple",
|
||||
secondaryColor: "orange",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const configuredChainWithNewRpc = configureChain(chain, {
|
||||
rpc: watch("rpcUrl"),
|
||||
});
|
||||
const minimizedChain = minimizeChain(configuredChainWithNewRpc);
|
||||
|
||||
const iframeSrc = buildIframeSrc(contract, ercOrMarketplace, {
|
||||
chain: JSON.stringify(minimizedChain),
|
||||
clientId: validApiKey?.key || "",
|
||||
...watch(),
|
||||
});
|
||||
|
||||
const embedCode = useMemo(
|
||||
() =>
|
||||
`<iframe
|
||||
src="${iframeSrc}"
|
||||
width="100%"
|
||||
height="750px"
|
||||
style="max-width:100%;"
|
||||
frameborder="0"
|
||||
></iframe>`,
|
||||
[iframeSrc],
|
||||
);
|
||||
|
||||
const [showCreateAPIKeyModal, setShowCreateAPIKeyModal] = useState(false);
|
||||
const activeAccount = useActiveAccount();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LazyCreateAPIKeyDialog
|
||||
prefill={{
|
||||
name: "Embed API Key",
|
||||
domains: "embed.ipfscdn.io",
|
||||
}}
|
||||
open={showCreateAPIKeyModal}
|
||||
onOpenChange={setShowCreateAPIKeyModal}
|
||||
onCreateAndComplete={() => {
|
||||
trackEvent({
|
||||
category: "api-keys",
|
||||
action: "create",
|
||||
label: "success",
|
||||
fromEmbed: true,
|
||||
});
|
||||
apiKeys.refetch();
|
||||
}}
|
||||
enableNebulaServiceByDefault={false}
|
||||
teamSlug={undefined}
|
||||
/>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertCircleIcon className="size-5" />
|
||||
<AlertTitle>Deprecated</AlertTitle>
|
||||
<AlertDescription className="leading-relaxed">
|
||||
<span>
|
||||
thirdweb NFT Embeds are deprecated and not actively maintained
|
||||
</span>{" "}
|
||||
<br />
|
||||
<span>
|
||||
Use the{" "}
|
||||
<Link
|
||||
href="https://github.com/thirdweb-example/nft-minting-template"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{" "}
|
||||
NFT Minting Template
|
||||
</Link>{" "}
|
||||
or build a custom NFT minting app using{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/react/v5"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
thirdweb React SDK components
|
||||
</Link>{" "}
|
||||
instead
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="mb-3 font-semibold text-xl tracking-tight">
|
||||
Configuration
|
||||
</h2>
|
||||
|
||||
<Card className="flex w-full flex-col gap-5 bg-card">
|
||||
{ercOrMarketplace === "marketplace" ? (
|
||||
<FormControl>
|
||||
<FormLabel>Listing ID</FormLabel>
|
||||
<Input type="number" {...register("listingId")} />
|
||||
<FormHelperText>
|
||||
The listing ID the embed should display
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) : null}
|
||||
|
||||
{ercOrMarketplace === "marketplace-v3" ? (
|
||||
<FormControl>
|
||||
<FormLabel>Listing type</FormLabel>
|
||||
<Select {...register("listingType")}>
|
||||
<option value="direct-listing">Direct Listing</option>
|
||||
<option value="english-auction">English Auction</option>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
The type of listing the embed should display
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) : null}
|
||||
|
||||
{ercOrMarketplace === "marketplace-v3" &&
|
||||
watch("listingType") === "direct-listing" ? (
|
||||
<FormControl>
|
||||
<FormLabel>Direct Listing ID</FormLabel>
|
||||
<Input type="number" {...register("directListingId")} />
|
||||
<FormHelperText>
|
||||
The direct listing ID the embed should display
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) : null}
|
||||
|
||||
{ercOrMarketplace === "marketplace-v3" &&
|
||||
watch("listingType") === "english-auction" ? (
|
||||
<FormControl>
|
||||
<FormLabel>English Auction ID</FormLabel>
|
||||
<Input type="number" {...register("englishAuctionId")} />
|
||||
<FormHelperText>
|
||||
The english auction ID the embed should display
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) : null}
|
||||
|
||||
{ercOrMarketplace === "erc1155" ? (
|
||||
<FormControl>
|
||||
<FormLabel>Token ID</FormLabel>
|
||||
<Input type="number" {...register("tokenId")} />
|
||||
<FormHelperText>
|
||||
The token ID the embed should display
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) : null}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
{!activeAccount ? (
|
||||
<Button asChild className="w-full">
|
||||
<Link
|
||||
href={`/login${
|
||||
pathname ? `?next=${encodeURIComponent(pathname)}` : ""
|
||||
}`}
|
||||
>
|
||||
Sign in to create a client ID
|
||||
</Link>
|
||||
</Button>
|
||||
) : validApiKey ? (
|
||||
<Input
|
||||
readOnly
|
||||
disabled
|
||||
value={`${validApiKey?.name} - ${validApiKey?.key}`}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "api-keys",
|
||||
action: "create",
|
||||
label: "attempt",
|
||||
fromEmbed: true,
|
||||
});
|
||||
|
||||
setShowCreateAPIKeyModal(true);
|
||||
}}
|
||||
disabled={createKeyMutation.isPending}
|
||||
>
|
||||
Create Client ID
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<FormHelperText>
|
||||
You need a client ID to use embeds.{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/account/api-keys"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>RPC Url</FormLabel>
|
||||
<Input type="url" {...register("rpcUrl")} />
|
||||
<FormHelperText>
|
||||
RPC the embed should use to connect to the blockchain.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{ercOrMarketplace === "marketplace" ||
|
||||
ercOrMarketplace === "marketplace-v3" ? null : (
|
||||
<FormControl gap={4}>
|
||||
<Heading size="title.sm" mb={2}>
|
||||
Gasless
|
||||
</Heading>
|
||||
<Select {...register("gasless")} mb={4}>
|
||||
<option value="false">Disabled</option>
|
||||
<option value="openZeppelin">OpenZeppelin Relayer</option>
|
||||
<option value="biconomy">Biconomy Relayer</option>
|
||||
</Select>
|
||||
{watch("gasless") === "openZeppelin" && (
|
||||
<FormControl>
|
||||
<FormLabel>OpenZeppelin Relayer URL</FormLabel>
|
||||
<Input type="url" {...register("relayUrl")} />
|
||||
</FormControl>
|
||||
)}
|
||||
{watch("gasless") === "biconomy" && (
|
||||
<Flex gap={4} flexDir="column">
|
||||
<FormControl>
|
||||
<FormLabel>Biconomy API key</FormLabel>
|
||||
<Input type="url" {...register("biconomyApiKey")} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Biconomy API ID</FormLabel>
|
||||
<Input type="url" {...register("biconomyApiId")} />
|
||||
</FormControl>
|
||||
</Flex>
|
||||
)}
|
||||
<FormHelperText>
|
||||
A relayer can be used to make the transaction gasless for the
|
||||
end user.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="https://blog.thirdweb.com/guides/setup-gasless-transactions"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<Select {...register("theme")}>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="system">User system</option>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Selecting system will make it so the embed would change
|
||||
depending on the user system's preferences.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Primary Color</FormLabel>
|
||||
<Select {...register("primaryColor")}>
|
||||
{ercOrMarketplace === "erc721" && (
|
||||
<option value="default">Default</option>
|
||||
)}
|
||||
{colorOptions.map((color) => (
|
||||
<option key={color} value={color}>
|
||||
{color[0]?.toUpperCase() + color.substring(1)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Used for the main actions button backgrounds.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{ercOrMarketplace === "marketplace" ||
|
||||
ercOrMarketplace === "marketplace-v3" ? (
|
||||
<FormControl>
|
||||
<FormLabel>Secondary Color</FormLabel>
|
||||
<Select {...register("secondaryColor")}>
|
||||
{colorOptions.map((color) => (
|
||||
<option key={color} value={color}>
|
||||
{color[0]?.toUpperCase() + color.substring(1)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Use for secondary actions (like when the user is connected to
|
||||
the wrong network)
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 font-semibold text-xl tracking-tight">
|
||||
Embed Code
|
||||
</h2>
|
||||
<CodeClient
|
||||
code={embedCode}
|
||||
lang="html"
|
||||
onCopy={() => {
|
||||
trackEvent({
|
||||
category: "embed",
|
||||
action: "click",
|
||||
label: "copy-code",
|
||||
address: contract.address,
|
||||
chainId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<h2 className="mb-3 font-semibold text-xl tracking-tight">Preview</h2>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{!validApiKey ? (
|
||||
<Text>You need to create a client ID to use embeds</Text>
|
||||
) : iframeSrc ? (
|
||||
<iframe
|
||||
title="thirdweb embed"
|
||||
src={iframeSrc}
|
||||
width="100%"
|
||||
height="750px"
|
||||
frameBorder="0"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getRawAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
import { EmbedSetupClient } from "./EmbedSetup.client";
|
||||
import { EmbedSetup } from "./embed-setup";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const account = await getRawAccount();
|
||||
|
||||
const { contract } = info;
|
||||
if (contract.chain.id === localhost.id) {
|
||||
return <EmbedSetupClient contract={contract} twAccount={account} />;
|
||||
}
|
||||
|
||||
const { embedType } = await getContractPageMetadata(contract);
|
||||
|
||||
if (embedType === null) {
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmbedSetup
|
||||
contract={contract}
|
||||
ercOrMarketplace={embedType}
|
||||
twAccount={account}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+33
-14
@@ -26,6 +26,7 @@ import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { addDays, fromUnixTime } from "date-fns";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
@@ -119,22 +120,39 @@ function ClaimableModule(props: ModuleInstanceProps) {
|
||||
claimConditionQuery.data?.startTimestamp === 0 &&
|
||||
claimConditionQuery.data?.endTimestamp === 0;
|
||||
|
||||
const currencyContract = getContract({
|
||||
address: claimConditionQuery.data?.currency || "",
|
||||
chain: props.contract.chain,
|
||||
client: props.contract.client,
|
||||
});
|
||||
const claimConditionCurrency = claimConditionQuery.data?.currency;
|
||||
|
||||
const shouldFetchTokenDecimals =
|
||||
claimConditionQuery.data &&
|
||||
claimConditionQuery.data?.currency !== ZERO_ADDRESS;
|
||||
const shouldFetchCurrencyDecimals =
|
||||
!!claimConditionCurrency && claimConditionCurrency !== ZERO_ADDRESS;
|
||||
|
||||
const currencyDecimalsQuery = useReadContract(decimals, {
|
||||
contract: currencyContract,
|
||||
queryOptions: {
|
||||
enabled: shouldFetchTokenDecimals,
|
||||
const currencyDecimalsQuery = useQuery({
|
||||
queryKey: [
|
||||
"currency-decimals",
|
||||
{
|
||||
contractAddress: props.contract.address,
|
||||
chainId: props.contract.chain.id,
|
||||
claimConditionCurrency,
|
||||
},
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!claimConditionCurrency) {
|
||||
throw new Error();
|
||||
}
|
||||
const currencyContract = getContract({
|
||||
address: claimConditionCurrency,
|
||||
chain: props.contract.chain,
|
||||
client: props.contract.client,
|
||||
});
|
||||
|
||||
const decimalsVal = await decimals({
|
||||
contract: currencyContract,
|
||||
});
|
||||
|
||||
return decimalsVal;
|
||||
},
|
||||
enabled: shouldFetchCurrencyDecimals,
|
||||
});
|
||||
|
||||
const tokenDecimalsQuery = useReadContract(decimals, {
|
||||
contract: contract,
|
||||
queryOptions: {
|
||||
@@ -265,7 +283,7 @@ function ClaimableModule(props: ModuleInstanceProps) {
|
||||
// claim conditions data is present
|
||||
claimConditionQuery.data &&
|
||||
// token decimals is fetched if it should be fetched
|
||||
(shouldFetchTokenDecimals ? currencyDecimalsQuery.isFetched : true)
|
||||
(shouldFetchCurrencyDecimals ? currencyDecimalsQuery.isFetched : true)
|
||||
? {
|
||||
claimCondition: claimConditionQuery.data,
|
||||
currencyDecimals: currencyDecimalsQuery.data,
|
||||
@@ -276,7 +294,7 @@ function ClaimableModule(props: ModuleInstanceProps) {
|
||||
tokenId,
|
||||
isLoading:
|
||||
claimConditionQuery.isLoading ||
|
||||
(!!shouldFetchTokenDecimals && currencyDecimalsQuery.isLoading),
|
||||
(!!shouldFetchCurrencyDecimals && currencyDecimalsQuery.isLoading),
|
||||
}}
|
||||
isOwnerAccount={!!ownerAccount}
|
||||
name={props.contractInfo.name}
|
||||
@@ -565,6 +583,7 @@ function ClaimConditionSection(props: {
|
||||
onClick={() => setAddClaimConditionButtonClicked(true)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={!props.isOwnerAccount}
|
||||
>
|
||||
Add Claim Condition
|
||||
</Button>
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ export async function ChainsData(props: {
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader className="z-0">
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Chain ID</TableHead>
|
||||
|
||||
+10
-29
@@ -83,14 +83,14 @@ export async function ChainListRow({
|
||||
|
||||
<TableCell>
|
||||
<div className="flex w-[520px] flex-row items-center gap-14 ">
|
||||
<div className="z-10 flex items-center gap-7">
|
||||
<div className="z-10 flex items-center gap-4">
|
||||
{products.map((p) => {
|
||||
return (
|
||||
<ProductIcon
|
||||
key={p.name}
|
||||
icon={p.icon}
|
||||
label={p.name}
|
||||
href={pidToHref(p.id)}
|
||||
href={p.link}
|
||||
isEnabled={enabledServices.includes(p.id)}
|
||||
/>
|
||||
);
|
||||
@@ -102,32 +102,6 @@ export async function ChainListRow({
|
||||
);
|
||||
}
|
||||
|
||||
function pidToHref(pid: (typeof products)[number]["id"]) {
|
||||
switch (pid) {
|
||||
case "account-abstraction": {
|
||||
return "https://portal.thirdweb.com/connect/account-abstraction/overview";
|
||||
}
|
||||
case "contracts": {
|
||||
return "https://portal.thirdweb.com/contracts";
|
||||
}
|
||||
case "connect-sdk": {
|
||||
return "https://portal.thirdweb.com/connect";
|
||||
}
|
||||
case "engine": {
|
||||
return "https://portal.thirdweb.com/engine";
|
||||
}
|
||||
case "pay": {
|
||||
return "https://portal.thirdweb.com/connect/pay/overview";
|
||||
}
|
||||
case "rpc-edge": {
|
||||
return "https://portal.thirdweb.com/infrastructure/rpc-edge/overview";
|
||||
}
|
||||
case "insight": {
|
||||
return "https://portal.thirdweb.com/insight";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ProductIcon(props: {
|
||||
icon: React.FC<{ className?: string }>;
|
||||
label: string;
|
||||
@@ -148,8 +122,15 @@ function ProductIcon(props: {
|
||||
<Link
|
||||
href={props.href}
|
||||
target={props.href.startsWith("http") ? "_blank" : undefined}
|
||||
className="group rounded-lg p-2 hover:bg-accent"
|
||||
>
|
||||
<props.icon className={cn("size-8", !props.isEnabled && "grayscale")} />
|
||||
<props.icon
|
||||
className={cn(
|
||||
"size-6 text-foreground group-hover:text-link-foreground",
|
||||
!props.isEnabled &&
|
||||
"text-muted-foreground opacity-50 group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
|
||||
@@ -106,9 +106,8 @@ export function StarButton(props: {
|
||||
) : (
|
||||
<Star
|
||||
className={cn(
|
||||
"size-6 transition-all",
|
||||
"size-6 text-foreground transition-all",
|
||||
props.iconClassName,
|
||||
isPreferred ? "text-yellow-400" : "text-foreground",
|
||||
)}
|
||||
fill={isPreferred ? "currentColor" : "none"}
|
||||
strokeWidth="1px"
|
||||
|
||||
+4
-74
@@ -7,85 +7,15 @@ export function ConnectSDKIcon(props: { className?: string }) {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
role="presentation"
|
||||
>
|
||||
<title>Connect SDK</title>
|
||||
<rect
|
||||
x="3"
|
||||
y="17.9988"
|
||||
width="14.9988"
|
||||
height="10.6499"
|
||||
rx="2"
|
||||
transform="rotate(-90 3 17.9988)"
|
||||
fill="url(#paint0_linear_195_4047)"
|
||||
/>
|
||||
<g filter="url(#filter0_bi_195_4047)">
|
||||
<path
|
||||
d="M3 9C3 7.89543 3.89543 7 5 7H19C20.1046 7 21 7.89543 21 9V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V9Z"
|
||||
fill="#B9DDFF"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M18.3004 12.6995V12.6995C17.5822 13.4177 17.5822 14.5822 18.3004 15.3004V15.3004"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
d="M16 8.00007V4.50073C16 3.66899 16 3.25312 15.8248 2.99755C15.6717 2.77425 15.4346 2.62257 15.1678 2.57715C14.8623 2.52517 14.4847 2.69944 13.7295 3.04799L4.85901 7.14206C4.18551 7.45291 3.84875 7.60834 3.60211 7.84939C3.38406 8.06249 3.21762 8.32262 3.1155 8.6099C3 8.93486 3 9.30575 3 10.0475V15.0001M16.5 14.5001H16.51M3 11.2001L3 17.8001C3 18.9202 3 19.4802 3.21799 19.9081C3.40973 20.2844 3.71569 20.5903 4.09202 20.7821C4.51984 21.0001 5.07989 21.0001 6.2 21.0001H17.8C18.9201 21.0001 19.4802 21.0001 19.908 20.7821C20.2843 20.5903 20.5903 20.2844 20.782 19.9081C21 19.4802 21 18.9202 21 17.8001V11.2001C21 10.08 21 9.51992 20.782 9.09209C20.5903 8.71577 20.2843 8.40981 19.908 8.21806C19.4802 8.00007 18.9201 8.00007 17.8 8.00007L6.2 8.00007C5.0799 8.00007 4.51984 8.00007 4.09202 8.21806C3.7157 8.40981 3.40973 8.71577 3.21799 9.09209C3 9.51991 3 10.08 3 11.2001ZM17 14.5001C17 14.7762 16.7761 15.0001 16.5 15.0001C16.2239 15.0001 16 14.7762 16 14.5001C16 14.2239 16.2239 14.0001 16.5 14.0001C16.7761 14.0001 17 14.2239 17 14.5001Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_bi_195_4047"
|
||||
x="1"
|
||||
y="5"
|
||||
width="22"
|
||||
height="18"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
|
||||
<feComposite
|
||||
in2="SourceAlpha"
|
||||
operator="in"
|
||||
result="effect1_backgroundBlur_195_4047"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_backgroundBlur_195_4047"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="0.2" />
|
||||
<feGaussianBlur stdDeviation="0.05" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect2_innerShadow_195_4047"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_195_4047"
|
||||
x1="15.026"
|
||||
y1="22.4602"
|
||||
x2="3.73188"
|
||||
y2="27.2831"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2567FF" />
|
||||
<stop offset="1" stopColor="#22A7FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-86
@@ -1,98 +1,21 @@
|
||||
export function ContractIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={props.className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
role="presentation"
|
||||
>
|
||||
<title>Contract</title>
|
||||
<g>
|
||||
<rect
|
||||
x="3"
|
||||
y="21"
|
||||
width="14.9988"
|
||||
height="10.6499"
|
||||
rx="1.5"
|
||||
transform="rotate(-90 3 21)"
|
||||
fill="url(#paint0_linear_281_45147)"
|
||||
/>
|
||||
<g filter="url(#filter0_bi_281_45147)">
|
||||
<path
|
||||
d="M7.39429 4.5C7.39429 3.67157 8.06586 3 8.89429 3H19.5C20.3285 3 21 3.67157 21 4.5V19.5C21 20.3284 20.3285 21 19.5 21H8.89429C8.06586 21 7.39429 20.3284 7.39429 19.5V4.5Z"
|
||||
fill="#EBA4D2"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M11.1971 7L17.1971 7.0001"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.1971 10L14.1971 10.0001"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_bi_281_45147"
|
||||
x="5.39429"
|
||||
y="1"
|
||||
width="17.6057"
|
||||
height="22"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
|
||||
<feComposite
|
||||
in2="SourceAlpha"
|
||||
operator="in"
|
||||
result="effect1_backgroundBlur_281_45147"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_backgroundBlur_281_45147"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="0.2" />
|
||||
<feGaussianBlur stdDeviation="0.05" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect2_innerShadow_281_45147"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_281_45147"
|
||||
x1="-2.77663"
|
||||
y1="30.016"
|
||||
x2="10.9305"
|
||||
y2="13.6496"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#F4009F" />
|
||||
<stop offset="1" stopColor="#F856C8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M14 11H8M10 15H8M16 7H8M20 6.8V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V6.8C4 5.11984 4 4.27976 4.32698 3.63803C4.6146 3.07354 5.07354 2.6146 5.63803 2.32698C6.27976 2 7.11984 2 8.8 2H15.2C16.8802 2 17.7202 2 18.362 2.32698C18.9265 2.6146 19.3854 3.07354 19.673 3.63803C20 4.27976 20 5.11984 20 6.8Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,77 +7,15 @@ export function EngineIcon(props: { className?: string }) {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
role="presentation"
|
||||
>
|
||||
<title>Engine</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M14.9523 11.9988C14.9523 11.9981 14.9518 11.9975 14.951 11.9975C14.9089 11.9992 14.8665 12 14.8239 12C13.0588 12 11.6278 10.5691 11.6278 8.8039C11.6278 7.03873 13.0588 5.60779 14.8239 5.60779C15.9762 5.60779 16.986 6.21756 17.5484 7.13202C17.695 7.37046 17.9908 7.48805 18.2658 7.43583C18.4057 7.40926 18.5501 7.39536 18.6978 7.39536C19.9694 7.39536 21.0001 8.42614 21.0001 9.69767C21.0001 10.9692 19.9694 12 18.6979 12C18.6979 12 18.6979 12 18.6979 12C18.6979 12 18.6979 12 18.6978 12H14.9536C14.9529 12 14.9523 11.9994 14.9523 11.9988Z"
|
||||
fill="url(#paint0_linear_195_3943)"
|
||||
d="M6.5 19C4.01472 19 2 16.9853 2 14.5C2 12.1564 3.79151 10.2313 6.07974 10.0194C6.54781 7.17213 9.02024 5 12 5C14.9798 5 17.4522 7.17213 17.9203 10.0194C20.2085 10.2313 22 12.1564 22 14.5C22 16.9853 19.9853 19 17.5 19C13.1102 19 10.3433 19 6.5 19Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<g filter="url(#filter0_bi_195_3943)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.18764 18.9251C6.17601 18.9253 6.16437 18.9253 6.15271 18.9253C4.41138 18.9253 2.99976 17.5137 2.99976 15.7724C2.99976 14.031 4.41138 12.6194 6.15271 12.6194C6.18301 12.6194 6.20685 12.5932 6.20379 12.563C6.18785 12.4057 6.1797 12.2463 6.1797 12.085C6.1797 9.37046 8.48997 7.16992 11.3398 7.16992C13.8545 7.16992 15.949 8.88324 16.407 11.1512C16.4682 11.4541 16.7615 11.6671 17.0697 11.6434C17.1634 11.6361 17.2581 11.6325 17.3536 11.6325C19.3675 11.6325 21 13.265 21 15.2789C21 17.2928 19.3675 18.9253 17.3536 18.9253C17.285 18.9253 17.2169 18.9234 17.1493 18.9197C17.1063 18.9234 17.0628 18.9253 17.0188 18.9253H6.20985C6.20243 18.9253 6.19503 18.9252 6.18764 18.9251Z"
|
||||
fill="#BFB5FF"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_bi_195_3943"
|
||||
x="0.999756"
|
||||
y="5.16992"
|
||||
width="22.0003"
|
||||
height="15.7554"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
|
||||
<feComposite
|
||||
in2="SourceAlpha"
|
||||
operator="in"
|
||||
result="effect1_backgroundBlur_195_3943"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_backgroundBlur_195_3943"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="0.2" />
|
||||
<feGaussianBlur stdDeviation="0.05" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect2_innerShadow_195_3943"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_195_3943"
|
||||
x1="13.1976"
|
||||
y1="10.8463"
|
||||
x2="28.7967"
|
||||
y2="2.94276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#3F2DAF" />
|
||||
<stop offset="1" stopColor="#917FFB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,47 +7,15 @@ export function InsightIcon(props: { className?: string }) {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
role="presentation"
|
||||
>
|
||||
<title>Insight</title>
|
||||
<circle
|
||||
cx="8.11963"
|
||||
cy="12.9889"
|
||||
r="4.505"
|
||||
fill="url(#paint0_linear_1095_479)"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M21.2163 7.97235C21.2163 7.96914 21.2164 7.96592 21.2164 7.96271C21.2164 5.60751 18.4863 3.69824 15.1186 3.69824C11.7509 3.69824 9.02089 5.60751 9.02089 7.96271C9.02089 7.96324 9.02089 7.96378 9.02089 7.96431H9.02087V17.0369C9.02087 17.0372 9.02087 17.0374 9.02087 17.0376C9.02137 19.3925 11.7512 21.3014 15.1186 21.3014C18.486 21.3014 21.2158 19.3925 21.2163 17.0376C21.2163 17.0374 21.2163 17.0372 21.2163 17.0369V7.97235Z"
|
||||
fill="#06B6D4"
|
||||
/>
|
||||
<path
|
||||
d="M11.1232 10.3516V10.3516C13.5336 11.9944 16.704 11.9944 19.1145 10.3515V10.3515"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
d="M20.5 7.27783L12 12.0001M12 12.0001L3.49997 7.27783M12 12.0001L12 21.5001M21 12.0001V7.94153C21 7.59889 21 7.42757 20.9495 7.27477C20.9049 7.13959 20.8318 7.01551 20.7354 6.91082C20.6263 6.79248 20.4766 6.70928 20.177 6.54288L12.777 2.43177C12.4934 2.27421 12.3516 2.19543 12.2015 2.16454C12.0685 2.13721 11.9315 2.13721 11.7986 2.16454C11.6484 2.19543 11.5066 2.27421 11.223 2.43177L3.82297 6.54288C3.52345 6.70928 3.37368 6.79248 3.26463 6.91082C3.16816 7.01551 3.09515 7.13959 3.05048 7.27477C3 7.42757 3 7.59889 3 7.94153V16.0586C3 16.4013 3 16.5726 3.05048 16.7254C3.09515 16.8606 3.16816 16.9847 3.26463 17.0893C3.37368 17.2077 3.52346 17.2909 3.82297 17.4573L11.223 21.5684C11.5066 21.726 11.6484 21.8047 11.7986 21.8356C11.9315 21.863 12.0685 21.863 12.2015 21.8356C12.3516 21.8047 12.4934 21.726 12.777 21.5684L13 21.4445M7.5 4.50008L16.5 9.50008M22 21.5001L21 20.5001M22 18.0001C22 19.6569 20.6569 21.0001 19 21.0001C17.3431 21.0001 16 19.6569 16 18.0001C16 16.3432 17.3431 15.0001 19 15.0001C20.6569 15.0001 22 16.3432 22 18.0001Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.1232 15.0107V15.0107C13.5336 16.6536 16.704 16.6535 19.1145 15.0107V15.0107"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_1095_479"
|
||||
x1="8.11963"
|
||||
y1="16.3861"
|
||||
x2="-1.14166"
|
||||
y2="3.67577"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#155E75" />
|
||||
<stop offset="1" stopColor="#27B0DB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,79 +7,15 @@ export function PayIcon(props: { className?: string }) {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
role="presentation"
|
||||
>
|
||||
<title>Pay</title>
|
||||
<circle cx="15.9849" cy="16" r="5" fill="url(#paint0_linear_585_2)" />
|
||||
<g filter="url(#filter0_bi_585_2)">
|
||||
<path
|
||||
d="M3 4.5C3 3.67157 3.67157 3 4.5 3H17.5C18.3284 3 19 3.67157 19 4.5V13.5C19 14.3284 18.3284 15 17.5 15H4.5C3.67157 15 3 14.3284 3 13.5V4.5Z"
|
||||
fill="#B9DDFF"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="4.98955"
|
||||
y="9.44861"
|
||||
width="3.54886"
|
||||
height="3.54886"
|
||||
rx="1"
|
||||
fill="white"
|
||||
<path
|
||||
d="M22 10H2M2 8.2L2 15.8C2 16.9201 2 17.4802 2.21799 17.908C2.40973 18.2843 2.71569 18.5903 3.09202 18.782C3.51984 19 4.07989 19 5.2 19L18.8 19C19.9201 19 20.4802 19 20.908 18.782C21.2843 18.5903 21.5903 18.2843 21.782 17.908C22 17.4802 22 16.9201 22 15.8V8.2C22 7.0799 22 6.51984 21.782 6.09202C21.5903 5.7157 21.2843 5.40974 20.908 5.21799C20.4802 5 19.9201 5 18.8 5L5.2 5C4.0799 5 3.51984 5 3.09202 5.21799C2.7157 5.40973 2.40973 5.71569 2.21799 6.09202C2 6.51984 2 7.07989 2 8.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M3.00316 5.99185H19" stroke="white" />
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_bi_585_2"
|
||||
x="1"
|
||||
y="1"
|
||||
width="20"
|
||||
height="16"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
|
||||
<feComposite
|
||||
in2="SourceAlpha"
|
||||
operator="in"
|
||||
result="effect1_backgroundBlur_585_2"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_backgroundBlur_585_2"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="0.2" />
|
||||
<feGaussianBlur stdDeviation="0.05" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect2_innerShadow_585_2"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_585_2"
|
||||
x1="19.003"
|
||||
y1="15.1892"
|
||||
x2="10.8494"
|
||||
y2="17.6614"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2567FF" />
|
||||
<stop offset="1" stopColor="#22A7FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,92 +7,15 @@ export function RPCIcon(props: { className?: string }) {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
role="presentation"
|
||||
>
|
||||
<title>RPC</title>
|
||||
<rect
|
||||
x="12.0015"
|
||||
y="12.5007"
|
||||
width="8.99854"
|
||||
height="8.99854"
|
||||
rx="1.5"
|
||||
fill="url(#paint0_linear_195_3994)"
|
||||
/>
|
||||
<g filter="url(#filter0_i_195_3994)">
|
||||
<path
|
||||
d="M5 18C3.89543 18 3 17.1046 3 16L3 5C3 3.89543 3.89543 3 5 3L16 3C17.1046 3 18 3.89543 18 5L18 16C18 17.1046 17.1046 18 16 18L5 18Z"
|
||||
fill="#BFB5FF"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M6.10608 9.20007C7.27946 8.04993 8.85702 7.4057 10.5001 7.4057C12.1431 7.4057 13.7207 8.04993 14.8941 9.20007"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.30289 11.3971C8.88958 10.822 9.67836 10.4999 10.4999 10.4999C11.3214 10.4999 12.1102 10.822 12.6969 11.3971"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.5 13.5941H10.5058"
|
||||
stroke="white"
|
||||
d="M4 11C6.38695 11 8.67613 11.9482 10.364 13.636C12.0518 15.3239 13 17.6131 13 20M4 4C8.24346 4 12.3131 5.68571 15.3137 8.68629C18.3143 11.6869 20 15.7565 20 20M6 19C6 19.5523 5.55228 20 5 20C4.44772 20 4 19.5523 4 19C4 18.4477 4.44772 18 5 18C5.55228 18 6 18.4477 6 19Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_i_195_3994"
|
||||
x="3"
|
||||
y="3"
|
||||
width="15"
|
||||
height="15.2764"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="0.276368" />
|
||||
<feGaussianBlur stdDeviation="0.138184" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect1_innerShadow_195_3994"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_195_3994"
|
||||
x1="13.5087"
|
||||
y1="19.8752"
|
||||
x2="30.3227"
|
||||
y2="14.0648"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#3F2DAF" />
|
||||
<stop offset="1" stopColor="#917FFB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-77
@@ -7,86 +7,15 @@ export function SmartAccountIcon(props: { className?: string }) {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
role="presentation"
|
||||
>
|
||||
<title>Smart Account</title>
|
||||
<rect
|
||||
x="11.6693"
|
||||
y="16.9566"
|
||||
width="13.4583"
|
||||
height="9.33069"
|
||||
rx="1.49536"
|
||||
transform="rotate(-90 11.6693 16.9566)"
|
||||
fill="url(#paint0_linear_195_3925)"
|
||||
/>
|
||||
<g filter="url(#filter0_bi_195_3925)">
|
||||
<path
|
||||
d="M8.18321 21.2503C8.7189 21.581 9.37914 21.581 9.91484 21.2503C11.5871 20.2182 15.098 17.6396 15.098 14.1832V10.2763C15.098 9.44263 14.5809 8.69639 13.8003 8.40366L9.75127 6.88528C9.2985 6.71549 8.79955 6.71549 8.34678 6.88528L4.29775 8.40366C3.51715 8.69639 3 9.44263 3 10.2763V14.1832C3 17.6396 6.51095 20.2182 8.18321 21.2503Z"
|
||||
fill="#B9DDFF"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M9.04903 14.4024C9.98975 14.4024 10.7524 13.6398 10.7524 12.6991C10.7524 11.7583 9.98975 10.9957 9.04903 10.9957C8.10831 10.9957 7.3457 11.7583 7.3457 12.6991C7.3457 13.6398 8.10831 14.4024 9.04903 14.4024Z"
|
||||
fill="white"
|
||||
d="M13 7L11.8845 4.76892C11.5634 4.1268 11.4029 3.80573 11.1634 3.57116C10.9516 3.36373 10.6963 3.20597 10.4161 3.10931C10.0992 3 9.74021 3 9.02229 3H5.2C4.0799 3 3.51984 3 3.09202 3.21799C2.71569 3.40973 2.40973 3.71569 2.21799 4.09202C2 4.51984 2 5.0799 2 6.2V7M2 7H17.2C18.8802 7 19.7202 7 20.362 7.32698C20.9265 7.6146 21.3854 8.07354 21.673 8.63803C22 9.27976 22 10.1198 22 11.8V16.2C22 17.8802 22 18.7202 21.673 19.362C21.3854 19.9265 20.9265 20.3854 20.362 20.673C19.7202 21 18.8802 21 17.2 21H6.8C5.11984 21 4.27976 21 3.63803 20.673C3.07354 20.3854 2.6146 19.9265 2.32698 19.362C2 18.7202 2 17.8802 2 16.2V7ZM12 17.5C12 17.5 15 16.0701 15 13.9252V11.4229L12.8124 10.6412C12.2868 10.4529 11.712 10.4529 11.1864 10.6412L9 11.4229V13.9252C9 16.0701 12 17.5 12 17.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.04906 13.6652C9.3792 13.6652 9.6472 13.9415 9.6472 14.2828V16.7528C9.6472 17.0937 9.37954 17.3704 9.04906 17.3704C8.71893 17.3704 8.45093 17.094 8.45093 16.7528V14.2828C8.45093 13.9419 8.71859 13.6652 9.04906 13.6652Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_bi_195_3925"
|
||||
x="1"
|
||||
y="4.75793"
|
||||
width="16.098"
|
||||
height="18.7404"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
|
||||
<feComposite
|
||||
in2="SourceAlpha"
|
||||
operator="in"
|
||||
result="effect1_backgroundBlur_195_3925"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_backgroundBlur_195_3925"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="0.2" />
|
||||
<feGaussianBlur stdDeviation="0.05" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect2_innerShadow_195_3925"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_195_3925"
|
||||
x1="22.4602"
|
||||
y1="20.8654"
|
||||
x2="12.4019"
|
||||
y2="25.2643"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2567FF" />
|
||||
<stop offset="1" stopColor="#22A7FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NebulaIcon } from "../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
import type { ChainSupportedService } from "../../types/chain";
|
||||
import { ConnectSDKIcon } from "./icons/ConnectSDKIcon";
|
||||
import { ContractIcon } from "./icons/ContractIcon";
|
||||
@@ -13,14 +14,14 @@ export const products = [
|
||||
id: "contracts",
|
||||
icon: ContractIcon,
|
||||
description: "Pre-built contracts & deployment tools",
|
||||
link: "https://portal.thirdweb.com/contracts",
|
||||
link: "https://thirdweb.com/contracts",
|
||||
},
|
||||
{
|
||||
name: "Connect SDK",
|
||||
id: "connect-sdk",
|
||||
icon: ConnectSDKIcon,
|
||||
description: "Client-side SDKs for wallets and blockchain interactions",
|
||||
link: "https://portal.thirdweb.com/connect",
|
||||
link: "https://thirdweb.com/connect",
|
||||
},
|
||||
{
|
||||
name: "RPC Edge",
|
||||
@@ -34,7 +35,7 @@ export const products = [
|
||||
id: "engine",
|
||||
icon: EngineIcon,
|
||||
description: "Backend server that reads, writes, and deploys contracts",
|
||||
link: "https://portal.thirdweb.com/engine",
|
||||
link: "https://thirdweb.com/engine",
|
||||
},
|
||||
{
|
||||
name: "Account Abstraction",
|
||||
@@ -55,7 +56,14 @@ export const products = [
|
||||
id: "insight",
|
||||
icon: InsightIcon,
|
||||
description: "Query, transform and analyze blockchain data",
|
||||
link: "https://portal.thirdweb.com/insight",
|
||||
link: "https://thirdweb.com/insight",
|
||||
},
|
||||
{
|
||||
name: "Nebula",
|
||||
id: "nebula",
|
||||
icon: NebulaIcon,
|
||||
description: "The most powerful AI for interacting with the blockchain",
|
||||
link: "https://thirdweb.com/nebula",
|
||||
},
|
||||
] satisfies Array<{
|
||||
name: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ChainSupportedService =
|
||||
| "connect-sdk"
|
||||
| "engine"
|
||||
| "account-abstraction"
|
||||
| "nebula"
|
||||
| "pay"
|
||||
| "rpc-edge"
|
||||
| "chainsaw"
|
||||
|
||||
+12
-12
@@ -33,9 +33,9 @@ const PackageIcon: React.FC = () => (
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,.5)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="16.5" y1="9.4" x2="7.5" y2="4.21" />
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
@@ -53,9 +53,9 @@ const FileTextIcon: React.FC = () => (
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,.5)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
@@ -74,9 +74,9 @@ const CalendarIcon: React.FC = () => (
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,.5)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
@@ -94,9 +94,9 @@ const VersionIcon: React.FC = () => (
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,.5)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { Box, Container, Flex } from "@chakra-ui/react";
|
||||
import { ChakraNextImage } from "components/Image";
|
||||
import { LandingDynamicSelector } from "components/landing-pages/dynamic-selector";
|
||||
import { LandingEndCTA } from "components/landing-pages/end-cta";
|
||||
import { LandingGuidesShowcase } from "components/landing-pages/guide-showcase";
|
||||
import { LandingHeroWithSideImage } from "components/landing-pages/hero-with-side-image";
|
||||
import { LandingShowcaseImage } from "components/landing-pages/showcase-image";
|
||||
import type { Metadata } from "next";
|
||||
import { Heading, Text } from "tw-components";
|
||||
// images
|
||||
import smartWalletMiniImage from "../../../../public/assets/product-icons/smart-wallet.png";
|
||||
import accountAbstractionImage from "../../../../public/assets/product-pages/smart-wallet/account-abstraction.png";
|
||||
import batchTxImage from "../../../../public/assets/product-pages/smart-wallet/batch-txns.png";
|
||||
import dashboardImage from "../../../../public/assets/product-pages/smart-wallet/dashboard.png";
|
||||
import desktopHero from "../../../../public/assets/product-pages/smart-wallet/desktop-hero.png";
|
||||
import fullyProgrammaticImage from "../../../../public/assets/product-pages/smart-wallet/full-programmability.png";
|
||||
import getStartedImage from "../../../../public/assets/product-pages/smart-wallet/get-started.png";
|
||||
import invisibleWalletsImage from "../../../../public/assets/product-pages/smart-wallet/invisible-wallet.png";
|
||||
import managedInfrastructureImage from "../../../../public/assets/product-pages/smart-wallet/managed-infrastructure.png";
|
||||
import mobileHero from "../../../../public/assets/product-pages/smart-wallet/mobile-hero.png";
|
||||
import pairAnyWalletImage from "../../../../public/assets/product-pages/smart-wallet/pair-any-wallet.png";
|
||||
import smartContractsImage from "../../../../public/assets/product-pages/smart-wallet/smart-contracts.png";
|
||||
import uiComponentsImage from "../../../../public/assets/product-pages/smart-wallet/ui-components.png";
|
||||
import chooseContractImage from "../../../../public/assets/product-pages/smart-wallet/which-contract.png";
|
||||
import { getAbsoluteUrl } from "../../../lib/vercel-utils";
|
||||
|
||||
const GUIDES = [
|
||||
{
|
||||
title: "The Quick-Start Guide to Account Abstraction",
|
||||
image: getStartedImage,
|
||||
link: "https://portal.thirdweb.com/wallets/smart-wallet/get-started",
|
||||
},
|
||||
{
|
||||
title: "Choosing Between Simple, Managed, & Dynamic Smart Accounts",
|
||||
image: chooseContractImage,
|
||||
link: "https://blog.thirdweb.com/smart-contract-deep-dive-building-smart-wallets-for-individuals-and-teams/",
|
||||
},
|
||||
{
|
||||
title: "How to Enable Batch Transactions with Account Abstraction",
|
||||
image: batchTxImage,
|
||||
link: "https://blog.thirdweb.com/guides/how-to-batch-transactions-with-the-thirdweb-sdk/",
|
||||
},
|
||||
];
|
||||
|
||||
const TRACKING_CATEGORY = "smart-wallet-landing";
|
||||
|
||||
const title = "The Complete Account Abstraction Toolkit";
|
||||
const description =
|
||||
"Add account abstraction to your web3 app & unlock powerful features for seamless onboarding, customizable transactions, & maximum security. Learn more.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: [
|
||||
{
|
||||
url: `${getAbsoluteUrl()}/assets/og-image/smart-wallet.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Container
|
||||
maxW="container.page"
|
||||
as={Flex}
|
||||
flexDir="column"
|
||||
gap={{ base: "80px", md: "120px" }}
|
||||
>
|
||||
<LandingHeroWithSideImage
|
||||
miniTitle="Powered by ERC-4337"
|
||||
title="The complete toolkit for"
|
||||
titleWithGradient="Account Abstraction"
|
||||
subtitle="Transform your app's user experience with signless transactions, multi-signature security, account recovery and more."
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
ctaLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
|
||||
contactUsTitle="Book Demo"
|
||||
gradient="linear(to-r, #4490FF, #4490FF)"
|
||||
image={desktopHero}
|
||||
mobileImage={mobileHero}
|
||||
miniImage={smartWalletMiniImage}
|
||||
/>
|
||||
<LandingDynamicSelector
|
||||
title="What can you build with account abstraction?"
|
||||
blackToWhiteTitle=""
|
||||
TRACKING_CATEGORY={TRACKING_CATEGORY}
|
||||
items={[
|
||||
{
|
||||
title: "Transform your apps UX",
|
||||
description:
|
||||
"Infinitely-customizable wallet transactions — with spend limits, batch transactions, and a signless & gasless combo for web2 users.",
|
||||
Component: (
|
||||
<ChakraNextImage
|
||||
src={invisibleWalletsImage}
|
||||
alt=""
|
||||
borderRadius="lg"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Instant onboarding for every user",
|
||||
description:
|
||||
"Auth for the most popular web3 wallets and web2 login flows — with just an email, phone number, social account, or passkeys.",
|
||||
Component: (
|
||||
<ChakraNextImage
|
||||
src={pairAnyWalletImage}
|
||||
alt=""
|
||||
borderRadius="lg"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Enterprise-grade security",
|
||||
description:
|
||||
"Wallet recovery, 2FA, and multi-signature support for ultimate peace of mind — for users & teams.",
|
||||
Component: (
|
||||
<ChakraNextImage
|
||||
src={fullyProgrammaticImage}
|
||||
alt=""
|
||||
borderRadius="lg"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Flex flexDir="column" gap={6} alignItems="center">
|
||||
<Heading
|
||||
as="h2"
|
||||
size="display.md"
|
||||
textAlign="center"
|
||||
px={{ base: 2, md: 0 }}
|
||||
>
|
||||
An all-in-one solution for
|
||||
<Box bgGradient="linear(to-r, #3385FF, #7BB0FF)" bgClip="text">
|
||||
Account Abstraction
|
||||
</Box>
|
||||
</Heading>
|
||||
<Text textAlign="center" size="body.xl" w={{ base: "100%", md: "60%" }}>
|
||||
Implement account abstraction into any web3 app — with a best-in-class
|
||||
SDK, full wallet customizability, and managed infrastructure.
|
||||
</Text>
|
||||
</Flex>
|
||||
<LandingShowcaseImage
|
||||
miniTitle="Smart Contracts"
|
||||
titleWithGradient="Fast Account Abstraction"
|
||||
title="factory deployment."
|
||||
gradient="linear(to-r, #3385FF, #7BB0FF)"
|
||||
description="Pre-built, audited, & customizable factory contracts with options for simple, managed, or dynamic permissions — deployable to any EVM network."
|
||||
image={smartContractsImage}
|
||||
/>
|
||||
<LandingShowcaseImage
|
||||
miniTitle="SDK"
|
||||
titleWithGradient="Integrate account abstraction"
|
||||
title="into your web3 apps."
|
||||
gradient="linear(to-r, #3385FF, #7BB0FF)"
|
||||
description="A best-in-class SDK with everything you need to build apps powered by account abstraction & generate accounts for your users — with full ERC-4337 compatibility."
|
||||
image={accountAbstractionImage}
|
||||
imagePosition="left"
|
||||
/>
|
||||
<LandingShowcaseImage
|
||||
miniTitle="UI Components"
|
||||
titleWithGradient="Drag and drop"
|
||||
title="UI Components."
|
||||
gradient="linear(to-r, #3385FF, #7BB0FF)"
|
||||
description="The easiest way to give accounts to your users with account abstraction — with UI components that are purpose-built for account abstraction & compatible with any existing ERC-4337 account, out of the box."
|
||||
image={uiComponentsImage}
|
||||
/>
|
||||
<LandingShowcaseImage
|
||||
miniTitle="Managed Infrastructure"
|
||||
titleWithGradient="Support millions"
|
||||
title="of users."
|
||||
gradient="linear(to-r, #3385FF, #7BB0FF)"
|
||||
description="Managed infrastructure with everything you need to scale to millions of users — handling bundlers, paymasters, & all of the complexity so you can focus on building your app."
|
||||
image={managedInfrastructureImage}
|
||||
imagePosition="left"
|
||||
/>
|
||||
<LandingShowcaseImage
|
||||
miniTitle="Dashboard & Analytics"
|
||||
titleWithGradient="Manage accounts"
|
||||
title="& view onchain analytics."
|
||||
gradient="linear(to-r, #3385FF, #7BB0FF)"
|
||||
description="A single source of truth to view accounts, manage signers, and view balances for your web3 app — complete with onchain analytics."
|
||||
image={dashboardImage}
|
||||
/>
|
||||
<LandingGuidesShowcase
|
||||
title="Get started with Account Abstraction"
|
||||
category={TRACKING_CATEGORY}
|
||||
description="See our quick-start guides to implement account abstraction into your web3 app"
|
||||
solution="Account Abstraction"
|
||||
guides={GUIDES}
|
||||
/>
|
||||
|
||||
<LandingEndCTA
|
||||
title="Add account abstraction"
|
||||
titleWithGradient="to your web3 app."
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
ctaLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
|
||||
gradient="linear(to-r, #3385FF, #7BB0FF)"
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export type AccountHeaderCompProps = {
|
||||
connectButton: React.ReactNode;
|
||||
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
|
||||
createProject: (team: Team) => void;
|
||||
account: Pick<Account, "email" | "id"> | undefined;
|
||||
account: Pick<Account, "email" | "id" | "image"> | undefined;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
@@ -41,10 +41,9 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
href="/account"
|
||||
className="flex flex-row items-center gap-2 font-normal text-sm"
|
||||
>
|
||||
{/* TODO - set account Image */}
|
||||
<GradientAvatar
|
||||
id={props.account?.id || "default"}
|
||||
src={""}
|
||||
src={props.account?.image || ""}
|
||||
className="size-6"
|
||||
client={props.client}
|
||||
/>
|
||||
@@ -90,10 +89,9 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
|
||||
"flex flex-row items-center gap-2 font-normal text-foreground text-sm",
|
||||
)}
|
||||
>
|
||||
{/* TODO - set account image */}
|
||||
<GradientAvatar
|
||||
id={props.account?.id}
|
||||
src={props.account ? "" : undefined}
|
||||
src={props.account?.image || ""}
|
||||
className="size-6"
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { updateAccount } from "@/actions/updateAccount";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
|
||||
export function AccountSettingsPage(props: {
|
||||
@@ -23,9 +24,25 @@ export function AccountSettingsPage(props: {
|
||||
</div>
|
||||
<div className="container max-w-[950px] grow pt-8 pb-20">
|
||||
<AccountSettingsPageUI
|
||||
// TODO - remove hide props these when these fields are functional
|
||||
hideAvatar
|
||||
hideDeleteAccount
|
||||
client={props.client}
|
||||
updateAccountAvatar={async (file) => {
|
||||
let uri: string | undefined = undefined;
|
||||
|
||||
if (file) {
|
||||
// upload to IPFS
|
||||
uri = await upload({
|
||||
client: props.client,
|
||||
files: [file],
|
||||
});
|
||||
}
|
||||
|
||||
await updateAccount({
|
||||
image: uri,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
}}
|
||||
account={props.account}
|
||||
updateEmailWithOTP={async (otp) => {
|
||||
const res = await confirmEmailWithOTP(otp);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
@@ -33,6 +34,8 @@ export const Mobile: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const client = getThirdwebClient();
|
||||
|
||||
function Variants() {
|
||||
const [isVerifiedEmail, setIsVerifiedEmail] = useState(true);
|
||||
const [sendEmailFails, setSendEmailFails] = useState(false);
|
||||
@@ -74,6 +77,10 @@ function Variants() {
|
||||
? new Date().toISOString()
|
||||
: undefined,
|
||||
}}
|
||||
client={client}
|
||||
updateAccountAvatar={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
updateEmailWithOTP={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (emailConfirmationFails) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -39,30 +40,35 @@ import { EllipsisIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { z } from "zod";
|
||||
import { FileInput } from "../../../components/shared/FileInput";
|
||||
|
||||
type MinimalAccount = Pick<
|
||||
Account,
|
||||
"name" | "email" | "emailConfirmedAt" | "unconfirmedEmail"
|
||||
"name" | "email" | "emailConfirmedAt" | "unconfirmedEmail" | "image"
|
||||
>;
|
||||
|
||||
export function AccountSettingsPageUI(props: {
|
||||
account: MinimalAccount;
|
||||
// TODO - remove hide props these when these fields are functional
|
||||
hideAvatar?: boolean;
|
||||
hideDeleteAccount?: boolean;
|
||||
sendEmail: (email: string) => Promise<void>;
|
||||
updateName: (name: string) => Promise<void>;
|
||||
updateEmailWithOTP: (otp: string) => Promise<void>;
|
||||
updateAccountAvatar: (avatar: File | undefined) => Promise<void>;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{!props.hideAvatar && <AccountAvatarFormControl />}
|
||||
<AccountNameFormControl
|
||||
name={props.account.name || ""}
|
||||
updateName={(name) => props.updateName(name)}
|
||||
/>
|
||||
<AccountAvatarFormControl
|
||||
updateAccountAvatar={props.updateAccountAvatar}
|
||||
avatar={props.account.image || undefined}
|
||||
client={props.client}
|
||||
/>
|
||||
<AccountEmailFormControl
|
||||
email={props.account.email || ""}
|
||||
status={props.account.emailConfirmedAt ? "verified" : "unverified"}
|
||||
@@ -75,18 +81,24 @@ export function AccountSettingsPageUI(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountAvatarFormControl() {
|
||||
function AccountAvatarFormControl(props: {
|
||||
updateAccountAvatar: (avatar: File | undefined) => Promise<void>;
|
||||
avatar: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const accountAvatarUrl = resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: props.avatar,
|
||||
});
|
||||
const [avatar, setAvatar] = useState<File>();
|
||||
|
||||
// TODO - implement
|
||||
const updateAvatarMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
mutationFn: (_avatar: File | undefined) => {
|
||||
return props.updateAccountAvatar(_avatar);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const promises = updateAvatarMutation.mutateAsync();
|
||||
const promises = updateAvatarMutation.mutateAsync(avatar);
|
||||
toast.promise(promises, {
|
||||
success: "Account avatar updated successfully",
|
||||
error: "Failed to update account avatar",
|
||||
@@ -118,6 +130,7 @@ function AccountAvatarFormControl() {
|
||||
setValue={setAvatar}
|
||||
className="w-20 rounded-full lg:w-28"
|
||||
disableHelperText
|
||||
fileUrl={accountAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -30,19 +30,27 @@ export function SecondaryNavLinks() {
|
||||
<ResourcesDropdownButton />
|
||||
|
||||
<Link
|
||||
href="https://portal.thirdweb.com"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
href="https://thirdweb.com/support"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
target="_blank"
|
||||
href="/support"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
href="https://feedback.thirdweb.com"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
Feedback
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
export function AccountButton(props: {
|
||||
logout: () => void;
|
||||
connectButton: React.ReactNode;
|
||||
account?: Pick<Account, "email" | "id">;
|
||||
account?: Pick<Account, "email" | "id" | "image">;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
@@ -35,11 +35,10 @@ export function AccountButton(props: {
|
||||
>
|
||||
{/* Don't remove the div */}
|
||||
<div>
|
||||
{/* TODO - set account image */}
|
||||
<GradientAvatar
|
||||
id={props.account?.id || "default"}
|
||||
src={""}
|
||||
className="size-9"
|
||||
src={props.account?.image || ""}
|
||||
className="size-9 border"
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +159,34 @@ export function MobileBurgerMenuButton(
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://portal.thirdweb.com"
|
||||
className="text-muted-foreground hover:text-foreground "
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
target="_blank"
|
||||
href="/support"
|
||||
className="text-muted-foreground hover:text-foreground "
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://feedback.thirdweb.com"
|
||||
className="text-muted-foreground hover:text-foreground "
|
||||
>
|
||||
Feedback
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<Separator />
|
||||
<div className="h-6" />
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
|
||||
accentText: "hsl(var(--link-foreground))",
|
||||
accentButtonBg: "hsl(var(--primary))",
|
||||
accentButtonText: "hsl(var(--primary-foreground))",
|
||||
primaryButtonBg: "hsl(var(--background))",
|
||||
primaryButtonText: "hsl(var(--foreground))",
|
||||
primaryButtonBg: "hsl(var(--inverted))",
|
||||
primaryButtonText: "hsl(var(--inverted-foreground))",
|
||||
secondaryButtonText: "hsl(var(--secondary-foreground))",
|
||||
tooltipBg: "hsl(var(--popover))",
|
||||
tooltipText: "hsl(var(--popover-foreground))",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "@/styles/globals.css";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Metadata } from "next";
|
||||
@@ -82,7 +81,6 @@ export default function RootLayout({
|
||||
<EnsureValidConnectedWalletLoginServer />
|
||||
</Suspense>
|
||||
</AppRouterProviders>
|
||||
<Toaster richColors />
|
||||
<DashboardRouterTopProgressBar />
|
||||
<NextTopLoader
|
||||
color="hsl(var(--primary))"
|
||||
|
||||
@@ -55,7 +55,15 @@ export function LoginAndOnboardingPage(props: {
|
||||
<ThirdwebMiniLogo className="size-7 md:size-8" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/"
|
||||
className="px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/support"
|
||||
target="_blank"
|
||||
@@ -65,11 +73,11 @@ export function LoginAndOnboardingPage(props: {
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/"
|
||||
className="px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
href="https://feedback.thirdweb.com"
|
||||
className="px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
Feedback
|
||||
</Link>
|
||||
|
||||
<ColorModeToggle />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,42 +2,33 @@ import { NEXT_PUBLIC_NEBULA_URL } from "@/constants/env";
|
||||
// TODO - copy the source of this library to dashboard
|
||||
import { stream } from "fetch-event-stream";
|
||||
import type { NebulaTxData } from "../components/Chats";
|
||||
import type { ExecuteConfig } from "./types";
|
||||
|
||||
export type ContextFilters = {
|
||||
chainIds?: string[];
|
||||
contractAddresses?: string[];
|
||||
walletAddresses?: string[];
|
||||
export type NebulaContext = {
|
||||
chainIds: string[] | null;
|
||||
walletAddress: string | null;
|
||||
};
|
||||
|
||||
export async function promptNebula(params: {
|
||||
message: string;
|
||||
sessionId: string;
|
||||
config: ExecuteConfig | null;
|
||||
authToken: string;
|
||||
handleStream: (res: ChatStreamedResponse) => void;
|
||||
abortController: AbortController;
|
||||
contextFilters: undefined | ContextFilters;
|
||||
context: undefined | NebulaContext;
|
||||
}) {
|
||||
const body: Record<string, string | boolean | object> = {
|
||||
message: params.message,
|
||||
user_id: "default-user",
|
||||
session_id: params.sessionId,
|
||||
stream: true,
|
||||
session_id: params.sessionId,
|
||||
};
|
||||
|
||||
if (params.contextFilters) {
|
||||
body.context_filter = {
|
||||
chain_ids: params.contextFilters.chainIds || [],
|
||||
contract_addresses: params.contextFilters.contractAddresses || [],
|
||||
wallet_addresses: params.contextFilters.walletAddresses || [],
|
||||
if (params.context) {
|
||||
body.context = {
|
||||
chain_ids: params.context.chainIds || [],
|
||||
wallet_address: params.context.walletAddress,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.config) {
|
||||
body.execute_config = params.config;
|
||||
}
|
||||
|
||||
const events = await stream(`${NEXT_PUBLIC_NEBULA_URL}/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
import { NEXT_PUBLIC_NEBULA_URL } from "@/constants/env";
|
||||
import { fetchWithAuthToken } from "../../../../utils/fetchWithAuthToken";
|
||||
import type { ContextFilters } from "./chat";
|
||||
import type { NebulaContext } from "./chat";
|
||||
import type {
|
||||
DeletedSessionInfo,
|
||||
ExecuteConfig,
|
||||
SessionInfo,
|
||||
TruncatedSessionInfo,
|
||||
UpdatedSessionInfo,
|
||||
} from "./types";
|
||||
|
||||
// TODO - get the spec for return types on /session POST and PUT
|
||||
|
||||
export async function createSession(params: {
|
||||
authToken: string;
|
||||
config: ExecuteConfig | null;
|
||||
contextFilters: ContextFilters | undefined;
|
||||
context: NebulaContext | undefined;
|
||||
}) {
|
||||
const body: Record<string, string | boolean | object> = {
|
||||
can_execute: !!params.config,
|
||||
};
|
||||
if (params.config) {
|
||||
body.execute_config = params.config;
|
||||
}
|
||||
const body: Record<string, string | boolean | object> = {};
|
||||
|
||||
if (params.contextFilters) {
|
||||
body.context_filter = {
|
||||
chain_ids: params.contextFilters.chainIds || [],
|
||||
contract_addresses: params.contextFilters.contractAddresses || [],
|
||||
wallet_addresses: params.contextFilters.walletAddresses || [],
|
||||
if (params.context) {
|
||||
body.context = {
|
||||
chain_ids: params.context.chainIds || [],
|
||||
wallet_address: params.context.walletAddress,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,22 +38,15 @@ export async function createSession(params: {
|
||||
|
||||
export async function updateSession(params: {
|
||||
authToken: string;
|
||||
config: ExecuteConfig | null;
|
||||
sessionId: string;
|
||||
contextFilters: ContextFilters | undefined;
|
||||
contextFilters: NebulaContext | undefined;
|
||||
}) {
|
||||
const body: Record<string, string | boolean | object> = {
|
||||
can_execute: !!params.config,
|
||||
};
|
||||
if (params.config) {
|
||||
body.execute_config = params.config;
|
||||
}
|
||||
const body: Record<string, string | boolean | object> = {};
|
||||
|
||||
if (params.contextFilters) {
|
||||
body.context_filter = {
|
||||
body.context = {
|
||||
chain_ids: params.contextFilters.chainIds || [],
|
||||
contract_addresses: params.contextFilters.contractAddresses || [],
|
||||
wallet_addresses: params.contextFilters.walletAddresses || [],
|
||||
wallet_address: params.contextFilters.walletAddress,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
type EngineConfig = {
|
||||
mode: "engine";
|
||||
engine_url: string;
|
||||
engine_authorization_token: string;
|
||||
engine_backend_wallet_address: string;
|
||||
};
|
||||
|
||||
type SessionKeyConfig = {
|
||||
mode: "session_key";
|
||||
smart_account_address: string;
|
||||
smart_account_factory_address: string;
|
||||
smart_account_session_key: string;
|
||||
};
|
||||
|
||||
type ClientConfig = {
|
||||
mode: "client";
|
||||
signer_wallet_address: string;
|
||||
};
|
||||
|
||||
export type ExecuteConfig = EngineConfig | SessionKeyConfig | ClientConfig;
|
||||
|
||||
type SessionContextFilter = {
|
||||
chain_ids: string[] | null;
|
||||
contract_addresses: string[] | null;
|
||||
wallet_addresses: string[] | null;
|
||||
wallet_address: string | null;
|
||||
};
|
||||
|
||||
export type SessionInfo = {
|
||||
@@ -31,7 +9,6 @@ export type SessionInfo = {
|
||||
modal_name: string;
|
||||
archive_at: string | null;
|
||||
can_execute: boolean;
|
||||
execute_config: ExecuteConfig | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
history: Array<{
|
||||
@@ -43,7 +20,7 @@ export type SessionInfo = {
|
||||
archived_at: string | null;
|
||||
title: string | null;
|
||||
is_public: boolean | null;
|
||||
context_filter: SessionContextFilter | null;
|
||||
context: SessionContextFilter | null;
|
||||
// memory
|
||||
// action: array<object> | null; <-- type of this is not available on https://nebula-api.thirdweb-dev.com/docs#/default/get_session_session__session_id__get
|
||||
};
|
||||
@@ -52,8 +29,7 @@ export type UpdatedSessionInfo = {
|
||||
title: string;
|
||||
modal_name: string;
|
||||
account_id: string;
|
||||
execute_config: ExecuteConfig | null;
|
||||
context_filter: SessionContextFilter | null;
|
||||
context: SessionContextFilter | null;
|
||||
};
|
||||
|
||||
export type DeletedSessionInfo = {
|
||||
|
||||
@@ -12,11 +12,11 @@ import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
|
||||
import { type ContextFilters, promptNebula } from "../api/chat";
|
||||
import { type NebulaContext, promptNebula } from "../api/chat";
|
||||
import { createSession, updateSession } from "../api/session";
|
||||
import type { ExecuteConfig, SessionInfo } from "../api/types";
|
||||
import type { SessionInfo } from "../api/types";
|
||||
import { newChatPageUrlStore, newSessionsStore } from "../stores";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
import { type ChatMessage, Chats } from "./Chats";
|
||||
@@ -81,27 +81,26 @@ export function ChatPageContent(props: {
|
||||
useState(false);
|
||||
|
||||
const [contextFilters, _setContextFilters] = useState<
|
||||
ContextFilters | undefined
|
||||
NebulaContext | undefined
|
||||
>(() => {
|
||||
const contextFilterRes = props.session?.context_filter;
|
||||
const value: ContextFilters = {
|
||||
chainIds: contextFilterRes?.chain_ids || undefined,
|
||||
contractAddresses: contextFilterRes?.contract_addresses || undefined,
|
||||
walletAddresses: contextFilterRes?.wallet_addresses || undefined,
|
||||
const contextRes = props.session?.context;
|
||||
const value: NebulaContext = {
|
||||
chainIds: contextRes?.chain_ids || null,
|
||||
walletAddress: contextRes?.wallet_address || null,
|
||||
};
|
||||
|
||||
return value;
|
||||
});
|
||||
|
||||
const setContextFilters = useCallback((v: ContextFilters | undefined) => {
|
||||
const setContextFilters = useCallback((v: NebulaContext | undefined) => {
|
||||
_setContextFilters(v);
|
||||
setHasUserUpdatedContextFilters(true);
|
||||
}, []);
|
||||
|
||||
const isNewSession = !props.session;
|
||||
|
||||
// if this is a new session, user has not manually updated context filters
|
||||
// update the context filters to the current user's wallet address and chain id
|
||||
// if this is a new session, user has not manually updated context
|
||||
// update the context to the current user's wallet address and chain id
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!isNewSession || hasUserUpdatedContextFilters) {
|
||||
@@ -109,11 +108,16 @@ export function ChatPageContent(props: {
|
||||
}
|
||||
|
||||
_setContextFilters((_contextFilters) => {
|
||||
const updatedContextFilters: ContextFilters = _contextFilters
|
||||
? { ..._contextFilters }
|
||||
: {};
|
||||
const updatedContextFilters: NebulaContext = _contextFilters
|
||||
? {
|
||||
..._contextFilters,
|
||||
}
|
||||
: {
|
||||
chainIds: [],
|
||||
walletAddress: null,
|
||||
};
|
||||
|
||||
updatedContextFilters.walletAddresses = address ? [address] : [];
|
||||
updatedContextFilters.walletAddress = address || null;
|
||||
updatedContextFilters.chainIds = activeChain
|
||||
? [activeChain.id.toString()]
|
||||
: [];
|
||||
@@ -122,15 +126,6 @@ export function ChatPageContent(props: {
|
||||
});
|
||||
}, [address, isNewSession, hasUserUpdatedContextFilters, activeChain]);
|
||||
|
||||
const config: ExecuteConfig | null = useMemo(() => {
|
||||
return address
|
||||
? {
|
||||
mode: "client",
|
||||
signer_wallet_address: address,
|
||||
}
|
||||
: null;
|
||||
}, [address]);
|
||||
|
||||
const [sessionId, _setSessionId] = useState<string | undefined>(
|
||||
props.session?.id,
|
||||
);
|
||||
@@ -163,12 +158,11 @@ export function ChatPageContent(props: {
|
||||
const initSession = useCallback(async () => {
|
||||
const session = await createSession({
|
||||
authToken: props.authToken,
|
||||
config,
|
||||
contextFilters,
|
||||
context: contextFilters,
|
||||
});
|
||||
setSessionId(session.id);
|
||||
return session;
|
||||
}, [config, contextFilters, props.authToken, setSessionId]);
|
||||
}, [contextFilters, props.authToken, setSessionId]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (message: string) => {
|
||||
@@ -221,7 +215,6 @@ export function ChatPageContent(props: {
|
||||
abortController,
|
||||
message: message,
|
||||
sessionId: currentSessionId,
|
||||
config: config,
|
||||
authToken: props.authToken,
|
||||
handleStream(res) {
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -308,7 +301,7 @@ export function ChatPageContent(props: {
|
||||
}
|
||||
}
|
||||
},
|
||||
contextFilters: contextFilters,
|
||||
context: contextFilters,
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -338,7 +331,6 @@ export function ChatPageContent(props: {
|
||||
[
|
||||
sessionId,
|
||||
contextFilters,
|
||||
config,
|
||||
props.authToken,
|
||||
messages.length,
|
||||
initSession,
|
||||
@@ -363,13 +355,12 @@ export function ChatPageContent(props: {
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
|
||||
const handleUpdateContextFilters = async (
|
||||
values: ContextFilters | undefined,
|
||||
values: NebulaContext | undefined,
|
||||
) => {
|
||||
// if session is not yet created, don't need to update sessions - starting a chat will create a session with the context filters
|
||||
// if session is not yet created, don't need to update sessions - starting a chat will create a session with the context
|
||||
if (sessionId) {
|
||||
await updateSession({
|
||||
authToken: props.authToken,
|
||||
config,
|
||||
sessionId,
|
||||
contextFilters: values,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
|
||||
import type { ContextFilters } from "../api/chat";
|
||||
import type { NebulaContext } from "../api/chat";
|
||||
import ContextFiltersButton from "./ContextFilters";
|
||||
|
||||
const meta = {
|
||||
@@ -37,6 +37,7 @@ function Story() {
|
||||
<Variant
|
||||
contextFilters={{
|
||||
chainIds: ["137"],
|
||||
walletAddress: null,
|
||||
}}
|
||||
label="1 chain"
|
||||
/>
|
||||
@@ -44,57 +45,25 @@ function Story() {
|
||||
<Variant
|
||||
contextFilters={{
|
||||
chainIds: ["137", "10", "421614"],
|
||||
walletAddress: null,
|
||||
}}
|
||||
label="Few chains"
|
||||
/>
|
||||
|
||||
<Variant
|
||||
contextFilters={{
|
||||
contractAddresses: ["0x1E51e33F9838A5a043E099C60409f62aA564272f"],
|
||||
}}
|
||||
label="1 contract"
|
||||
/>
|
||||
|
||||
<Variant
|
||||
contextFilters={{
|
||||
contractAddresses: [
|
||||
"0x1E51e33F9838A5a043E099C60409f62aA564272f",
|
||||
"0xF61c8d5492139b40af09bDB353733d5F0a348aCf",
|
||||
],
|
||||
}}
|
||||
label="Few contracts"
|
||||
/>
|
||||
|
||||
<Variant
|
||||
contextFilters={{
|
||||
walletAddresses: ["0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37"],
|
||||
walletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
chainIds: null,
|
||||
}}
|
||||
label="1 wallet"
|
||||
/>
|
||||
|
||||
<Variant
|
||||
contextFilters={{
|
||||
walletAddresses: [
|
||||
"0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
"0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
|
||||
],
|
||||
}}
|
||||
label="Few wallets"
|
||||
/>
|
||||
|
||||
<Variant
|
||||
contextFilters={{
|
||||
chainIds: ["137", "10", "421614"],
|
||||
contractAddresses: [
|
||||
"0x1E51e33F9838A5a043E099C60409f62aA564272f",
|
||||
"0xF61c8d5492139b40af09bDB353733d5F0a348aCf",
|
||||
],
|
||||
walletAddresses: [
|
||||
"0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
"0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
|
||||
],
|
||||
walletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
}}
|
||||
label="chains + wallets + contracts"
|
||||
label="chains + wallet"
|
||||
/>
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
@@ -103,10 +72,10 @@ function Story() {
|
||||
|
||||
function Variant(props: {
|
||||
label: string;
|
||||
contextFilters: ContextFilters | undefined;
|
||||
contextFilters: NebulaContext | undefined;
|
||||
}) {
|
||||
const [contextFilters, setContextFilters] = useState<
|
||||
ContextFilters | undefined
|
||||
NebulaContext | undefined
|
||||
>(props.contextFilters);
|
||||
return (
|
||||
<BadgeContainer label={props.label}>
|
||||
@@ -114,7 +83,7 @@ function Variant(props: {
|
||||
contextFilters={contextFilters}
|
||||
setContextFilters={setContextFilters}
|
||||
updateContextFilters={async (values) => {
|
||||
console.log("Updating context filters", values);
|
||||
console.log("Updating context", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { AutoResizeTextarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -31,52 +31,34 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { isAddress } from "thirdweb";
|
||||
import { z } from "zod";
|
||||
import type { ContextFilters } from "../api/chat";
|
||||
import type { NebulaContext } from "../api/chat";
|
||||
|
||||
export default function ContextFiltersButton(props: {
|
||||
contextFilters: ContextFilters | undefined;
|
||||
setContextFilters: (filters: ContextFilters | undefined) => void;
|
||||
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
|
||||
contextFilters: NebulaContext | undefined;
|
||||
setContextFilters: (filters: NebulaContext | undefined) => void;
|
||||
updateContextFilters: (filters: NebulaContext | undefined) => Promise<void>;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const chainIds = props.contextFilters?.chainIds;
|
||||
const contractAddresses = props.contextFilters?.contractAddresses;
|
||||
const walletAddresses = props.contextFilters?.walletAddresses;
|
||||
const walletAddress = props.contextFilters?.walletAddress;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="max-w-full gap-2">
|
||||
<SlidersHorizontalIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
Context Filters
|
||||
Context
|
||||
<div className="flex gap-1 overflow-hidden">
|
||||
{chainIds && chainIds.length > 0 && (
|
||||
<Badge className="gap-1 truncate" variant="secondary">
|
||||
Chain
|
||||
<span className="max-sm:hidden">{chainIds.join(", ")}</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{contractAddresses && contractAddresses.length > 0 && (
|
||||
<Badge className="gap-1 truncate" variant="secondary">
|
||||
Contract
|
||||
<span className="max-sm:hidden">
|
||||
{contractAddresses
|
||||
.map((add) => `${add.trim().slice(0, 6)}..`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{walletAddresses && walletAddresses.length > 0 && (
|
||||
{walletAddress && (
|
||||
<Badge className="gap-1 truncate" variant="secondary">
|
||||
Wallet
|
||||
<span className="max-sm:hidden">
|
||||
{walletAddresses
|
||||
.map((add) => `${add.trim().slice(0, 6)}..`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -84,9 +66,7 @@ export default function ContextFiltersButton(props: {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-1">
|
||||
<DialogTitle className="font-semibold text-xl">
|
||||
Context Filters
|
||||
</DialogTitle>
|
||||
<DialogTitle className="font-semibold text-xl">Context</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Provide context to Nebula for your prompts
|
||||
</DialogDescription>
|
||||
@@ -105,17 +85,21 @@ export default function ContextFiltersButton(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const commaSeparateListOfAddresses = z.string().refine(
|
||||
(s) => {
|
||||
if (s.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
return s.split(",").every((s) => isAddress(s.trim()));
|
||||
},
|
||||
{
|
||||
message: "Must be a comma-separated list of valid addresses",
|
||||
},
|
||||
);
|
||||
const optionalAddressSchema = z
|
||||
.string()
|
||||
.transform((v) => v.trim())
|
||||
.optional()
|
||||
.refine(
|
||||
(s) => {
|
||||
if (!s) {
|
||||
return true;
|
||||
}
|
||||
return isAddress(s);
|
||||
},
|
||||
{
|
||||
message: "Must be a valid addresses",
|
||||
},
|
||||
);
|
||||
|
||||
const formSchema = z.object({
|
||||
chainIds: z.string().refine(
|
||||
@@ -132,14 +116,13 @@ const formSchema = z.object({
|
||||
message: "Chain IDs must be a comma-separated list of integers",
|
||||
},
|
||||
),
|
||||
contractAddresses: commaSeparateListOfAddresses,
|
||||
walletAddresses: commaSeparateListOfAddresses,
|
||||
walletAddress: optionalAddressSchema,
|
||||
});
|
||||
|
||||
export function ContextFiltersForm(props: {
|
||||
contextFilters: ContextFilters | undefined;
|
||||
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
|
||||
setContextFilters: (filters: ContextFilters | undefined) => void;
|
||||
contextFilters: NebulaContext | undefined;
|
||||
updateContextFilters: (filters: NebulaContext | undefined) => Promise<void>;
|
||||
setContextFilters: (filters: NebulaContext | undefined) => void;
|
||||
formBodyClassName?: string;
|
||||
formActionContainerClassName?: string;
|
||||
modal:
|
||||
@@ -158,40 +141,26 @@ export function ContextFiltersForm(props: {
|
||||
chainIds: props.contextFilters?.chainIds
|
||||
? props.contextFilters.chainIds.join(",")
|
||||
: "",
|
||||
contractAddresses: props.contextFilters?.contractAddresses
|
||||
? props.contextFilters.contractAddresses.join(",")
|
||||
: "",
|
||||
walletAddresses: props.contextFilters?.walletAddresses
|
||||
? props.contextFilters.walletAddresses.join(",")
|
||||
: "",
|
||||
walletAddress: props.contextFilters?.walletAddress || "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { chainIds, contractAddresses, walletAddresses } = values;
|
||||
const { chainIds, walletAddress } = values;
|
||||
|
||||
const chainIdsArray = chainIds.split(",").filter((id) => id.trim());
|
||||
|
||||
const contractAddressesArray = contractAddresses
|
||||
.split(",")
|
||||
.filter((v) => v.trim());
|
||||
|
||||
const walletAddressesArray = walletAddresses
|
||||
.split(",")
|
||||
.filter((v) => v.trim());
|
||||
|
||||
const data = {
|
||||
chainIds: chainIdsArray,
|
||||
contractAddresses: contractAddressesArray,
|
||||
walletAddresses: walletAddressesArray,
|
||||
walletAddress: walletAddress || null,
|
||||
};
|
||||
|
||||
const promise = updateMutation.mutateAsync(data);
|
||||
|
||||
toast.promise(promise, {
|
||||
success: "Context filters updated",
|
||||
error: "Failed to update context filters",
|
||||
success: "Context updated",
|
||||
error: "Failed to update context",
|
||||
});
|
||||
|
||||
promise.then(() => {
|
||||
@@ -228,7 +197,6 @@ export function ContextFiltersForm(props: {
|
||||
.filter(Boolean)
|
||||
.map(Number)}
|
||||
onChange={(values) => {
|
||||
console.log("values", values);
|
||||
form.setValue("chainIds", values.join(","));
|
||||
}}
|
||||
/>
|
||||
@@ -240,33 +208,15 @@ export function ContextFiltersForm(props: {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contractAddresses"
|
||||
name="walletAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contract Addresses</FormLabel>
|
||||
<FormLabel>Wallet Address</FormLabel>
|
||||
<FormControl>
|
||||
<AutoResizeTextarea
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="0x123..., 0x456..."
|
||||
className="!leading-relaxed min-h-[52px] resize-none md:text-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="walletAddresses"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Wallet Addresses</FormLabel>
|
||||
<FormControl>
|
||||
<AutoResizeTextarea
|
||||
{...field}
|
||||
placeholder="0x123..., 0x456..."
|
||||
className="!leading-relaxed min-h-[52px] resize-none md:text-xs"
|
||||
placeholder="0x123.."
|
||||
className="md:text-xs md:placeholder:text-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -61,7 +61,7 @@ export function NebulaAccountButton(props: {
|
||||
<div className="shrink-0">
|
||||
<GradientAvatar
|
||||
id={props.account?.id || "default"}
|
||||
src={""}
|
||||
src={props.account?.image || ""}
|
||||
className="size-9 rounded-lg"
|
||||
client={client}
|
||||
/>
|
||||
@@ -88,7 +88,7 @@ export function NebulaAccountButton(props: {
|
||||
<div className="shrink-0">
|
||||
<GradientAvatar
|
||||
id={props.account?.id || "default"}
|
||||
src={""}
|
||||
src={props.account?.image || ""}
|
||||
className="size-9 rounded-full"
|
||||
client={client}
|
||||
/>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import {
|
||||
ThirdwebProvider,
|
||||
useActiveAccount,
|
||||
@@ -31,6 +32,7 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
enableSystem={false}
|
||||
defaultTheme="dark"
|
||||
>
|
||||
<ToasterSetup />
|
||||
<SanctionedAddressesChecker>
|
||||
{props.children}
|
||||
</SanctionedAddressesChecker>
|
||||
@@ -40,6 +42,11 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ToasterSetup() {
|
||||
const { theme } = useTheme();
|
||||
return <Toaster richColors theme={theme === "light" ? "light" : "dark"} />;
|
||||
}
|
||||
|
||||
function SyncChainDefinitionsToConnectionManager() {
|
||||
const { allChainsV5 } = useAllChainsData();
|
||||
const connectionManager = useConnectionManager();
|
||||
|
||||
+49
-20
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { FormItem } from "@/components/ui/form";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { engineKeys } from "@3rdweb-sdk/react";
|
||||
import {
|
||||
type BackendWallet,
|
||||
useEngineBackendWalletBalance,
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
type UseDisclosureReturn,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { type ColumnDef, createColumnHelper } from "@tanstack/react-table";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { TWTable } from "components/shared/TWTable";
|
||||
@@ -40,18 +42,21 @@ import { EngineBackendWalletOptions } from "lib/engine";
|
||||
import { useV5DashboardChain } from "lib/v5-adapter";
|
||||
import {
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
PencilIcon,
|
||||
RefreshCcwIcon,
|
||||
TrashIcon,
|
||||
TriangleAlertIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import QRCode from "qrcode";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { getAddress } from "thirdweb";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { FormHelperText, FormLabel, LinkButton, Text } from "tw-components";
|
||||
import { FormHelperText, FormLabel, Text } from "tw-components";
|
||||
import { prettyPrintCurrency } from "./utils";
|
||||
|
||||
interface BackendWalletsTableProps {
|
||||
@@ -83,16 +88,17 @@ const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
|
||||
authToken,
|
||||
chainId,
|
||||
}) => {
|
||||
const { data: backendWalletBalance } = useEngineBackendWalletBalance({
|
||||
instanceUrl: instanceUrl,
|
||||
address,
|
||||
authToken,
|
||||
chainId,
|
||||
});
|
||||
const { data: backendWalletBalance, isFetching } =
|
||||
useEngineBackendWalletBalance({
|
||||
instanceUrl: instanceUrl,
|
||||
address,
|
||||
authToken,
|
||||
chainId,
|
||||
});
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
|
||||
if (!backendWalletBalance) {
|
||||
return <Skeleton className="h-5 w-32 rounded-lg" />;
|
||||
if (!backendWalletBalance || isFetching) {
|
||||
return <Skeleton className="h-6 w-36 rounded-lg" />;
|
||||
}
|
||||
|
||||
const balanceDisplay = prettyPrintCurrency({
|
||||
@@ -100,9 +106,7 @@ const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
|
||||
symbol: backendWalletBalance.symbol || chain.nativeCurrency?.symbol,
|
||||
});
|
||||
|
||||
const balanceComponent = (
|
||||
<div className="text-muted-foreground">{balanceDisplay}</div>
|
||||
);
|
||||
const balanceComponent = <span>{balanceDisplay}</span>;
|
||||
|
||||
const explorer = chain.blockExplorers?.[0];
|
||||
if (!explorer) {
|
||||
@@ -110,14 +114,14 @@ const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant="ghost"
|
||||
isExternal
|
||||
size="xs"
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`${explorer.url}/address/${address}`}
|
||||
className="inline-flex items-center gap-2.5 rounded-lg px-2 py-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{balanceComponent}
|
||||
</LinkButton>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -133,6 +137,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
|
||||
const receiveDisclosure = useDisclosure();
|
||||
const sendDisclosure = useDisclosure();
|
||||
const deleteDisclosure = useDisclosure();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
@@ -160,7 +165,31 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("address", {
|
||||
header: "Balance",
|
||||
header: () => (
|
||||
<div className="flex w-[180px] items-center gap-1.5">
|
||||
Balance
|
||||
<ToolTipLabel
|
||||
label="Refresh Balance"
|
||||
contentClassName="capitalize font-normal tracking-normal leading-normal"
|
||||
>
|
||||
<Button
|
||||
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async (e) => {
|
||||
const buttonEl = e.currentTarget;
|
||||
buttonEl.setAttribute("data-pending", "true");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.backendWalletBalanceAll(),
|
||||
});
|
||||
buttonEl.setAttribute("data-pending", "false");
|
||||
}}
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
),
|
||||
cell: (cell) => {
|
||||
const address = cell.getValue();
|
||||
return (
|
||||
@@ -175,7 +204,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
|
||||
id: "balance",
|
||||
}),
|
||||
];
|
||||
}, [instanceUrl, authToken, chainId]);
|
||||
}, [instanceUrl, authToken, chainId, queryClient]);
|
||||
|
||||
const [selectedBackendWallet, setSelectedBackendWallet] =
|
||||
useState<BackendWallet>();
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import { getTeamSettingsLinks } from "./getTeamSettingsLinks";
|
||||
|
||||
export function TeamSettingsSidebar(props: {
|
||||
team: Team;
|
||||
account: Pick<Account, "id"> | undefined;
|
||||
account: Pick<Account, "id" | "image"> | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const teamLinks = getTeamSettingsLinks(props.team.slug);
|
||||
@@ -34,7 +34,7 @@ export function TeamSettingsSidebar(props: {
|
||||
team={props.team}
|
||||
titleAvatarIcon={{
|
||||
id: props.account?.id,
|
||||
src: "", // TODO - set account image
|
||||
src: props.account?.image || "",
|
||||
}}
|
||||
client={props.client}
|
||||
/>
|
||||
@@ -60,7 +60,7 @@ function RenderLinkGroup(props: {
|
||||
<div className="flex items-center gap-1.5 px-4">
|
||||
<GradientAvatar
|
||||
src={props.titleAvatarIcon.src}
|
||||
className="size-4"
|
||||
className="size-[18px]"
|
||||
id={props.titleAvatarIcon.id}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
+4
-6
@@ -1,24 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { TeamGeneralSettingsPageUI } from "./TeamGeneralSettingsPageUI";
|
||||
import { updateTeam } from "./updateTeam";
|
||||
|
||||
export function TeamGeneralSettingsPage(props: {
|
||||
team: Team;
|
||||
authToken: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const client = useThirdwebClient();
|
||||
|
||||
return (
|
||||
<TeamGeneralSettingsPageUI
|
||||
team={props.team}
|
||||
client={client}
|
||||
client={props.client}
|
||||
updateTeamField={async (teamValue) => {
|
||||
await updateTeam({
|
||||
teamId: props.team.id,
|
||||
@@ -38,7 +36,7 @@ export function TeamGeneralSettingsPage(props: {
|
||||
if (file) {
|
||||
// upload to IPFS
|
||||
uri = await upload({
|
||||
client: getThirdwebClient(props.authToken),
|
||||
client: props.client,
|
||||
files: [file],
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ function TeamAvatarFormControl(props: {
|
||||
disabled: false,
|
||||
isPending: updateTeamAvatarMutation.isPending,
|
||||
}}
|
||||
noPermissionText={undefined} // TODO
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<div className="flex flex-row gap-4 md:justify-between">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { TeamGeneralSettingsPage } from "./general/TeamGeneralSettingsPage";
|
||||
@@ -14,5 +15,7 @@ export default async function Page(props: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <TeamGeneralSettingsPage team={team} authToken={token} />;
|
||||
return (
|
||||
<TeamGeneralSettingsPage team={team} client={getThirdwebClient(token)} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function TeamSelectionUI(props: {
|
||||
currentTeam: Team | undefined;
|
||||
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
|
||||
upgradeTeamLink: string | undefined;
|
||||
account: Pick<Account, "email" | "id"> | undefined;
|
||||
account: Pick<Account, "email" | "id" | "image"> | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { setHoveredTeam, currentTeam, teamsAndProjects } = props;
|
||||
@@ -52,9 +52,8 @@ export function TeamSelectionUI(props: {
|
||||
asChild
|
||||
>
|
||||
<Link href="/account">
|
||||
{/* TODO set Image src */}
|
||||
<GradientAvatar
|
||||
src={""}
|
||||
src={props.account?.image || ""}
|
||||
className="size-4"
|
||||
id={props.account?.id}
|
||||
client={props.client}
|
||||
|
||||
@@ -417,14 +417,9 @@ function AuthEndpointFields(props: {
|
||||
canEditAdvancedFeatures: boolean;
|
||||
}) {
|
||||
const { form, canEditAdvancedFeatures } = props;
|
||||
const customHeaderFields = useFieldArray({
|
||||
control: form.control,
|
||||
name: "customAuthEndpoint.customHeaders",
|
||||
});
|
||||
|
||||
const expandCustomAuthEndpointField =
|
||||
form.watch("customAuthEndpoint")?.authEndpoint !== undefined &&
|
||||
canEditAdvancedFeatures;
|
||||
form.watch("customAuthEndpoint") !== undefined && canEditAdvancedFeatures;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -464,88 +459,107 @@ function AuthEndpointFields(props: {
|
||||
}}
|
||||
/>
|
||||
</SwitchContainer>
|
||||
|
||||
<AdvancedConfigurationContainer
|
||||
show={expandCustomAuthEndpointField}
|
||||
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customAuthEndpoint.authEndpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://example.com/your-auth-verifier"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the URL of your server where we will send the user payload
|
||||
for verification
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
{/* useFieldArray used on this component - it creates empty customAuthEndpoint.customHeaders array on mount */}
|
||||
{/* So only mount if expandCustomAuthEndpointField is true */}
|
||||
{expandCustomAuthEndpointField && (
|
||||
<AuthEndpointFieldsContent
|
||||
form={form}
|
||||
canEditAdvancedFeatures={canEditAdvancedFeatures}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 inline-block">Custom Headers</Label>
|
||||
<div className="flex flex-col gap-4">
|
||||
{customHeaderFields.fields.map((field, customHeaderIdx) => {
|
||||
return (
|
||||
<div className="flex gap-4" key={field.id}>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
type="text"
|
||||
{...form.register(
|
||||
`customAuthEndpoint.customHeaders.${customHeaderIdx}.key`,
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value"
|
||||
type="text"
|
||||
{...form.register(
|
||||
`customAuthEndpoint.customHeaders.${customHeaderIdx}.value`,
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Remove header"
|
||||
onClick={() => {
|
||||
customHeaderFields.remove(customHeaderIdx);
|
||||
}}
|
||||
className="!w-auto px-3"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
function AuthEndpointFieldsContent(props: {
|
||||
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
|
||||
canEditAdvancedFeatures: boolean;
|
||||
}) {
|
||||
const { form } = props;
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-background"
|
||||
onClick={() => {
|
||||
customHeaderFields.append({
|
||||
key: "",
|
||||
value: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
const customHeaderFields = useFieldArray({
|
||||
control: form.control,
|
||||
name: "customAuthEndpoint.customHeaders",
|
||||
});
|
||||
|
||||
<p className="mt-3 text-muted-foreground text-sm">
|
||||
Set custom headers to be sent along the request with the payload to
|
||||
the authentication endpoint above. This can be used to verify the
|
||||
incoming requests
|
||||
</p>
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customAuthEndpoint.authEndpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://example.com/your-auth-verifier"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the URL of your server where we will send the user payload
|
||||
for verification
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 inline-block">Custom Headers</Label>
|
||||
<div className="flex flex-col gap-4">
|
||||
{customHeaderFields.fields.map((field, customHeaderIdx) => {
|
||||
return (
|
||||
<div className="flex gap-4" key={field.id}>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
type="text"
|
||||
{...form.register(
|
||||
`customAuthEndpoint.customHeaders.${customHeaderIdx}.key`,
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value"
|
||||
type="text"
|
||||
{...form.register(
|
||||
`customAuthEndpoint.customHeaders.${customHeaderIdx}.value`,
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Remove header"
|
||||
onClick={() => {
|
||||
customHeaderFields.remove(customHeaderIdx);
|
||||
}}
|
||||
className="!w-auto px-3"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-background"
|
||||
onClick={() => {
|
||||
customHeaderFields.append({
|
||||
key: "",
|
||||
value: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
</AdvancedConfigurationContainer>
|
||||
|
||||
<p className="mt-3 text-muted-foreground text-sm">
|
||||
Set custom headers to be sent along the request with the payload to
|
||||
the authentication endpoint above. This can be used to verify the
|
||||
incoming requests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
GridItem,
|
||||
SimpleGrid,
|
||||
useBreakpointValue,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChakraNextImage } from "components/Image";
|
||||
import type { StaticImageData } from "next/image";
|
||||
import { Heading, Text } from "tw-components";
|
||||
|
||||
interface LandingShowcaseImageProps {
|
||||
miniTitle?: string;
|
||||
title: string;
|
||||
titleWithGradient?: string;
|
||||
gradient?: string;
|
||||
description: string;
|
||||
image: StaticImageData;
|
||||
imagePosition?: "left" | "right";
|
||||
}
|
||||
|
||||
export const LandingShowcaseImage: React.FC<LandingShowcaseImageProps> = ({
|
||||
miniTitle,
|
||||
title,
|
||||
titleWithGradient,
|
||||
gradient,
|
||||
description,
|
||||
image,
|
||||
imagePosition = "right",
|
||||
}) => {
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
return (
|
||||
<SimpleGrid gap={8} columns={{ base: 1, md: 2 }}>
|
||||
<GridItem order={imagePosition === "left" && !isMobile ? 2 : 1}>
|
||||
<Flex flexDir="column" justifyContent="center">
|
||||
{miniTitle && (
|
||||
<Text
|
||||
size="body.lg"
|
||||
as="span"
|
||||
textTransform="uppercase"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{miniTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Heading pb={4} size="display.sm">
|
||||
<Box as="span" bgGradient={gradient} bgClip="text">
|
||||
{titleWithGradient}
|
||||
</Box>
|
||||
{title}
|
||||
</Heading>
|
||||
{description && <Text size="body.xl">{description}</Text>}
|
||||
</Flex>
|
||||
</GridItem>
|
||||
<GridItem order={imagePosition === "left" && !isMobile ? 1 : 2}>
|
||||
<ChakraNextImage src={image} alt="" borderRadius="xl" />
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -13,15 +13,14 @@ export function NotFoundPage() {
|
||||
|
||||
<p className="text-center font-bold text-3xl tracking-tighter md:text-5xl">
|
||||
<span className="block"> Uh oh. </span>
|
||||
<span className="block">Looks like web3</span>
|
||||
<span className="block">can't be found here.</span>
|
||||
<span className="block">Looks like you're lost!</span>
|
||||
</p>
|
||||
|
||||
<div className="h-12" />
|
||||
|
||||
<div>
|
||||
<p className="text-center text-muted-foreground text-xl leading-7">
|
||||
Try our{" "}
|
||||
Find live content on our{" "}
|
||||
<TrackedLinkTW
|
||||
category="page-not-found"
|
||||
label="homepage"
|
||||
@@ -47,8 +46,8 @@ export function NotFoundPage() {
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
developer portal
|
||||
</TrackedLinkTW>{" "}
|
||||
instead
|
||||
</TrackedLinkTW>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [[`f77165e`](https://github.com/thirdweb-dev/js/commit/f77165e2d1dd13a1887604c3431bd49b9bd67f28)]:
|
||||
- thirdweb@5.87.4
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#6137](https://github.com/thirdweb-dev/js/pull/6137) [`a6b7e8d`](https://github.com/thirdweb-dev/js/commit/a6b7e8d81868b5f32f1c8b7ff093bb1f06c734ca) Thanks [@jnsdls](https://github.com/jnsdls)! - updated dependencies
|
||||
|
||||
- Updated dependencies [[`a6b7e8d`](https://github.com/thirdweb-dev/js/commit/a6b7e8d81868b5f32f1c8b7ff093bb1f06c734ca), [`9d5828e`](https://github.com/thirdweb-dev/js/commit/9d5828eeab201960a720744ca3a59c85a0d8e548), [`b693b78`](https://github.com/thirdweb-dev/js/commit/b693b78645e2b214a5f8be0eec6d335d569ceb8c), [`08cc489`](https://github.com/thirdweb-dev/js/commit/08cc48910df351d068c1ce224d4102f40cb1dce1)]:
|
||||
- thirdweb@5.87.3
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,16 @@
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"next": true,
|
||||
"ignore": ["public/**"],
|
||||
"project": ["src/**"],
|
||||
"ignoreBinaries": ["only-allow", "biome"],
|
||||
"ignoreDependencies": ["thirdweb", "eslint-config-next"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*", // Set your origin
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET, POST, PUT, DELETE, OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "thirdweb-login",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"format": "biome format ./src --write",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"thirdweb": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/node": "22.13.0",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"knip": "5.43.6",
|
||||
"postcss": "8.5.1",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg width="903" height="549" viewBox="0 0 903 549" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M475.061 0.925537C480.587 2.07406 485.804 4.39614 490.358 7.73528C495.457 11.7723 499.362 17.1238 501.656 23.2159C516.137 59.447 530.618 95.6561 545.099 131.843C561.124 172.089 577.229 212.341 593.414 252.601C601.849 273.709 610.241 294.839 618.807 315.903C620.629 320.212 621.568 324.844 621.568 329.523C621.568 334.202 620.629 338.834 618.807 343.142L545.208 526.743C540.335 538.895 531.506 546.449 518.526 548.42C502.377 550.851 487.933 542.88 482.098 528.713C468.287 494.752 454.782 460.682 441.168 426.633L371.94 253.433C351.442 202.036 330.915 150.652 310.359 99.2836C303.542 82.2264 296.942 65.0816 289.818 48.1558C287.856 43.5536 286.929 38.5755 287.1 33.5741C287.272 28.5726 288.539 23.6702 290.812 19.2139C293.084 14.7576 296.307 10.8566 300.251 7.78696C304.196 4.71736 308.766 2.55433 313.637 1.45105C314.227 1.31967 314.796 1.10071 315.364 0.925537H475.061Z" fill="url(#paint0_linear_4033_153464)"/>
|
||||
<path d="M188.239 0.925964C190.052 1.42958 191.866 1.8675 193.636 2.4806C198.245 3.9207 202.472 6.37753 206.008 9.67041C209.544 12.9633 212.298 17.0085 214.068 21.5085C229.875 60.7466 245.609 100.014 261.27 139.31C277.12 178.928 292.97 218.553 308.821 258.185C316.571 277.615 324.358 297.037 332.181 316.451C333.932 320.609 334.834 325.076 334.834 329.589C334.834 334.102 333.932 338.569 332.181 342.727C323.44 364.536 314.699 386.337 305.958 408.131C289.933 448.026 273.908 487.921 257.882 527.816C255.367 534.028 251.065 539.351 245.525 543.109C239.985 546.867 233.455 548.891 226.765 548.924C219.677 549.049 212.723 546.977 206.856 542.991C200.988 539.005 196.493 533.299 193.986 526.656C179.17 489.06 164.048 451.573 149.057 414.043C128.968 363.229 108.776 312.452 88.4822 261.711C67.8243 210.065 47.1738 158.411 26.5305 106.751C18.7073 87.1973 10.9497 67.6001 3.01724 48.0686C-0.448593 39.9305 -0.616668 30.7602 2.54861 22.5001C5.71389 14.2399 11.9639 7.53886 19.9747 3.81627C22.881 2.63941 25.8679 1.67348 28.9124 0.925964L188.239 0.925964Z" fill="url(#paint1_linear_4033_153464)"/>
|
||||
<path d="M902.82 334.858C898.45 349.638 891.894 363.586 886.322 377.841C870.195 418.393 853.98 458.923 837.678 499.431C833.941 508.715 830.139 517.977 826.511 527.305C823.958 533.679 819.554 539.138 813.87 542.976C808.185 546.815 801.482 548.855 794.628 548.834C787.773 548.813 781.083 546.731 775.422 542.857C769.761 538.984 765.391 533.497 762.877 527.108L692.119 350.142L630.932 197.284C610.959 147.375 591 97.4735 571.056 47.5791C569.16 43.017 568.286 38.0935 568.495 33.156C568.704 28.2184 569.992 23.387 572.267 19.0024C574.543 14.6177 577.751 10.7867 581.664 7.77934C585.578 4.77199 590.103 2.66159 594.919 1.59695C595.575 1.44368 596.208 1.22472 596.864 1.02765H756.562C757.064 1.18092 757.545 1.37799 758.048 1.48747C763.649 2.65867 768.864 5.22722 773.211 8.95523C777.558 12.6832 780.896 17.4502 782.916 22.8144C792.21 46.1705 801.52 69.4608 810.843 92.6855C835.245 153.674 859.625 214.662 883.983 275.651C890.386 291.723 897.51 307.51 902.755 324.02L902.82 334.858Z" fill="url(#paint2_linear_4033_153464)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4033_153464" x1="262.328" y1="45.4188" x2="647.023" y2="429.344" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF00A8"/>
|
||||
<stop offset="1" stop-color="#6200C6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_4033_153464" x1="-24.342" y1="45.2878" x2="360.265" y2="429.125" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF00A8"/>
|
||||
<stop offset="1" stop-color="#6200C6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_4033_153464" x1="543.762" y1="45.28" x2="928.392" y2="429.14" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF00A8"/>
|
||||
<stop offset="1" stop-color="#6200C6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,193 @@
|
||||
// biome-ignore lint/complexity/useArrowFunction: This is a self-executing function, we do not want to use an arrow function here.
|
||||
(function () {
|
||||
const globalSetup = getSetup();
|
||||
|
||||
const JWT_KEY = "tw.login:jwt";
|
||||
const CODE_KEY = "tw.login:code";
|
||||
|
||||
// check if redirected first, this sets up the logged in state if it was from redirect
|
||||
const result = parseURL(new URL(window.location));
|
||||
if (
|
||||
result &&
|
||||
result.length === 2 &&
|
||||
result[1] === localStorage.getItem(CODE_KEY)
|
||||
) {
|
||||
// reset the URL
|
||||
window.location.hash = "";
|
||||
window.location.search = "";
|
||||
|
||||
// write the jwt to local storage
|
||||
localStorage.setItem(JWT_KEY, result[0]);
|
||||
}
|
||||
|
||||
// always reset the code
|
||||
localStorage.removeItem(CODE_KEY);
|
||||
|
||||
const jwt = localStorage.getItem(JWT_KEY);
|
||||
|
||||
if (jwt) {
|
||||
// handle logged in state
|
||||
handleIsLoggedIn();
|
||||
} else {
|
||||
// handle not logged in state
|
||||
handleNotLoggedIn();
|
||||
}
|
||||
|
||||
function handleIsLoggedIn() {
|
||||
window.thirdweb = {
|
||||
isLoggedIn: true,
|
||||
getUser: async () => {
|
||||
const res = await fetch(`${globalSetup.baseUrl}/api/user`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem(JWT_KEY)}`,
|
||||
},
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
logout: () => {
|
||||
window.localStorage.removeItem(JWT_KEY);
|
||||
window.location.reload();
|
||||
},
|
||||
};
|
||||
|
||||
renderFloatingBubble(true);
|
||||
}
|
||||
|
||||
function handleNotLoggedIn() {
|
||||
window.thirdweb = { login: onLogin, isLoggedIn: false };
|
||||
renderFloatingBubble(false);
|
||||
}
|
||||
|
||||
function onLogin() {
|
||||
const code = window.crypto.getRandomValues(new Uint8Array(16)).join("");
|
||||
localStorage.setItem(CODE_KEY, code);
|
||||
// redirect to the login page
|
||||
const redirect = new URL(globalSetup.baseUrl);
|
||||
redirect.searchParams.set("code", code);
|
||||
redirect.searchParams.set("clientId", globalSetup.clientId);
|
||||
redirect.searchParams.set(
|
||||
"redirect",
|
||||
window.location.origin + window.location.pathname,
|
||||
);
|
||||
window.location.href = redirect.href;
|
||||
}
|
||||
|
||||
// utils
|
||||
function getSetup() {
|
||||
const el = document.currentScript;
|
||||
if (!el) {
|
||||
throw new Error("Could not find script element");
|
||||
}
|
||||
const baseUrl = new URL(el.src).origin;
|
||||
const dataset = el.dataset;
|
||||
const clientId = dataset.clientId;
|
||||
const theme = dataset.theme || "dark";
|
||||
if (!clientId) {
|
||||
throw new Error("Missing client-id");
|
||||
}
|
||||
return { clientId, baseUrl, theme };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {URL} url
|
||||
* @returns null | [string, string]
|
||||
*/
|
||||
function parseURL(url) {
|
||||
try {
|
||||
const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
||||
const code = url.searchParams.get("code");
|
||||
if (!hash || !code) {
|
||||
return null;
|
||||
}
|
||||
return [hash, code];
|
||||
} catch {
|
||||
// if this fails, invalid data -> return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFloatingBubble(loggedIn) {
|
||||
const el = document.createElement("div");
|
||||
el.id = "tw-floating-bubble";
|
||||
el.style.position = "fixed";
|
||||
el.style.bottom = "24px";
|
||||
el.style.right = "24px";
|
||||
el.style.zIndex = "1000";
|
||||
el.style.width = "138px";
|
||||
el.style.height = "40px";
|
||||
el.style.backgroundColor =
|
||||
globalSetup.theme === "dark" ? "#131418" : "#ffffff";
|
||||
el.style.color = globalSetup.theme === "dark" ? "white" : "black";
|
||||
el.style.borderRadius = "8px";
|
||||
el.style.placeItems = "center";
|
||||
el.style.fontSize = loggedIn ? "12px" : "12px";
|
||||
el.style.cursor = "pointer";
|
||||
el.style.overflow = "hidden";
|
||||
el.style.boxShadow = "1px 1px 10px rgba(0, 0, 0, 0.5)";
|
||||
el.style.display = "flex";
|
||||
el.style.alignItems = "center";
|
||||
el.style.justifyContent = "space-around";
|
||||
el.style.fontFamily = "sans-serif";
|
||||
el.style.gap = "8px";
|
||||
el.style.padding = "0px 8px";
|
||||
el.onclick = () => {
|
||||
if (loggedIn) {
|
||||
window.thirdweb.logout();
|
||||
} else {
|
||||
window.thirdweb.login();
|
||||
}
|
||||
};
|
||||
el.innerHTML = loggedIn ? await renderBlobbie() : renderThirdwebLogo();
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
function renderThirdwebLogo() {
|
||||
const el = document.createElement("img");
|
||||
el.src = `${globalSetup.baseUrl}/logo.svg`;
|
||||
el.style.height = "16px";
|
||||
el.style.objectFit = "contain";
|
||||
el.style.flexShrink = "0";
|
||||
el.style.marginLeft = "-4px";
|
||||
return `${el.outerHTML} <span>Login</span><span></span>`;
|
||||
}
|
||||
|
||||
async function renderBlobbie() {
|
||||
const address = (await window.thirdweb.getUser()).address;
|
||||
|
||||
function hexToNumber(hex) {
|
||||
if (typeof hex !== "string")
|
||||
throw new Error(`hex string expected, got ${typeof hex}`);
|
||||
return hex === "" ? _0n : BigInt(`0x${hex}`);
|
||||
}
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
["#fca5a5", "#b91c1c"],
|
||||
["#fdba74", "#c2410c"],
|
||||
["#fcd34d", "#b45309"],
|
||||
["#fde047", "#a16207"],
|
||||
["#a3e635", "#4d7c0f"],
|
||||
["#86efac", "#15803d"],
|
||||
["#67e8f9", "#0e7490"],
|
||||
["#7dd3fc", "#0369a1"],
|
||||
["#93c5fd", "#1d4ed8"],
|
||||
["#a5b4fc", "#4338ca"],
|
||||
["#c4b5fd", "#6d28d9"],
|
||||
["#d8b4fe", "#7e22ce"],
|
||||
["#f0abfc", "#a21caf"],
|
||||
["#f9a8d4", "#be185d"],
|
||||
["#fda4af", "#be123c"],
|
||||
];
|
||||
const colors =
|
||||
COLOR_OPTIONS[
|
||||
Number(hexToNumber(address.slice(2, 4))) % COLOR_OPTIONS.length
|
||||
];
|
||||
const el = document.createElement("div");
|
||||
el.style.backgroundImage = `radial-gradient(ellipse at left bottom, ${colors[0]}, ${colors[1]})`;
|
||||
el.style.width = "24px";
|
||||
el.style.height = "24px";
|
||||
el.style.borderRadius = "50%";
|
||||
el.style.flexShrink = "0";
|
||||
|
||||
return `${el.outerHTML}<span>${address.slice(0, 6)}...${address.slice(-4)}</span><span></span>`;
|
||||
}
|
||||
})();
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(){let e=function e(){let t=document.currentScript;if(!t)throw Error("Could not find script element");let a=new URL(t.src).origin,n=t.dataset,l=n.clientId,s=n.theme||"dark";if(!l)throw Error("Missing client-id");return{clientId:l,baseUrl:a,theme:s}}(),t="tw.login:jwt",a="tw.login:code",n=function e(t){try{let a=t.hash.startsWith("#")?t.hash.slice(1):t.hash,n=t.searchParams.get("code");if(!a||!n)return null;return[a,n]}catch{return null}}(new URL(window.location));n&&2===n.length&&n[1]===localStorage.getItem(a)&&(window.location.hash="",window.location.search="",localStorage.setItem(t,n[0])),localStorage.removeItem(a);let l=localStorage.getItem(t);function s(){let t=window.crypto.getRandomValues(new Uint8Array(16)).join("");localStorage.setItem(a,t);let n=new URL(e.baseUrl);n.searchParams.set("code",t),n.searchParams.set("clientId",e.clientId),n.searchParams.set("redirect",window.location.origin+window.location.pathname),window.location.href=n.href}async function i(t){let a=document.createElement("div");a.id="tw-floating-bubble",a.style.position="fixed",a.style.bottom="24px",a.style.right="24px",a.style.zIndex="1000",a.style.width="138px",a.style.height="40px",a.style.backgroundColor="dark"===e.theme?"#131418":"#ffffff",a.style.color="dark"===e.theme?"white":"black",a.style.borderRadius="8px",a.style.placeItems="center",a.style.fontSize="12px",a.style.cursor="pointer",a.style.overflow="hidden",a.style.boxShadow="1px 1px 10px rgba(0, 0, 0, 0.5)",a.style.display="flex",a.style.alignItems="center",a.style.justifyContent="space-around",a.style.fontFamily="sans-serif",a.style.gap="8px",a.style.padding="0px 8px",a.onclick=()=>{t?window.thirdweb.logout():window.thirdweb.login()},a.innerHTML=t?await r():function t(){let a=document.createElement("img");return a.src=`${e.baseUrl}/logo.svg`,a.style.height="16px",a.style.objectFit="contain",a.style.flexShrink="0",a.style.marginLeft="-4px",`${a.outerHTML} <span>Login</span><span></span>`}(),document.body.appendChild(a)}async function r(){let e=(await window.thirdweb.getUser()).address,t=[["#fca5a5","#b91c1c"],["#fdba74","#c2410c"],["#fcd34d","#b45309"],["#fde047","#a16207"],["#a3e635","#4d7c0f"],["#86efac","#15803d"],["#67e8f9","#0e7490"],["#7dd3fc","#0369a1"],["#93c5fd","#1d4ed8"],["#a5b4fc","#4338ca"],["#c4b5fd","#6d28d9"],["#d8b4fe","#7e22ce"],["#f0abfc","#a21caf"],["#f9a8d4","#be185d"],["#fda4af","#be123c"],],a=t[Number(function e(t){if("string"!=typeof t)throw Error(`hex string expected, got ${typeof t}`);return""===t?_0n:BigInt(`0x${t}`)}(e.slice(2,4)))%t.length],n=document.createElement("div");return n.style.backgroundImage=`radial-gradient(ellipse at left bottom, ${a[0]}, ${a[1]})`,n.style.width="24px",n.style.height="24px",n.style.borderRadius="50%",n.style.flexShrink="0",`${n.outerHTML}<span>${e.slice(0,6)}...${e.slice(-4)}</span><span></span>`}l?(window.thirdweb={isLoggedIn:!0,async getUser(){let a=await fetch(`${e.baseUrl}/api/user`,{headers:{Authorization:`Bearer ${localStorage.getItem(t)}`}});return a.json()},logout(){window.localStorage.removeItem(t),window.location.reload()}},i(!0)):(window.thirdweb={login:s,isLoggedIn:!1},i(!1))}();
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { verifyJWT } from "../../authorization/jwt";
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const jwt = req.headers.get("Authorization")?.split("Bearer ")[1];
|
||||
if (!jwt) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "No JWT provided",
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedPayload = await verifyJWT(jwt);
|
||||
return NextResponse.json({
|
||||
address: verifiedPayload.sub,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("failed", e);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Invalid JWT",
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
"server only";
|
||||
|
||||
import "server-only";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { verifyEOASignature } from "thirdweb/auth";
|
||||
import { decodeJWT, encodeJWT, stringify } from "thirdweb/utils";
|
||||
import { privateKeyToAccount, randomPrivateKey } from "thirdweb/wallets";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "e9ba48c289e0cc3d06a23bfd370cc111",
|
||||
});
|
||||
const privateKey = process.env.THIRDWEB_ADMIN_PRIVATE_KEY || randomPrivateKey();
|
||||
|
||||
if (!privateKey) {
|
||||
throw new Error("Missing THIRDWEB_ADMIN_PRIVATE_KEY");
|
||||
}
|
||||
|
||||
const serverAccount = privateKeyToAccount({
|
||||
client,
|
||||
privateKey: privateKey,
|
||||
});
|
||||
|
||||
export async function signJWT(data: {
|
||||
address: string;
|
||||
sessionKeySignerAddress: string;
|
||||
code: string;
|
||||
}) {
|
||||
"use server";
|
||||
return await encodeJWT({
|
||||
account: serverAccount,
|
||||
payload: {
|
||||
iss: serverAccount.address,
|
||||
sub: data.address,
|
||||
aud: data.sessionKeySignerAddress,
|
||||
exp: new Date(Date.now() + 1000 * 60 * 60),
|
||||
nbf: new Date(),
|
||||
iat: new Date(),
|
||||
jti: data.code,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyJWT(jwt: string) {
|
||||
const { payload, signature } = decodeJWT(jwt);
|
||||
|
||||
console.log("payload", payload);
|
||||
console.log("signature", signature);
|
||||
// verify the signature
|
||||
const verified = await verifyEOASignature({
|
||||
message: stringify(payload),
|
||||
signature,
|
||||
address: serverAccount.address,
|
||||
});
|
||||
if (!verified) {
|
||||
throw new Error("Invalid JWT signature");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { createThirdwebClient, getContract } from "thirdweb";
|
||||
import { baseSepolia } from "thirdweb/chains";
|
||||
import { addSessionKey, isActiveSigner } from "thirdweb/extensions/erc4337";
|
||||
import {
|
||||
AccountAddress,
|
||||
AccountBlobbie,
|
||||
AccountProvider,
|
||||
ConnectEmbed,
|
||||
TransactionButton,
|
||||
useActiveAccount,
|
||||
} from "thirdweb/react";
|
||||
import { isContractDeployed, shortenAddress } from "thirdweb/utils";
|
||||
|
||||
const sessionKeySignerAddress = "0x6f700ba0258886411D2536399624EAa7158d1742";
|
||||
|
||||
export function LoginPageInner({
|
||||
generateJWT,
|
||||
}: {
|
||||
generateJWT: (opts: {
|
||||
address: string;
|
||||
sessionKeySignerAddress: string;
|
||||
code: string;
|
||||
}) => Promise<string>;
|
||||
}) {
|
||||
const account = useActiveAccount();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const code = searchParams.get("code");
|
||||
// const clientId = searchParams.get("clientId");
|
||||
const redirect = searchParams.get("redirect");
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "e9ba48c289e0cc3d06a23bfd370cc111",
|
||||
});
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
{account ? (
|
||||
<div
|
||||
className="flex flex-col gap-4 rounded-lg border p-12"
|
||||
style={{ borderColor: "rgba(255, 255, 255, 0.1)" }}
|
||||
>
|
||||
<AccountProvider address={account.address} client={client}>
|
||||
<div
|
||||
className="flex flex-row items-center gap-2 rounded-md border p-4"
|
||||
style={{ borderColor: "rgba(255, 255, 255, 0.1)" }}
|
||||
>
|
||||
<AccountBlobbie className="h-12 w-12 rounded-full" />
|
||||
<div className="flex flex-col gap-0">
|
||||
<AccountAddress formatFn={shortenAddress} />
|
||||
</div>
|
||||
</div>
|
||||
</AccountProvider>
|
||||
<hr className="opacity-10" />
|
||||
<div className="flex flex-col gap-0">
|
||||
<h1 className="font-bold text-2xl">Grant Permissions</h1>
|
||||
<p className="opacity-70">
|
||||
<strong>App.xyz</strong> is asking you to grant it the following
|
||||
permissions:
|
||||
</p>
|
||||
</div>
|
||||
<hr className="opacity-10" />
|
||||
<ul>
|
||||
<li>
|
||||
<span className="mr-4">✅</span>Read your wallet address.
|
||||
</li>
|
||||
</ul>
|
||||
<hr className="opacity-10" />
|
||||
<ul>
|
||||
<li>
|
||||
<span className="mr-4">⚠️</span>Interact with <strong>all</strong>{" "}
|
||||
contracts on <strong>Base Sepolia</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span className="mr-4">⚠️</span>Spend <strong>all</strong> your
|
||||
funds on <strong>Base Sepolia</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span className="mr-4">📆</span>Granted until{" "}
|
||||
<strong>02/02/2025</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<hr className="opacity-10" />
|
||||
<div className="flex flex-row gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-[50%] rounded-lg border p-2 hover:bg-gray-950"
|
||||
style={{ borderColor: "rgba(255, 255, 255, 0.1)" }}
|
||||
onClick={() => {
|
||||
window.location.href = redirect || "";
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<TransactionButton
|
||||
className="!w-[50%]"
|
||||
transaction={async () => {
|
||||
if (!account) {
|
||||
throw new Error("No account found");
|
||||
}
|
||||
if (!code) {
|
||||
throw new Error("No code found");
|
||||
}
|
||||
if (!redirect) {
|
||||
throw new Error("No redirect found");
|
||||
}
|
||||
const accountContract = getContract({
|
||||
address: account.address,
|
||||
// hard coded for now
|
||||
chain: baseSepolia,
|
||||
client,
|
||||
});
|
||||
let hasSessionKey = false;
|
||||
// check if already added
|
||||
const accountDeployed =
|
||||
await isContractDeployed(accountContract);
|
||||
if (accountDeployed) {
|
||||
hasSessionKey = await isActiveSigner({
|
||||
contract: accountContract,
|
||||
signer: sessionKeySignerAddress,
|
||||
});
|
||||
}
|
||||
// if not added, send tx to add the session key
|
||||
if (!hasSessionKey) {
|
||||
return addSessionKey({
|
||||
account,
|
||||
contract: accountContract,
|
||||
sessionKeyAddress: sessionKeySignerAddress,
|
||||
// hard coded for now
|
||||
permissions: { approvedTargets: "*" },
|
||||
});
|
||||
}
|
||||
throw "already-added";
|
||||
}}
|
||||
onError={async (e) => {
|
||||
if (!code) {
|
||||
throw new Error("No code found");
|
||||
}
|
||||
if (!redirect) {
|
||||
throw new Error("No redirect found");
|
||||
}
|
||||
if (typeof e === "string" && e === "already-added") {
|
||||
// sign jwt
|
||||
const jwt = await generateJWT({
|
||||
address: account.address,
|
||||
sessionKeySignerAddress,
|
||||
code,
|
||||
});
|
||||
// redirect
|
||||
window.location.href = `${redirect}?code=${code}#${jwt}`;
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
}}
|
||||
onTransactionConfirmed={async () => {
|
||||
if (!code) {
|
||||
throw new Error("No code found");
|
||||
}
|
||||
if (!redirect) {
|
||||
throw new Error("No redirect found");
|
||||
}
|
||||
// sign jwt
|
||||
const jwt = await generateJWT({
|
||||
address: account.address,
|
||||
sessionKeySignerAddress,
|
||||
code,
|
||||
});
|
||||
// redirect
|
||||
window.location.href = `${redirect}?code=${code}#${jwt}`;
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ConnectEmbed
|
||||
client={client}
|
||||
accountAbstraction={{ chain: baseSepolia, sponsorGas: true }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,163 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
/* scroll-behavior: smooth; */
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
font-feature-settings: "rlig" 1, "calt" 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variation-settings: normal;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--sticky-top-height: 70px;
|
||||
}
|
||||
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
/* bg - neutral */
|
||||
--background: 0 0% 0%;
|
||||
--card: 0 0% 3.92%;
|
||||
--popover: 0 0% 0%;
|
||||
--secondary: 0 0% 11%;
|
||||
--muted: 0 0% 11%;
|
||||
--accent: 0 0% 11%;
|
||||
--inverted: 0 0% 100%;
|
||||
|
||||
/* bg - colorful */
|
||||
--primary: 221 83% 54%;
|
||||
--destructive: 360 72% 51%;
|
||||
|
||||
/* Text */
|
||||
--foreground: 0 0% 98%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted-foreground: 0 0% 63%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--link-foreground: 215.88 100% 65%;
|
||||
--warning-text: 38 92% 50%;
|
||||
--destructive-text: 360 72% 55%;
|
||||
--success-text: 142 75% 50%;
|
||||
--inverted-foreground: 0 0% 0%;
|
||||
|
||||
/* Borders */
|
||||
--border: 0 0% 15%;
|
||||
--active-border: 0 0% 22%;
|
||||
--ring: 0 0% 30%;
|
||||
--input: 0 0% 15%;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
/* bg - neutral */
|
||||
--background: 0 0% 98%;
|
||||
--popover: 0 0% 100%;
|
||||
--card: 0 0% 100%;
|
||||
--secondary: 0 0% 90%;
|
||||
--muted: 0 0% 93%;
|
||||
--accent: 0 0% 93%;
|
||||
--inverted: 0 0 0%;
|
||||
|
||||
/* bg - colorful */
|
||||
--primary: 221 83% 54%;
|
||||
--destructive: 360 72% 51%;
|
||||
|
||||
/* Text */
|
||||
--foreground: 0 0% 4%;
|
||||
--card-foreground: 0 0% 4%;
|
||||
--popover-foreground: 240 10% 4%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary-foreground: 0 0% 4%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--inverted-foreground: 0 0% 100%;
|
||||
--link-foreground: 221.21deg 83.19% 53.33%;
|
||||
--success-text: 142.09 70.56% 35.29%;
|
||||
--warning-text: 38 92% 40%;
|
||||
--destructive-text: 357.15deg 100% 68.72%;
|
||||
|
||||
/* Borders */
|
||||
--border: 0 0% 85%;
|
||||
--active-border: 0 0% 70%;
|
||||
--input: 0 0% 85%;
|
||||
--ring: 0 0% 80%;
|
||||
}
|
||||
}
|
||||
|
||||
/* If no data-theme is added in body, its in dark theme */
|
||||
body:not([data-theme="light"]) .light-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="light"] .dark-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code span {
|
||||
color: var(--code-dark-color);
|
||||
}
|
||||
|
||||
body[data-theme="light"] code span {
|
||||
color: var(--code-light-color);
|
||||
}
|
||||
|
||||
/* @layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
} */
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.styled-scrollbar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||
border-radius: 0.5rem;
|
||||
transition: color 200ms ease;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(var(--foreground));
|
||||
color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fira_Code, Inter } from "next/font/google";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const sansFont = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
weight: "variable",
|
||||
});
|
||||
|
||||
const monoFont = Fira_Code({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
weight: "variable",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "login with thirdweb",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="h-full w-full bg-black text-white">
|
||||
<body
|
||||
className={`${sansFont.variable} ${monoFont.variable} h-full w-full font-sans antialiased`}
|
||||
>
|
||||
<Providers>
|
||||
<Suspense>{children}</Suspense>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { signJWT } from "./authorization/jwt";
|
||||
import { LoginPageInner } from "./components/LoginPageInner";
|
||||
|
||||
export default function Page() {
|
||||
return <LoginPageInner generateJWT={signJWT} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
|
||||
export const Providers = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return <ThirdwebProvider>{children}</ThirdwebProvider>;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,11 @@
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<h1>Test Page</h1>
|
||||
|
||||
<!-- load the script -->
|
||||
<script src="http://localhost:3000/twl.js" data-client-id="testfoo1234"></script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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.3.0",
|
||||
"@abstract-foundation/agw-react": "^1.4.3",
|
||||
"@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",
|
||||
@@ -29,26 +31,30 @@
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "1.1.7",
|
||||
"@tanstack/react-query": "5.65.1",
|
||||
"@tanstack/react-query": "5.66.0",
|
||||
"@thirdweb-dev/engine": "^0.0.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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.10.10",
|
||||
"@types/node": "22.13.0",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"eslint": "8.57.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user