Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
120f965941 | ||
|
|
cf36a33c3f | ||
|
|
2fdb69dd44 | ||
|
|
06c0cf3898 | ||
|
|
47b1bbbd7c | ||
|
|
53e9d8bc1d | ||
|
|
9fadbcc17a | ||
|
|
c04b93e705 | ||
|
|
e6e994479f | ||
|
|
cf61115064 | ||
|
|
60d2190e95 | ||
|
|
3cf0039c58 | ||
|
|
4841ff382a | ||
|
|
d580fe05e3 | ||
|
|
7a1099b601 | ||
|
|
4e5fdcf415 | ||
|
|
eaeb53218c | ||
|
|
09b7404985 | ||
|
|
3c77c6b2c8 | ||
|
|
71aa2578c3 | ||
|
|
c32576fb47 | ||
|
|
d522343e04 | ||
|
|
4f22e2b190 | ||
|
|
319a203a4e | ||
|
|
cdb907f175 | ||
|
|
364d97e97e | ||
|
|
795e9c13a4 | ||
|
|
0b510f881b | ||
|
|
5351d4e407 | ||
|
|
0cafa33a18 | ||
|
|
dd77c7f57c | ||
|
|
9c3ff94f29 | ||
|
|
45fcfb1dae | ||
|
|
788eefd1d5 | ||
|
|
f8c981c0ca | ||
|
|
1628fa2a79 | ||
|
|
0e26a88e7f | ||
|
|
50f98d7486 | ||
|
|
40aeb529ac | ||
|
|
e7bbc321c2 | ||
|
|
8f157aa43f | ||
|
|
a437baa57a | ||
|
|
4331ed1f04 | ||
|
|
5c896b0fd5 | ||
|
|
16aa7b7918 | ||
|
|
341d312679 | ||
|
|
3aff548a10 | ||
|
|
002c545d0e | ||
|
|
bc5b543269 | ||
|
|
1a45f21abb | ||
|
|
d59ab13ce8 |
@@ -29,3 +29,5 @@ packages/*/typedoc/*
|
||||
*storybook.log
|
||||
storybook-static
|
||||
.aider*
|
||||
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -34,6 +34,11 @@ module.exports = {
|
||||
message:
|
||||
"Use useV5DashboardChain instead if you are using it inside a component",
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='resolveScheme']",
|
||||
message:
|
||||
"resolveScheme can throw error if resolution fails. Either catch the error and ignore the lint warning or Use `resolveSchemeWithErrorHandler` / `replaceIpfsUrl` utility in dashboard instead",
|
||||
},
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// add framer paths here
|
||||
module.exports = [
|
||||
"/",
|
||||
"/connect/sign-in",
|
||||
"/contracts/modular-contracts",
|
||||
"/unlimited-wallets",
|
||||
];
|
||||
@@ -1,4 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@@ -77,8 +78,16 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
additionalPaths: async (config) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const FRAMER_PATHS = require("./framer-rewrites");
|
||||
const allChains = await fetchChainsFromApi();
|
||||
return [
|
||||
...FRAMER_PATHS.map((path) => ({
|
||||
loc: path,
|
||||
changefreq: config.changefreq,
|
||||
priority: config.priority,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
})),
|
||||
...allChains.map((chain) => {
|
||||
return {
|
||||
loc: `/${chain.slug}`,
|
||||
|
||||
@@ -6,7 +6,7 @@ const ContentSecurityPolicy = `
|
||||
media-src * data: blob:;
|
||||
object-src 'none';
|
||||
style-src 'self' 'unsafe-inline' vercel.live;
|
||||
font-src 'self' vercel.live assets.vercel.com framerusercontent.com;
|
||||
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
|
||||
frame-src * data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com;
|
||||
connect-src * data: blob:;
|
||||
@@ -38,13 +38,7 @@ const securityHeaders = [
|
||||
];
|
||||
|
||||
const redirects = require("./redirects");
|
||||
|
||||
// add framer paths here
|
||||
const FRAMER_PATHS = [
|
||||
"/connect/sign-in",
|
||||
"/contracts/modular-contracts",
|
||||
"/unlimited-wallets",
|
||||
];
|
||||
const FRAMER_PATHS = require("./framer-rewrites");
|
||||
|
||||
/**
|
||||
* @returns {import('next').RemotePattern[]}
|
||||
@@ -135,6 +129,11 @@ const moduleExports = {
|
||||
source: "/thirdweb.eth/:path*",
|
||||
destination: "/deployer.thirdweb.eth/:path*",
|
||||
},
|
||||
// re-write /home to / (this is so that logged in users will be able to go to /home and NOT be redirected to the logged in app)
|
||||
{
|
||||
source: "/home",
|
||||
destination: "/",
|
||||
},
|
||||
...FRAMER_PATHS.map((path) => ({
|
||||
source: path,
|
||||
destination: `https://landing.thirdweb.com${path}`,
|
||||
@@ -191,7 +190,7 @@ module.exports = withBundleAnalyzer(
|
||||
// An auth token is required for uploading source maps.
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
// Suppresses source map uploading logs during build
|
||||
silent: false,
|
||||
silent: true,
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
|
||||
+18
-18
@@ -46,11 +46,11 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.3",
|
||||
"@sentry/nextjs": "8.34.0",
|
||||
"@shazow/whatsabi": "^0.15.3",
|
||||
"@sentry/nextjs": "8.35.0",
|
||||
"@shazow/whatsabi": "^0.15.4",
|
||||
"@stripe/react-stripe-js": "^2.8.1",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
"@tanstack/react-query": "5.59.13",
|
||||
"@tanstack/react-query": "5.59.16",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@vercel/og": "^0.6.2",
|
||||
@@ -62,13 +62,13 @@
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "11.11.8",
|
||||
"framer-motion": "11.11.9",
|
||||
"fuse.js": "7.0.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"ioredis": "^5.4.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "0.452.0",
|
||||
"lucide-react": "0.453.0",
|
||||
"next": "14.2.15",
|
||||
"next-plausible": "^3.12.0",
|
||||
"next-seo": "^6.5.0",
|
||||
@@ -85,8 +85,8 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-dropzone": "^14.2.9",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-hook-form": "7.52.0",
|
||||
"react-intersection-observer": "^9.10.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-responsive-carousel": "^3.2.23",
|
||||
"react-table": "^7.8.0",
|
||||
@@ -109,18 +109,18 @@
|
||||
"@next/bundle-analyzer": "14.2.15",
|
||||
"@next/eslint-plugin-next": "14.2.15",
|
||||
"@playwright/test": "1.47.2",
|
||||
"@storybook/addon-essentials": "8.3.5",
|
||||
"@storybook/addon-interactions": "8.3.5",
|
||||
"@storybook/addon-links": "8.3.5",
|
||||
"@storybook/addon-onboarding": "8.3.5",
|
||||
"@storybook/addon-viewport": "8.3.5",
|
||||
"@storybook/blocks": "8.3.5",
|
||||
"@storybook/nextjs": "8.3.5",
|
||||
"@storybook/react": "8.3.5",
|
||||
"@storybook/test": "8.3.5",
|
||||
"@storybook/addon-essentials": "8.3.6",
|
||||
"@storybook/addon-interactions": "8.3.6",
|
||||
"@storybook/addon-links": "8.3.6",
|
||||
"@storybook/addon-onboarding": "8.3.6",
|
||||
"@storybook/addon-viewport": "8.3.6",
|
||||
"@storybook/blocks": "8.3.6",
|
||||
"@storybook/nextjs": "8.3.6",
|
||||
"@storybook/react": "8.3.6",
|
||||
"@storybook/test": "8.3.6",
|
||||
"@types/color": "^3.0.6",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.11",
|
||||
@@ -139,8 +139,8 @@
|
||||
"knip": "^5.33.3",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.4.47",
|
||||
"storybook": "8.3.5",
|
||||
"tailwindcss": "3.4.13",
|
||||
"storybook": "8.3.6",
|
||||
"tailwindcss": "3.4.14",
|
||||
"typescript": "5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,13 +119,7 @@ export function AppFooter(props: AppFooterProps) {
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://thirdweb.com/gas"
|
||||
target="_blank"
|
||||
>
|
||||
Gas Estimator
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://thirdweb.com/chainlist"
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function UnexpectedValueErrorMessage(props: {
|
||||
value: unknown;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const stringifiedValue = useMemo(() => {
|
||||
try {
|
||||
return JSON.stringify(props.value, null, 2);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<p className="text-base text-destructive-text"> {props.title} </p>
|
||||
<p className="text-foreground text-sm">{props.description}</p>
|
||||
{stringifiedValue && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-0.5 text-muted-foreground text-sm">Value Received</p>
|
||||
<ScrollShadow className="rounded-lg bg-muted/50">
|
||||
<code className="block whitespace-pre p-4 font-mono text-xs">
|
||||
{stringifiedValue}
|
||||
</code>
|
||||
</ScrollShadow>
|
||||
|
||||
<CopyTextButton
|
||||
copyIconPosition="left"
|
||||
textToShow="Copy value"
|
||||
textToCopy={stringifiedValue}
|
||||
tooltip="Copy value"
|
||||
className="-translate-x-2 mt-1 text-muted-foreground"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
HoverCard,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { Check, Copy, ExternalLinkIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type SocialProfile,
|
||||
useSocialProfiles,
|
||||
} from "thirdweb/react";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -113,22 +112,20 @@ export function WalletAddress(props: {
|
||||
) : !profiles.data?.length ? (
|
||||
<p className="text-muted-foreground text-sm">No profiles found</p>
|
||||
) : (
|
||||
profiles.data?.map((profile) => (
|
||||
<div
|
||||
className="flex flex-row items-center gap-2"
|
||||
key={profile.type + profile.name}
|
||||
>
|
||||
{profile.avatar &&
|
||||
(profile.avatar.startsWith("http") ||
|
||||
profile.avatar?.startsWith("ipfs")) && (
|
||||
profiles.data?.map((profile) => {
|
||||
const walletAvatarLink = resolveSchemeWithErrorHandler({
|
||||
client: thirdwebClient,
|
||||
uri: profile.avatar,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center gap-2"
|
||||
key={profile.type + profile.name}
|
||||
>
|
||||
{walletAvatarLink && (
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={resolveScheme({
|
||||
client: thirdwebClient,
|
||||
uri: profile.avatar,
|
||||
})}
|
||||
alt={profile.name}
|
||||
/>
|
||||
<AvatarImage src={walletAvatarLink} alt={profile.name} />
|
||||
{profile.name && (
|
||||
<AvatarFallback>
|
||||
{profile.name.slice(0, 2)}
|
||||
@@ -136,19 +133,20 @@ export function WalletAddress(props: {
|
||||
)}
|
||||
</Avatar>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-4">
|
||||
<h4 className="font-semibold text-md">{profile.name}</h4>
|
||||
<Badge variant="outline">{profile.type}</Badge>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-4">
|
||||
<h4 className="font-semibold text-md">{profile.name}</h4>
|
||||
<Badge variant="outline">{profile.type}</Badge>
|
||||
</div>
|
||||
{profile.bio && (
|
||||
<p className="line-clamp-1 whitespace-normal text-muted-foreground text-sm">
|
||||
{profile.bio}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{profile.bio && (
|
||||
<p className="line-clamp-1 whitespace-normal text-muted-foreground text-sm">
|
||||
{profile.bio}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
export function PlainTextCodeBlock(props: {
|
||||
code: string;
|
||||
copyButtonClassName?: string;
|
||||
}) {
|
||||
const { hasCopied, onCopy } = useClipboard(props.code);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<code className="block whitespace-pre rounded-lg border border-border bg-muted/50 p-4">
|
||||
{props.code}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCopy}
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3.5 h-auto p-2",
|
||||
props.copyButtonClassName,
|
||||
)}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<CheckIcon className="size-3 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,12 +51,12 @@ const RadioGroupItemButton = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex min-w-32 cursor-pointer items-center space-x-3 space-y-0 rounded-md border-2 px-3 py-4 font-medium transition-all hover:border-foreground/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 peer-hover:border-nonce data-[state=checked]:border-inverted",
|
||||
"group flex min-w-32 cursor-pointer items-center space-x-3 space-y-0 rounded-lg border px-4 py-3 font-medium transition-all hover:border-foreground/25 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 peer-hover:border-nonce data-[state=checked]:border-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex aspect-square h-4 w-4 items-center justify-center rounded-full border-2 text-inverted ring-offset-background transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 group-hover:border-foreground/25 group-data-[state=checked]:border-inverted">
|
||||
<div className="flex aspect-square h-4 w-4 items-center justify-center rounded-full border text-inverted ring-offset-background transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 group-hover:border-foreground/25 group-data-[state=checked]:border-inverted">
|
||||
{/* Show on checked */}
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center rounded-full transition-all">
|
||||
<Circle className="h-2 w-2 fill-current text-current" />
|
||||
|
||||
@@ -5,7 +5,6 @@ const LOGGED_IN_ONLY_PATHS = [
|
||||
"/team",
|
||||
// anything that _starts_ with /cli is logged in only
|
||||
"/cli",
|
||||
"/support",
|
||||
// publish page
|
||||
"/contracts/publish",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
|
||||
export function resolveSchemeWithErrorHandler(options: {
|
||||
uri: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
if (!options.uri) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return resolveScheme({
|
||||
uri: options.uri,
|
||||
client: options.client,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("error resolving ipfs url", options.uri, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -435,7 +435,7 @@ export function useUserOpUsageAggregate(args: {
|
||||
const { clientId, from, to } = args;
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
return useQuery<UserOpStats>({
|
||||
queryKey: accountKeys.userOpStats(
|
||||
user?.address as string,
|
||||
clientId as string,
|
||||
@@ -444,12 +444,28 @@ export function useUserOpUsageAggregate(args: {
|
||||
"all",
|
||||
),
|
||||
queryFn: async () => {
|
||||
return getUserOpUsage({
|
||||
const userOpStats: UserOpStats[] = await getUserOpUsage({
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
period: "all",
|
||||
});
|
||||
|
||||
// Aggregate stats across wallet types
|
||||
return userOpStats.reduce(
|
||||
(acc, curr) => {
|
||||
acc.successful += curr.successful;
|
||||
acc.failed += curr.failed;
|
||||
acc.sponsoredUsd += curr.sponsoredUsd;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
date: (from || new Date()).toISOString(),
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
sponsoredUsd: 0,
|
||||
},
|
||||
);
|
||||
},
|
||||
enabled: !!clientId && !!user?.address && isLoggedIn,
|
||||
});
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ export function FaucetButton({
|
||||
amount,
|
||||
}: {
|
||||
chain: ChainMetadata;
|
||||
amount: string;
|
||||
amount: number;
|
||||
}) {
|
||||
const client = useThirdwebClient();
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
+2
-2
@@ -1,16 +1,16 @@
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getFaucetClaimAmount } from "../../../../../../api/testnet-faucet/claim/claim-amount";
|
||||
import { ChainIcon } from "../../../../components/server/chain-icon";
|
||||
import { FaucetButton } from "../client/FaucetButton";
|
||||
import { GiftIcon } from "../icons/GiftIcon";
|
||||
import { SectionTitle } from "./SectionTitle";
|
||||
|
||||
const amountToGive = "0.01";
|
||||
|
||||
export async function FaucetSection(props: { chain: ChainMetadata }) {
|
||||
const { chain } = props;
|
||||
|
||||
// Check eligibilty.
|
||||
const sanitizedChainName = chain.name.replace("Mainnet", "").trim();
|
||||
const amountToGive = getFaucetClaimAmount(props.chain.chainId);
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
||||
+10
-13
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { AdminOnly } from "@3rdweb-sdk/react/components/roles/admin-only";
|
||||
import { useIsAdmin } from "@3rdweb-sdk/react/hooks/useContractRoles";
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
@@ -445,19 +445,16 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
};
|
||||
}, [claimConditionsQuery.data, controlledFields, isMultiPhase]);
|
||||
|
||||
if (isFetchingData) {
|
||||
return (
|
||||
<div className="flex h-[400px] w-full items-center justify-center rounded-lg border border-border">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* spinner */}
|
||||
{isFetchingData && (
|
||||
<Spinner
|
||||
color="primary"
|
||||
size="xs"
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={4}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex onSubmit={handleFormSubmit} direction="column" as="form" gap={10}>
|
||||
<Flex direction="column" gap={6}>
|
||||
{/* Show the reason why the form is disabled */}
|
||||
@@ -553,7 +550,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
flexDir={{ base: "column", md: isColumn ? "column" : "row" }}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={2}
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
|
||||
+1
-6
@@ -19,12 +19,7 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-8">
|
||||
<Flex p={0} position="relative">
|
||||
<Flex
|
||||
pt={{ base: isColumn ? 0 : 6, md: 6 }}
|
||||
direction="column"
|
||||
gap={8}
|
||||
w="full"
|
||||
>
|
||||
<Flex direction="column" gap={6} w="full">
|
||||
{/* Info */}
|
||||
<section>
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
|
||||
@@ -3,7 +3,9 @@ import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { ContractMetadata } from "components/custom-contract/contract-header/contract-metadata";
|
||||
import { DeprecatedAlert } from "components/shared/DeprecatedAlert";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { isAddress, isContractDeployed } from "thirdweb/utils";
|
||||
import { resolveFunctionSelectors } from "../../../../../lib/selectors";
|
||||
import { shortenIfAddress } from "../../../../../utils/usedapp-external";
|
||||
import { ConfigureCustomChain } from "./ConfigureCustomChain";
|
||||
@@ -20,6 +22,10 @@ export default async function Layout(props: {
|
||||
};
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!isAddress(props.params.contractAddress)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
|
||||
if (!info) {
|
||||
@@ -27,6 +33,18 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
const { contract, chainMetadata } = info;
|
||||
|
||||
if (chainMetadata.status === "deprecated") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// check if the contract exists
|
||||
const isValidContract = await isContractDeployed(contract);
|
||||
if (!isValidContract) {
|
||||
// TODO - replace 404 with a better page to upsale deploy or other thirdweb products
|
||||
notFound();
|
||||
}
|
||||
|
||||
const contractPageMetadata = await getContractPageMetadata(contract);
|
||||
const sidebarLinks = getContractPageSidebarLinks({
|
||||
chainSlug: chainMetadata.slug,
|
||||
|
||||
+103
-88
@@ -1,17 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Flex, useDisclosure } from "@chakra-ui/react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { multicall } from "thirdweb/extensions/common";
|
||||
import { balanceOf, encodeSafeTransferFrom } from "thirdweb/extensions/erc1155";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { Button, Text } from "tw-components";
|
||||
import { type AirdropAddressInput, AirdropUpload } from "./airdrop-upload";
|
||||
import {
|
||||
type AirdropAddressInput,
|
||||
AirdropUpload,
|
||||
} from "../../../tokens/components/airdrop-upload";
|
||||
|
||||
interface AirdropTabProps {
|
||||
contract: ThirdwebContract;
|
||||
@@ -22,8 +33,6 @@ interface AirdropTabProps {
|
||||
* This component must only take in ERC1155 contracts
|
||||
*/
|
||||
const AirdropTab: React.FC<AirdropTabProps> = ({ contract, tokenId }) => {
|
||||
const account = useActiveAccount();
|
||||
|
||||
const address = useActiveAccount()?.address;
|
||||
const { handleSubmit, setValue, watch, reset, formState } = useForm<{
|
||||
addresses: AirdropAddressInput[];
|
||||
@@ -31,100 +40,106 @@ const AirdropTab: React.FC<AirdropTabProps> = ({ contract, tokenId }) => {
|
||||
defaultValues: { addresses: [] },
|
||||
});
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const { mutate, isPending } = useSendAndConfirmTransaction();
|
||||
|
||||
const { onSuccess, onError } = useTxNotifications(
|
||||
"Airdrop successful",
|
||||
"Error transferring",
|
||||
contract,
|
||||
);
|
||||
|
||||
const sendAndConfirmTx = useSendAndConfirmTransaction();
|
||||
const addresses = watch("addresses");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<form
|
||||
onSubmit={handleSubmit(async (_data) => {
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "airdrop",
|
||||
label: "attempt",
|
||||
contract_address: contract.address,
|
||||
token_id: tokenId,
|
||||
});
|
||||
const totalOwned = await balanceOf({
|
||||
contract,
|
||||
tokenId: BigInt(tokenId),
|
||||
owner: account?.address ?? "",
|
||||
});
|
||||
// todo: make a batch-transfer extension for erc1155?
|
||||
const totalToAirdrop = _data.addresses.reduce((prev, curr) => {
|
||||
return BigInt(prev) + BigInt(curr?.quantity || 1);
|
||||
}, 0n);
|
||||
if (totalOwned < totalToAirdrop) {
|
||||
return onError(
|
||||
new Error(
|
||||
`The caller owns ${totalOwned.toString()} NFTs, but wants to airdrop ${totalToAirdrop.toString()} NFTs.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const data = _data.addresses.map(({ address: to, quantity }) =>
|
||||
encodeSafeTransferFrom({
|
||||
from: account?.address ?? "",
|
||||
to,
|
||||
value: BigInt(quantity),
|
||||
data: "0x",
|
||||
try {
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "airdrop",
|
||||
label: "attempt",
|
||||
contract_address: contract.address,
|
||||
token_id: tokenId,
|
||||
});
|
||||
const totalOwned = await balanceOf({
|
||||
contract,
|
||||
tokenId: BigInt(tokenId),
|
||||
}),
|
||||
);
|
||||
const transaction = multicall({ contract, data });
|
||||
mutate(transaction, {
|
||||
onSuccess: () => {
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "airdrop",
|
||||
label: "success",
|
||||
contract_address: contract.address,
|
||||
token_id: tokenId,
|
||||
});
|
||||
onSuccess();
|
||||
reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "airdrop",
|
||||
label: "success",
|
||||
contract_address: contract.address,
|
||||
token_id: tokenId,
|
||||
error,
|
||||
});
|
||||
onError(error);
|
||||
},
|
||||
});
|
||||
owner: address ?? "",
|
||||
});
|
||||
// todo: make a batch-transfer extension for erc1155?
|
||||
const totalToAirdrop = _data.addresses.reduce((prev, curr) => {
|
||||
return BigInt(prev) + BigInt(curr?.quantity || 1);
|
||||
}, 0n);
|
||||
if (totalOwned < totalToAirdrop) {
|
||||
return toast.error(
|
||||
`The caller owns ${totalOwned.toString()} NFTs, but wants to airdrop ${totalToAirdrop.toString()} NFTs.`,
|
||||
);
|
||||
}
|
||||
const data = _data.addresses.map(({ address: to, quantity }) =>
|
||||
encodeSafeTransferFrom({
|
||||
from: address ?? "",
|
||||
to,
|
||||
value: BigInt(quantity),
|
||||
data: "0x",
|
||||
tokenId: BigInt(tokenId),
|
||||
}),
|
||||
);
|
||||
const transaction = multicall({ contract, data });
|
||||
const promise = sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "airdrop",
|
||||
label: "success",
|
||||
contract_address: contract.address,
|
||||
token_id: tokenId,
|
||||
});
|
||||
reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "airdrop",
|
||||
label: "success",
|
||||
contract_address: contract.address,
|
||||
token_id: tokenId,
|
||||
error,
|
||||
});
|
||||
},
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Airdropping NFTs",
|
||||
success: "Airdropped successfully",
|
||||
error: "Failed to airdrop",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to airdrop NFTs");
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mb-3 flex w-full flex-col gap-6 md:flex-row">
|
||||
<AirdropUpload
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
setAirdrop={(value) =>
|
||||
setValue("addresses", value, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Flex direction={{ base: "column", md: "row" }} gap={4}>
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
borderRadius="md"
|
||||
onClick={onOpen}
|
||||
rightIcon={<UploadIcon className="size-5" />}
|
||||
>
|
||||
Upload addresses
|
||||
</Button>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
borderRadius="md"
|
||||
rightIcon={<UploadIcon className="size-5" />}
|
||||
>
|
||||
Upload addresses
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="mb-5 text-left">
|
||||
Aidrop NFTs
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AirdropUpload
|
||||
onClose={() => setOpen(false)}
|
||||
setAirdrop={(value) =>
|
||||
setValue("addresses", value, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Flex
|
||||
gap={2}
|
||||
@@ -149,7 +164,7 @@ const AirdropTab: React.FC<AirdropTabProps> = ({ contract, tokenId }) => {
|
||||
<TransactionButton
|
||||
txChainID={contract.chain.id}
|
||||
transactionCount={1}
|
||||
isLoading={isPending}
|
||||
isLoading={sendAndConfirmTx.isPending}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
disabled={!!address && addresses.length === 0}
|
||||
|
||||
-494
@@ -1,494 +0,0 @@
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Flex,
|
||||
IconButton,
|
||||
Link,
|
||||
Portal,
|
||||
Select,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tooltip,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
import { Logo } from "components/logo";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type DropzoneOptions, useDropzone } from "react-dropzone";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import { isAddress } from "thirdweb/utils";
|
||||
import { Button, Drawer, Heading, Text } from "tw-components";
|
||||
import { csvMimeTypes } from "utils/batch";
|
||||
|
||||
export interface AirdropAddressInput {
|
||||
address: string;
|
||||
quantity: string;
|
||||
isValid?: boolean;
|
||||
}
|
||||
interface AirdropUploadProps {
|
||||
setAirdrop: (airdrop: AirdropAddressInput[]) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
setAirdrop,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const client = useThirdwebClient();
|
||||
const [validAirdrop, setValidAirdrop] = useState<AirdropAddressInput[]>([]);
|
||||
const [airdropData, setAirdropData] = useState<AirdropAddressInput[]>([]);
|
||||
const [noCsv, setNoCsv] = useState(false);
|
||||
const [invalidFound, setInvalidFound] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValidAirdrop([]);
|
||||
setNoCsv(false);
|
||||
}, []);
|
||||
|
||||
const _onClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const onDrop = useCallback<Required<DropzoneOptions>["onDrop"]>(
|
||||
(acceptedFiles) => {
|
||||
setNoCsv(false);
|
||||
|
||||
const csv = acceptedFiles.find(
|
||||
(f) => csvMimeTypes.includes(f.type) || f.name?.endsWith(".csv"),
|
||||
);
|
||||
if (!csv) {
|
||||
console.error(
|
||||
"No valid CSV file found, make sure you have an address column.",
|
||||
);
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
|
||||
Papa.parse(csv, {
|
||||
header: true,
|
||||
complete: (results) => {
|
||||
const data: AirdropAddressInput[] = (
|
||||
results.data as AirdropAddressInput[]
|
||||
)
|
||||
.map(({ address, quantity }) => ({
|
||||
address: (address || "").trim(),
|
||||
quantity: (quantity || "1").trim(),
|
||||
}))
|
||||
.filter(({ address }) => address !== "");
|
||||
|
||||
if (!data[0]?.address) {
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidAirdrop(data);
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// FIXME: this can be a mutation or query instead!
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (validAirdrop.length === 0) {
|
||||
return setAirdropData([]);
|
||||
}
|
||||
|
||||
const normalizeAddresses = async (snapshot: AirdropAddressInput[]) => {
|
||||
const normalized = await Promise.all(
|
||||
snapshot.map(async ({ address, ...rest }) => {
|
||||
let isValid = true;
|
||||
let resolvedAddress = address;
|
||||
|
||||
try {
|
||||
resolvedAddress = isAddress(address)
|
||||
? address
|
||||
: await resolveAddress({ name: address, client });
|
||||
isValid = !!resolvedAddress;
|
||||
} catch {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
resolvedAddress,
|
||||
isValid,
|
||||
...rest,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const seen = new Set();
|
||||
const filteredData = normalized.filter((el) => {
|
||||
const duplicate = seen.has(el.resolvedAddress);
|
||||
seen.add(el.resolvedAddress);
|
||||
return !duplicate;
|
||||
});
|
||||
|
||||
const valid = filteredData.filter(({ isValid }) => isValid);
|
||||
const invalid = filteredData.filter(({ isValid }) => !isValid);
|
||||
|
||||
if (invalid?.length > 0) {
|
||||
setInvalidFound(true);
|
||||
}
|
||||
const ordered = [...invalid, ...valid];
|
||||
setAirdropData(ordered);
|
||||
};
|
||||
normalizeAddresses(validAirdrop);
|
||||
}, [validAirdrop, client]);
|
||||
|
||||
const removeInvalid = useCallback(() => {
|
||||
const filteredData = airdropData.filter(({ isValid }) => isValid);
|
||||
setValidAirdrop(filteredData);
|
||||
setInvalidFound(false);
|
||||
}, [airdropData]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
});
|
||||
|
||||
const paginationPortalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onSave = () => {
|
||||
setAirdrop(airdropData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
allowPinchZoom
|
||||
preserveScrollBarGap
|
||||
size="xl"
|
||||
onClose={_onClose}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<Flex direction="column" gap={6} h="100%">
|
||||
<Flex shadow="sm">
|
||||
<Container maxW="container.page">
|
||||
<Flex align="center" justify="space-between" p={4}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Logo hideWordmark />
|
||||
<Heading size="title.md">
|
||||
{validAirdrop.length ? "Edit" : "Upload"} Airdrop
|
||||
</Heading>
|
||||
</div>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
|
||||
{validAirdrop.length > 0 ? (
|
||||
<AirdropTable portalRef={paginationPortalRef} data={airdropData} />
|
||||
) : (
|
||||
<Flex flexGrow={1} align="center" overflow="auto">
|
||||
<Container maxW="container.page">
|
||||
<Flex gap={8} flexDir="column">
|
||||
<div className="relative aspect-[21/9] w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
|
||||
noCsv ? "bg-red-200" : "bg-card",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="m-auto flex flex-col p-6">
|
||||
<UploadIcon
|
||||
size={16}
|
||||
className={cn("mx-auto mb-2 text-gray-500", {
|
||||
"text-red-500": noCsv,
|
||||
})}
|
||||
/>
|
||||
{isDragActive ? (
|
||||
<Heading
|
||||
as={Text}
|
||||
size="label.md"
|
||||
className="text-center"
|
||||
>
|
||||
Drop the files here
|
||||
</Heading>
|
||||
) : (
|
||||
<Heading
|
||||
as={Text}
|
||||
size="label.md"
|
||||
color={noCsv ? "red.500" : "gray.600"}
|
||||
className="text-center"
|
||||
>
|
||||
{noCsv
|
||||
? `No valid CSV file found, make sure your CSV includes the "address" column.`
|
||||
: "Drag & Drop a CSV file here"}
|
||||
</Heading>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Heading size="subtitle.sm">Requirements</Heading>
|
||||
<UnorderedList>
|
||||
<li>
|
||||
Files <em>must</em> contain one .csv file with an address
|
||||
and quantity column, if the quantity column is not
|
||||
provided, it will default to 1 NFT per wallet. -{" "}
|
||||
<Link download color="primary.500" href="/airdrop.csv">
|
||||
Download an example CSV
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Repeated addresses will be removed and only the first
|
||||
found will be kept.
|
||||
</li>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex borderTop="1px solid" borderTopColor="borderColor">
|
||||
<Container maxW="container.page">
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
p={{ base: 0, md: 4 }}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
mt={{ base: 4, md: 0 }}
|
||||
>
|
||||
<Box ref={paginationPortalRef} />
|
||||
<Flex
|
||||
gap={2}
|
||||
align="center"
|
||||
mt={{ base: 4, md: 0 }}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
<Button
|
||||
borderRadius="md"
|
||||
disabled={validAirdrop.length === 0}
|
||||
onClick={() => {
|
||||
reset();
|
||||
}}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{invalidFound ? (
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
disabled={validAirdrop.length === 0}
|
||||
onClick={() => {
|
||||
removeInvalid();
|
||||
}}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Remove invalid
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
onClick={onSave}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
isDisabled={validAirdrop.length === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
interface AirdropTableProps {
|
||||
data: AirdropAddressInput[];
|
||||
portalRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const AirdropTable: React.FC<AirdropTableProps> = ({ data, portalRef }) => {
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
Header: "Address",
|
||||
accessor: ({ address, isValid }) => {
|
||||
if (isValid) {
|
||||
return address;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Tooltip
|
||||
label={
|
||||
address.startsWith("0x")
|
||||
? "Address is not valid"
|
||||
: "Address couldn't be resolved"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<CircleAlertIcon className="size-5 text-red-500" />
|
||||
<Text fontWeight="bold" color="red.500" cursor="default">
|
||||
{address}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Quantity",
|
||||
accessor: ({ quantity }: { quantity: string }) => {
|
||||
return quantity || "1";
|
||||
},
|
||||
},
|
||||
] as Column<AirdropAddressInput>[];
|
||||
}, []);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
// Instead of using 'rows', we'll use page,
|
||||
page,
|
||||
// which has only the rows for the active page
|
||||
|
||||
// The rest of these things are super handy, too ;)
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageOptions,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
initialState: {
|
||||
pageSize: 50,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
// old package: this will be removed
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
);
|
||||
|
||||
// Render the UI for your table
|
||||
return (
|
||||
<Flex flexGrow={1}>
|
||||
<TableContainer>
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup, headerGroupIndex) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
|
||||
{headerGroup.headers.map((column, columnIndex) => (
|
||||
<Th
|
||||
{...column.getHeaderProps()}
|
||||
border="none"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={columnIndex}
|
||||
>
|
||||
<Text as="label" size="label.sm" color="faded">
|
||||
{column.render("Header")}
|
||||
</Text>
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()}>
|
||||
{page.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...row.getRowProps()} key={rowIndex}>
|
||||
{row.cells.map((cell, cellIndex) => (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderColor="borderColor"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Portal containerRef={portalRef}>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row gap-1">
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="my-auto whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
onChange={(e) => {
|
||||
setPageSize(Number.parseInt(e.target.value as string, 10));
|
||||
}}
|
||||
value={pageSize}
|
||||
>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
+86
-14
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { UnexpectedValueErrorMessage } from "@/components/blocks/error-fallbacks/unexpect-value-error-message";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import {
|
||||
Box,
|
||||
ButtonGroup,
|
||||
@@ -24,7 +26,6 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import { getNFT as getErc721NFT } from "thirdweb/extensions/erc721";
|
||||
import { getNFT as getErc1155NFT } from "thirdweb/extensions/erc1155";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
import { Badge, Button, Card, CodeBlock, Heading, Text } from "tw-components";
|
||||
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
@@ -53,6 +54,8 @@ interface TokenIdPageProps {
|
||||
isErc721: boolean;
|
||||
}
|
||||
|
||||
// TODO: verify the entire nft object with zod schema and display an error message
|
||||
|
||||
export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
contract,
|
||||
tokenId,
|
||||
@@ -80,6 +83,15 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
},
|
||||
);
|
||||
|
||||
const tokenURIHttpLink = resolveSchemeWithErrorHandler({
|
||||
client,
|
||||
uri: nft?.tokenURI,
|
||||
});
|
||||
const nftImageLink = resolveSchemeWithErrorHandler({
|
||||
client,
|
||||
uri: nft?.metadata.image,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
@@ -132,15 +144,15 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
height={isMobile ? "100%" : "300px"}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Flex flexDir="column" gap={6} w="full" px={2}>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Heading size="title.lg">{nft.metadata.name}</Heading>
|
||||
<Flex flexDir="column" gap={1.5}>
|
||||
<NFTName value={nft.metadata.name} />
|
||||
{nft.metadata?.description && (
|
||||
<Text size="label.md" noOfLines={50} whiteSpace="pre-wrap">
|
||||
{nft.metadata.description}
|
||||
</Text>
|
||||
<NFTDescription value={nft.metadata.description} />
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex flexDir="column" gap={{ base: 0, md: 4 }}>
|
||||
<Box
|
||||
w="full"
|
||||
@@ -260,15 +272,39 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
tooltip="The URI of this NFT"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
<Button variant="ghost">
|
||||
<Link
|
||||
href={resolveScheme({ client, uri: nft.tokenURI })}
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{tokenURIHttpLink && (
|
||||
<Button variant="ghost" size="sm">
|
||||
<Link href={tokenURIHttpLink} target="_blank">
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</GridItem>
|
||||
{nft.metadata.image && (
|
||||
<>
|
||||
<GridItem colSpan={4}>
|
||||
<Heading size="label.md">Media URI</Heading>
|
||||
</GridItem>
|
||||
<GridItem
|
||||
colSpan={8}
|
||||
className="flex flex-row items-center gap-1"
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={nft.metadata.image}
|
||||
textToShow={shortenString(nft.metadata.image)}
|
||||
tooltip="The media URI of this NFT"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
{nftImageLink && (
|
||||
<Button variant="ghost" size="sm">
|
||||
<Link href={nftImageLink} target="_blank">
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</GridItem>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
{properties ? (
|
||||
@@ -309,3 +345,39 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
function NFTName(props: {
|
||||
value: unknown;
|
||||
}) {
|
||||
if (typeof props.value === "string") {
|
||||
return (
|
||||
<h2 className="font-semibold text-lg tracking-tight"> {props.value}</h2>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnexpectedValueErrorMessage
|
||||
title="Invalid Name"
|
||||
description="Name is not a string"
|
||||
value={props.value}
|
||||
className="mb-3 rounded-lg border border-border p-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NFTDescription(props: {
|
||||
value: unknown;
|
||||
}) {
|
||||
if (typeof props.value === "string") {
|
||||
return <p className="text-muted-foreground"> {props.value}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnexpectedValueErrorMessage
|
||||
title="Invalid Description"
|
||||
description="Description is not a string"
|
||||
value={props.value}
|
||||
className="mb-3 rounded-lg border border-border p-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+84
-26
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { UnexpectedValueErrorMessage } from "@/components/blocks/error-fallbacks/unexpect-value-error-message";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Flex,
|
||||
IconButton,
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { MediaCell } from "components/contract-pages/table/table-columns/cells/media-cell";
|
||||
import { useChainSlug } from "hooks/chains/chainSlug";
|
||||
import {
|
||||
@@ -26,6 +30,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
|
||||
import {
|
||||
type CellProps,
|
||||
type Column,
|
||||
@@ -72,24 +77,51 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
|
||||
{
|
||||
Header: "Name",
|
||||
accessor: (row) => row.metadata.name,
|
||||
Cell: (cell: CellProps<NFT, string>) => (
|
||||
<Text noOfLines={1} size="label.md">
|
||||
{cell.value}
|
||||
</Text>
|
||||
),
|
||||
Cell: (cell: CellProps<NFT, string>) => {
|
||||
if (typeof cell.value !== "string") {
|
||||
return (
|
||||
<UnexpectedValueErrorMessage
|
||||
title="Invalid Name"
|
||||
description="Name is not a string"
|
||||
value={cell.value}
|
||||
className="w-[300px] py-3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="line-clamp-1 text-muted-foreground text-sm">
|
||||
{cell.value}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Description",
|
||||
accessor: (row) => row.metadata.description,
|
||||
Cell: (cell: CellProps<NFT, string>) => (
|
||||
<Text
|
||||
noOfLines={4}
|
||||
size="body.md"
|
||||
fontStyle={!cell.value ? "italic" : "normal"}
|
||||
>
|
||||
{cell.value || "No description"}
|
||||
</Text>
|
||||
),
|
||||
Cell: (cell: CellProps<NFT, string>) => {
|
||||
if (typeof cell.value !== "string") {
|
||||
return (
|
||||
<UnexpectedValueErrorMessage
|
||||
title="Invalid description"
|
||||
description="Description is not a string"
|
||||
value={cell.value}
|
||||
className="w-[300px] py-3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"line-clamp-4 max-w-[200px] whitespace-normal text-muted-foreground text-sm",
|
||||
{ italic: !cell.value },
|
||||
)}
|
||||
>
|
||||
{cell.value || "No description"}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (isErc721) {
|
||||
@@ -280,18 +312,22 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={rowIndex}
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) => (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderBottomWidth="inherit"
|
||||
borderColor="borderColor"
|
||||
maxW="sm"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
))}
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
return (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderBottomWidth="inherit"
|
||||
borderColor="borderColor"
|
||||
maxW="sm"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={NFTCellErrorBoundary}>
|
||||
{cell.render("Cell")}
|
||||
</ErrorBoundary>
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
<Td borderBottomWidth="inherit" borderColor="borderColor">
|
||||
{!failedToLoad && <ArrowRightIcon className="size-4" />}
|
||||
</Td>
|
||||
@@ -374,3 +410,25 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
function NFTCellErrorBoundary(errorProps: FallbackProps) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("component-crashed", "true");
|
||||
scope.setTag("component-crashed-boundary", "NFTCellErrorBoundary");
|
||||
scope.setLevel("fatal");
|
||||
Sentry.captureException(errorProps.error);
|
||||
});
|
||||
}, [errorProps.error]);
|
||||
|
||||
return (
|
||||
<CopyTextButton
|
||||
textToShow={errorProps.error.message}
|
||||
textToCopy={errorProps.error.message}
|
||||
copyIconPosition="left"
|
||||
tooltip={errorProps.error.message}
|
||||
className="max-w-[250px] truncate text-destructive-text"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-3
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { PublishedBy } from "components/contract-components/shared/published-by";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { AnalyticsOverview } from "./components/Analytics";
|
||||
import { BuildYourApp } from "./components/BuildYourApp";
|
||||
@@ -21,6 +20,7 @@ interface ContractOverviewPageProps {
|
||||
isPermissionsEnumerable: boolean;
|
||||
chainSlug: string;
|
||||
isAnalyticsSupported: boolean;
|
||||
functionSelectors: string[];
|
||||
}
|
||||
|
||||
const TRACKING_CATEGORY = "contract_overview";
|
||||
@@ -35,6 +35,7 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
|
||||
isPermissionsEnumerable,
|
||||
chainSlug,
|
||||
isAnalyticsSupported,
|
||||
functionSelectors,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 lg:flex-row">
|
||||
@@ -45,6 +46,7 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
|
||||
isErc20={isErc20}
|
||||
contract={contract}
|
||||
chainSlug={chainSlug}
|
||||
functionSelectors={functionSelectors}
|
||||
/>
|
||||
|
||||
{isAnalyticsSupported && (
|
||||
@@ -98,7 +100,9 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 lg:w-[300px]">
|
||||
<PublishedBy contract={contract} />
|
||||
<ErrorBoundary fallback={null}>
|
||||
<PublishedBy contract={contract} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
||||
+5
-1
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Flex,
|
||||
GridItem,
|
||||
@@ -7,7 +9,9 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { ChakraNextImage as Image } from "components/Image";
|
||||
import { PRODUCTS } from "components/product-pages/common/nav/data";
|
||||
import { Card, Text, TrackedLink, type TrackedLinkProps } from "tw-components";
|
||||
import { Card } from "tw-components/card";
|
||||
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
|
||||
import { Text } from "tw-components/text";
|
||||
|
||||
const RENDERED_PRODUCTS = ["sdk", "storage", "ui-components", "auth"];
|
||||
|
||||
|
||||
+4
-5
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AdminOnly } from "@3rdweb-sdk/react/components/roles/admin-only";
|
||||
import { useIsMinter } from "@3rdweb-sdk/react/hooks/useContractRoles";
|
||||
import { StepsCard } from "components/dashboard/StepsCard";
|
||||
import { useContractFunctionSelectors } from "contract-ui/hooks/useContractFunctionSelectors";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
@@ -18,6 +19,7 @@ interface ContractChecklistProps {
|
||||
isErc1155: boolean;
|
||||
isErc20: boolean;
|
||||
chainSlug: string;
|
||||
functionSelectors: string[];
|
||||
}
|
||||
|
||||
type Step = {
|
||||
@@ -27,13 +29,10 @@ type Step = {
|
||||
};
|
||||
|
||||
export const ContractChecklist: React.FC<ContractChecklistProps> = (props) => {
|
||||
const functionSelectorQuery = useContractFunctionSelectors(props.contract);
|
||||
return (
|
||||
// if no permissions, simply return null (do not fail open)
|
||||
<AdminOnly contract={props.contract} failOpen={false}>
|
||||
{!!functionSelectorQuery.data?.length && (
|
||||
<Inner functionSelectors={functionSelectorQuery.data} {...props} />
|
||||
)}
|
||||
<Inner {...props} />
|
||||
</AdminOnly>
|
||||
);
|
||||
};
|
||||
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type InternalTransaction,
|
||||
useActivity,
|
||||
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Flex, useBreakpointValue } from "@chakra-ui/react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import * as ERC721 from "thirdweb/extensions/erc721";
|
||||
import * as ERC1155 from "thirdweb/extensions/erc1155";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { TrackedLink, type TrackedLinkProps } from "tw-components";
|
||||
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
|
||||
import { NFTCards } from "../../_components/NFTCards";
|
||||
|
||||
interface NFTDetailsProps {
|
||||
|
||||
+7
-8
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { Box, Flex, List, SimpleGrid, Tag } from "@chakra-ui/react";
|
||||
import { getAllRoleMembers } from "contract-ui/hooks/permissions";
|
||||
@@ -8,14 +10,11 @@ import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Heading,
|
||||
Text,
|
||||
TrackedLink,
|
||||
type TrackedLinkProps,
|
||||
} from "tw-components";
|
||||
import { Button } from "tw-components/button";
|
||||
import { Card } from "tw-components/card";
|
||||
import { Heading } from "tw-components/heading";
|
||||
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
|
||||
import { Text } from "tw-components/text";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
|
||||
interface PermissionsTableProps {
|
||||
|
||||
@@ -31,6 +31,7 @@ export default async function Page(props: {
|
||||
}
|
||||
chainSlug={chainMetadata.slug}
|
||||
isAnalyticsSupported={contractPageMetadata.isAnalyticsSupported}
|
||||
functionSelectors={contractPageMetadata.functionSelectors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -89,7 +89,8 @@ export const SettingsMetadata = ({
|
||||
let image: string | undefined = metadata.data?.image;
|
||||
try {
|
||||
image = image
|
||||
? resolveScheme({
|
||||
? // eslint-disable-next-line no-restricted-syntax
|
||||
resolveScheme({
|
||||
client: contract.client,
|
||||
uri: image,
|
||||
})
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ interface VerificationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function verifyContract(contract: ThirdwebContract) {
|
||||
export async function verifyContract(contract: ThirdwebContract) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://contract.thirdweb.com/verify/contract",
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ export const TokenAirdropButton: React.FC<TokenAirdropButtonProps> = ({
|
||||
Airdrop
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000] overflow-y-auto sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Aidrop tokens</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
+52
-50
@@ -10,10 +10,7 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import { transferBatch } from "thirdweb/extensions/erc20";
|
||||
import { useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { Button, Text } from "tw-components";
|
||||
import {
|
||||
AirdropUploadERC20,
|
||||
type ERC20AirdropAddressInput,
|
||||
} from "./airdrop-upload-erc20";
|
||||
import { type AirdropAddressInput, AirdropUpload } from "./airdrop-upload";
|
||||
interface TokenAirdropFormProps {
|
||||
contract: ThirdwebContract;
|
||||
toggle?: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -25,7 +22,7 @@ export const TokenAirdropForm: React.FC<TokenAirdropFormProps> = ({
|
||||
toggle,
|
||||
}) => {
|
||||
const { handleSubmit, setValue, watch } = useForm<{
|
||||
addresses: ERC20AirdropAddressInput[];
|
||||
addresses: AirdropAddressInput[];
|
||||
}>({
|
||||
defaultValues: { addresses: [] },
|
||||
});
|
||||
@@ -41,55 +38,60 @@ export const TokenAirdropForm: React.FC<TokenAirdropFormProps> = ({
|
||||
<div className="pt-3">
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => {
|
||||
trackEvent({
|
||||
category: "token",
|
||||
action: "airdrop",
|
||||
label: "attempt",
|
||||
contractAddress: contract.address,
|
||||
});
|
||||
const tx = transferBatch({
|
||||
contract,
|
||||
batch: data.addresses
|
||||
.filter((address) => address.quantity !== undefined)
|
||||
.map((address) => ({
|
||||
to: address.address,
|
||||
amount: address.quantity,
|
||||
})),
|
||||
});
|
||||
const promise = sendTransaction.mutateAsync(tx, {
|
||||
onSuccess: () => {
|
||||
trackEvent({
|
||||
category: "token",
|
||||
action: "airdrop",
|
||||
label: "success",
|
||||
contract_address: contract.address,
|
||||
});
|
||||
// Close the sheet/modal on success
|
||||
if (toggle) {
|
||||
toggle(false);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
category: "token",
|
||||
action: "airdrop",
|
||||
label: "success",
|
||||
contract_address: contract.address,
|
||||
error,
|
||||
});
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Airdropping tokens",
|
||||
success: "Tokens airdropped successfully",
|
||||
error: "Failed to airdrop tokens",
|
||||
});
|
||||
try {
|
||||
trackEvent({
|
||||
category: "token",
|
||||
action: "airdrop",
|
||||
label: "attempt",
|
||||
contractAddress: contract.address,
|
||||
});
|
||||
const tx = transferBatch({
|
||||
contract,
|
||||
batch: data.addresses
|
||||
.filter((address) => address.quantity !== undefined)
|
||||
.map((address) => ({
|
||||
to: address.address,
|
||||
amount: address.quantity,
|
||||
})),
|
||||
});
|
||||
const promise = sendTransaction.mutateAsync(tx, {
|
||||
onSuccess: () => {
|
||||
trackEvent({
|
||||
category: "token",
|
||||
action: "airdrop",
|
||||
label: "success",
|
||||
contract_address: contract.address,
|
||||
});
|
||||
// Close the sheet/modal on success
|
||||
if (toggle) {
|
||||
toggle(false);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
category: "token",
|
||||
action: "airdrop",
|
||||
label: "success",
|
||||
contract_address: contract.address,
|
||||
error,
|
||||
});
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Airdropping tokens",
|
||||
success: "Tokens airdropped successfully",
|
||||
error: "Failed to airdrop tokens",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to airdrop tokens");
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="mb-3 flex w-full flex-col gap-6 md:flex-row">
|
||||
{airdropFormOpen ? (
|
||||
<AirdropUploadERC20
|
||||
<AirdropUpload
|
||||
onClose={() => setAirdropFormOpen(false)}
|
||||
setAirdrop={(value) =>
|
||||
setValue("addresses", value, { shouldDirty: true })
|
||||
|
||||
+10
-15
@@ -33,17 +33,17 @@ import { isAddress } from "thirdweb/utils";
|
||||
import { Button, Heading, Text } from "tw-components";
|
||||
import { csvMimeTypes } from "utils/batch";
|
||||
|
||||
export interface ERC20AirdropAddressInput {
|
||||
export interface AirdropAddressInput {
|
||||
address: string;
|
||||
quantity: string;
|
||||
isValid?: boolean;
|
||||
}
|
||||
interface AirdropUploadProps {
|
||||
setAirdrop: (airdrop: ERC20AirdropAddressInput[]) => void;
|
||||
setAirdrop: (airdrop: AirdropAddressInput[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
async function checkIsAddress(
|
||||
item: ERC20AirdropAddressInput,
|
||||
item: AirdropAddressInput,
|
||||
thirdwebClient: ThirdwebClient,
|
||||
) {
|
||||
const { address, ...rest } = item;
|
||||
@@ -75,10 +75,7 @@ async function checkIsAddress(
|
||||
};
|
||||
}
|
||||
function processAirdropData(
|
||||
data: (
|
||||
| (ERC20AirdropAddressInput & { resolvedAddress: string })
|
||||
| undefined
|
||||
)[],
|
||||
data: ((AirdropAddressInput & { resolvedAddress: string }) | undefined)[],
|
||||
) {
|
||||
const seen = new Set();
|
||||
const filteredData = data
|
||||
@@ -95,14 +92,12 @@ function processAirdropData(
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export const AirdropUploadERC20: React.FC<AirdropUploadProps> = ({
|
||||
export const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
setAirdrop,
|
||||
onClose,
|
||||
}) => {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
const [validAirdrop, setValidAirdrop] = useState<ERC20AirdropAddressInput[]>(
|
||||
[],
|
||||
);
|
||||
const [validAirdrop, setValidAirdrop] = useState<AirdropAddressInput[]>([]);
|
||||
const [noCsv, setNoCsv] = useState(false);
|
||||
const reset = useCallback(() => {
|
||||
setValidAirdrop([]);
|
||||
@@ -124,8 +119,8 @@ export const AirdropUploadERC20: React.FC<AirdropUploadProps> = ({
|
||||
Papa.parse(csv, {
|
||||
header: true,
|
||||
complete: (results) => {
|
||||
const data: ERC20AirdropAddressInput[] = (
|
||||
results.data as ERC20AirdropAddressInput[]
|
||||
const data: AirdropAddressInput[] = (
|
||||
results.data as AirdropAddressInput[]
|
||||
)
|
||||
.map(({ address, quantity }) => ({
|
||||
address: (address || "").trim(),
|
||||
@@ -278,7 +273,7 @@ export const AirdropUploadERC20: React.FC<AirdropUploadProps> = ({
|
||||
};
|
||||
|
||||
interface AirdropTableProps {
|
||||
data: ERC20AirdropAddressInput[];
|
||||
data: AirdropAddressInput[];
|
||||
portalRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
const AirdropTable: React.FC<AirdropTableProps> = ({ data, portalRef }) => {
|
||||
@@ -316,7 +311,7 @@ const AirdropTable: React.FC<AirdropTableProps> = ({ data, portalRef }) => {
|
||||
return quantity || "1";
|
||||
},
|
||||
},
|
||||
] as Column<ERC20AirdropAddressInput>[];
|
||||
] as Column<AirdropAddressInput>[];
|
||||
}, []);
|
||||
const {
|
||||
getTableProps,
|
||||
@@ -2,8 +2,8 @@
|
||||
import "server-only";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/env";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
|
||||
const fallbackChainIcon =
|
||||
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIHZpZXdCb3g9IjAgMCA5NiA5NiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTY4LjE1MTkgNzUuNzM3MkM2Mi4yOTQzIDc5Ljk5MyA1NS4yMzk3IDgyLjI4NTIgNDcuOTk5MyA4Mi4yODUyQzQwLjc1ODkgODIuMjg1MiAzMy43MDQzIDc5Ljk5MyAyNy44NDY2IDc1LjczNzJNNjMuMDI5MSAxNy4xODM3QzY5LjUzNjggMjAuMzU3NyA3NC44NzI2IDI1LjUxMDQgNzguMjcxOCAzMS45MDMzQzgxLjY3MDkgMzguMjk2MiA4Mi45NTkgNDUuNjAxMiA4MS45NTEzIDUyLjc3MTFNMTQuMDQ3NiA1Mi43NzA4QzEzLjAzOTkgNDUuNjAwOCAxNC4zMjggMzguMjk1OSAxNy43MjcxIDMxLjkwM0MyMS4xMjYzIDI1LjUxMDEgMjYuNDYyMSAyMC4zNTczIDMyLjk2OTggMTcuMTgzM000Ni4wNTk4IDI5LjM2NzVMMjkuMzY3MyA0Ni4wNkMyOC42ODg1IDQ2LjczODkgMjguMzQ5IDQ3LjA3ODMgMjguMjIxOCA0Ny40Njk3QzI4LjExIDQ3LjgxNCAyOC4xMSA0OC4xODQ5IDI4LjIyMTggNDguNTI5MkMyOC4zNDkgNDguOTIwNiAyOC42ODg1IDQ5LjI2MDEgMjkuMzY3MyA0OS45MzlMNDYuMDU5OCA2Ni42MzE0QzQ2LjczODcgNjcuMzEwMyA0Ny4wNzgxIDY3LjY0OTcgNDcuNDY5NSA2Ny43NzY5QzQ3LjgxMzggNjcuODg4OCA0OC4xODQ3IDY3Ljg4ODggNDguNTI5IDY3Ljc3NjlDNDguOTIwNCA2Ny42NDk3IDQ5LjI1OTkgNjcuMzEwMyA0OS45Mzg4IDY2LjYzMTRMNjYuNjMxMiA0OS45MzlDNjcuMzEwMSA0OS4yNjAxIDY3LjY0OTUgNDguOTIwNiA2Ny43NzY3IDQ4LjUyOTJDNjcuODg4NiA0OC4xODQ5IDY3Ljg4ODYgNDcuODE0IDY3Ljc3NjcgNDcuNDY5N0M2Ny42NDk1IDQ3LjA3ODMgNjcuMzEwMSA0Ni43Mzg5IDY2LjYzMTIgNDYuMDZMNDkuOTM4OCAyOS4zNjc1QzQ5LjI1OTkgMjguNjg4NyA0OC45MjA0IDI4LjM0OTIgNDguNTI5IDI4LjIyMkM0OC4xODQ3IDI4LjExMDIgNDcuODEzOCAyOC4xMTAyIDQ3LjQ2OTUgMjguMjIyQzQ3LjA3ODEgMjguMzQ5MiA0Ni43Mzg3IDI4LjY4ODcgNDYuMDU5OCAyOS4zNjc1WiIgc3Ryb2tlPSIjNDA0MDQwIiBzdHJva2Utd2lkdGg9IjYuODU3MTQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K";
|
||||
@@ -13,24 +13,35 @@ export async function ChainIcon(props: {
|
||||
className?: string;
|
||||
}) {
|
||||
if (props.iconUrl) {
|
||||
const resolved = resolveScheme({
|
||||
let imageLink = fallbackChainIcon;
|
||||
|
||||
const resolved = resolveSchemeWithErrorHandler({
|
||||
client: getThirdwebClient(),
|
||||
uri: props.iconUrl,
|
||||
});
|
||||
const res = await fetch(resolved, {
|
||||
// revalidate every hour
|
||||
next: { revalidate: 60 * 60 },
|
||||
method: "HEAD",
|
||||
headers: DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
}
|
||||
: {},
|
||||
}).catch(() => null);
|
||||
|
||||
if (resolved) {
|
||||
// check if it loads or not
|
||||
const res = await fetch(resolved, {
|
||||
// revalidate every hour
|
||||
next: { revalidate: 60 * 60 },
|
||||
method: "HEAD",
|
||||
headers: DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
}
|
||||
: {},
|
||||
}).catch(() => null);
|
||||
|
||||
if (res?.status === 200) {
|
||||
imageLink = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt=""
|
||||
src={res?.status === 200 ? resolved : fallbackChainIcon}
|
||||
src={imageLink}
|
||||
className={cn("object-contain", props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getActiveAccountCookie, getJWTCookie } from "@/constants/cookie";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { ContractPublishForm } from "components/contract-components/contract-publish-form";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { getPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
@@ -47,6 +47,10 @@ export default async function PublishContractPage(
|
||||
contract_id: publishMetadataFromUri.name,
|
||||
});
|
||||
|
||||
if (!publishedContractVersions) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const publishedContract = publishedContractVersions[0];
|
||||
|
||||
if (publishedContract) {
|
||||
|
||||
+4
-2
@@ -44,7 +44,7 @@ export default async function Page(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col content-start justify-between gap-4 lg:flex-row">
|
||||
<PageHeader />
|
||||
|
||||
@@ -68,7 +68,9 @@ export default async function Page(props: {
|
||||
<SmartWallets
|
||||
apiKeyServices={apiKey.services || []}
|
||||
trackingCategory="smart-wallet"
|
||||
defaultTab={props.searchParams.tab === "1" ? 1 : 0}
|
||||
tab={props.searchParams.tab}
|
||||
smartWalletsLayoutSlug={`/dashboard/connect/account-abstraction/${clientId}`}
|
||||
clientId={apiKey.key}
|
||||
/>
|
||||
|
||||
<ConnectSDKCard
|
||||
|
||||
@@ -51,7 +51,10 @@ export default async function Page(props: {
|
||||
|
||||
<div className="h-4 lg:h-8" />
|
||||
|
||||
<ConnectAnalyticsDashboard clientId={apiKey.key} />
|
||||
<ConnectAnalyticsDashboard
|
||||
clientId={apiKey.key}
|
||||
connectLayoutSlug="/dashboard/connect"
|
||||
/>
|
||||
|
||||
<div className="h-4 lg:h-8" />
|
||||
<ConnectSDKCard description="Add the Connect SDK to your app to start collecting analytics." />
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export function EcosystemPermissionsPage({
|
||||
const { ecosystem } = useEcosystem({ slug: params.slug });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-8">
|
||||
<IntegrationPermissionsSection ecosystem={ecosystem} />
|
||||
<AuthOptionsSection ecosystem={ecosystem} />
|
||||
{ecosystem?.permission === "PARTNER_WHITELIST" && (
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export function AddPartnerDialogButton(props: {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 max-sm:w-full">
|
||||
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
|
||||
+462
-207
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog";
|
||||
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { SettingsCard } from "@/components/blocks/SettingsCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -13,111 +13,114 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { FormDescription } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { getSocialIcon } from "thirdweb/wallets/in-app";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_6,
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
} from "thirdweb/wallets/smart";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { type Ecosystem, authOptions } from "../../../../types";
|
||||
import { useUpdateEcosystem } from "../../hooks/use-update-ecosystem";
|
||||
|
||||
type AuthOptionsFormData = {
|
||||
authOptions: string[];
|
||||
useCustomAuth: boolean;
|
||||
customAuthEndpoint: string;
|
||||
customHeaders: { key: string; value: string }[];
|
||||
useSmartAccount: boolean;
|
||||
chainIds: number[];
|
||||
sponsorGas: boolean;
|
||||
accountFactoryType: "v0.6" | "v0.7" | "custom";
|
||||
customAccountFactoryAddress: string;
|
||||
};
|
||||
|
||||
export function AuthOptionsForm({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
const [messageToConfirm, setMessageToConfirm] = useState<
|
||||
| {
|
||||
title: string;
|
||||
description: string;
|
||||
authOptions: typeof ecosystem.authOptions;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
const {
|
||||
mutateAsync: updateEcosystem,
|
||||
variables,
|
||||
isPending,
|
||||
} = useUpdateEcosystem({
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to update ecosystem";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-5 md:gap-2">
|
||||
{authOptions.map((option) => (
|
||||
<CheckboxWithLabel
|
||||
key={option}
|
||||
className={cn(
|
||||
isPending &&
|
||||
variables?.authOptions?.includes(option) &&
|
||||
"animate-pulse",
|
||||
"hover:cursor-pointer hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={ecosystem.authOptions?.includes(option)}
|
||||
onClick={() => {
|
||||
if (ecosystem.authOptions?.includes(option)) {
|
||||
setMessageToConfirm({
|
||||
title: `Are you sure you want to remove ${option.slice(0, 1).toUpperCase() + option.slice(1)} as an authentication option for this ecosystem?`,
|
||||
description:
|
||||
"Users will no longer be able to log into your ecosystem using this option. Any users that previously used this option will be unable to log in.",
|
||||
authOptions: ecosystem.authOptions?.filter(
|
||||
(o) => o !== option,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setMessageToConfirm({
|
||||
title: `Are you sure you want to add ${option.slice(0, 1).toUpperCase() + option.slice(1)} as an authentication option for this ecosystem?`,
|
||||
description:
|
||||
"Users will be able to log into your ecosystem using this option. If you later remove this option users that used it will no longer be able to log in.",
|
||||
authOptions: [...ecosystem.authOptions, option],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{option.slice(0, 1).toUpperCase() + option.slice(1)}
|
||||
</CheckboxWithLabel>
|
||||
))}
|
||||
<ConfirmationDialog
|
||||
open={!!messageToConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setMessageToConfirm(undefined);
|
||||
}
|
||||
}}
|
||||
title={messageToConfirm?.title}
|
||||
description={messageToConfirm?.description}
|
||||
onSubmit={() => {
|
||||
invariant(
|
||||
messageToConfirm,
|
||||
"Must have message for modal to be open",
|
||||
);
|
||||
updateEcosystem({
|
||||
...ecosystem,
|
||||
authOptions: messageToConfirm.authOptions,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CustomAuthOptionsForm ecosystem={ecosystem} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomAuthOptionsForm({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
const form = useForm({
|
||||
const form = useForm<AuthOptionsFormData>({
|
||||
defaultValues: {
|
||||
customAuthEndpoint: ecosystem.customAuthOptions?.authEndpoint?.url,
|
||||
customHeaders: ecosystem.customAuthOptions?.authEndpoint?.headers,
|
||||
authOptions: ecosystem.authOptions || [],
|
||||
useCustomAuth: !!ecosystem.customAuthOptions,
|
||||
customAuthEndpoint: ecosystem.customAuthOptions?.authEndpoint?.url || "",
|
||||
customHeaders: ecosystem.customAuthOptions?.authEndpoint?.headers || [],
|
||||
useSmartAccount: !!ecosystem.smartAccountOptions,
|
||||
chainIds: ecosystem.smartAccountOptions?.chainIds || [],
|
||||
sponsorGas: ecosystem.smartAccountOptions?.sponsorGas || false,
|
||||
accountFactoryType:
|
||||
ecosystem.smartAccountOptions?.accountFactoryAddress ===
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_7
|
||||
? "v0.7"
|
||||
: ecosystem.smartAccountOptions?.accountFactoryAddress ===
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_6
|
||||
? "v0.6"
|
||||
: "custom",
|
||||
customAccountFactoryAddress:
|
||||
ecosystem.smartAccountOptions?.accountFactoryAddress || "",
|
||||
},
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
authOptions: z.array(z.string()),
|
||||
useCustomAuth: z.boolean(),
|
||||
customAuthEndpoint: z.string().optional(),
|
||||
customHeaders: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
useSmartAccount: z.boolean(),
|
||||
chainIds: z.array(z.number()),
|
||||
sponsorGas: z.boolean(),
|
||||
accountFactoryType: z.enum(["v0.6", "v0.7", "custom"]),
|
||||
customAccountFactoryAddress: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.useSmartAccount && data.chainIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Please select at least one chain for smart accounts",
|
||||
path: ["chainIds"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.useCustomAuth && !data.customAuthEndpoint) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid custom auth endpoint",
|
||||
path: ["customAuthEndpoint"],
|
||||
},
|
||||
),
|
||||
),
|
||||
});
|
||||
const { fields, remove, append } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "customHeaders",
|
||||
});
|
||||
|
||||
const { mutateAsync: updateEcosystem, isPending } = useUpdateEcosystem({
|
||||
onError: (error) => {
|
||||
const message =
|
||||
@@ -125,126 +128,378 @@ function CustomAuthOptionsForm({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Custom Auth Options updated");
|
||||
toast.success("Ecosystem options updated");
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="font-semibold text-2xl text-foreground">
|
||||
Custom Auth Options
|
||||
</h4>
|
||||
<Card className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customAuthEndpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Endpoint</FormLabel>
|
||||
<FormDescription>
|
||||
Enter the URL for your own authentication endpoint.{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/custom-auth/configuration#generic-auth"
|
||||
>
|
||||
Learn more.
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="url"
|
||||
placeholder="https://your-custom-auth-endpoint.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customHeaders"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Headers</FormLabel>
|
||||
<FormDescription>
|
||||
Optional: Add headers for your authentication endpoint
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<div className="space-y-2">
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Header Key"
|
||||
{...form.register(`customHeaders.${index}.key`)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header Value"
|
||||
{...form.register(`customHeaders.${index}.value`)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
>
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={isPending}
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
const customAuthEndpoint =
|
||||
form.getValues("customAuthEndpoint");
|
||||
let customAuthOptions:
|
||||
| Ecosystem["customAuthOptions"]
|
||||
| undefined = undefined;
|
||||
if (customAuthEndpoint) {
|
||||
try {
|
||||
const url = new URL(customAuthEndpoint);
|
||||
invariant(url.hostname, "Invalid URL");
|
||||
} catch {
|
||||
toast.error("Invalid URL");
|
||||
return;
|
||||
}
|
||||
const customHeaders = form.getValues("customHeaders");
|
||||
customAuthOptions = {
|
||||
authEndpoint: {
|
||||
url: customAuthEndpoint,
|
||||
headers: customHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
updateEcosystem({
|
||||
...ecosystem,
|
||||
customAuthOptions,
|
||||
});
|
||||
const onSubmit = (data: AuthOptionsFormData) => {
|
||||
let customAuthOptions: Ecosystem["customAuthOptions"] | null = null;
|
||||
if (data.useCustomAuth && data.customAuthEndpoint) {
|
||||
try {
|
||||
const url = new URL(data.customAuthEndpoint);
|
||||
invariant(url.hostname, "Invalid URL");
|
||||
customAuthOptions = {
|
||||
authEndpoint: {
|
||||
url: data.customAuthEndpoint,
|
||||
headers: data.customHeaders,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
toast.error("Invalid Custom Auth URL");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let smartAccountOptions: Ecosystem["smartAccountOptions"] | null = null;
|
||||
if (data.useSmartAccount) {
|
||||
let accountFactoryAddress: string;
|
||||
switch (data.accountFactoryType) {
|
||||
case "v0.6":
|
||||
accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_6;
|
||||
break;
|
||||
case "v0.7":
|
||||
accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_7;
|
||||
break;
|
||||
case "custom":
|
||||
accountFactoryAddress = data.customAccountFactoryAddress;
|
||||
break;
|
||||
}
|
||||
|
||||
smartAccountOptions = {
|
||||
chainIds: data.chainIds,
|
||||
sponsorGas: data.sponsorGas,
|
||||
accountFactoryAddress,
|
||||
};
|
||||
}
|
||||
|
||||
updateEcosystem({
|
||||
...ecosystem,
|
||||
authOptions: data.authOptions as (typeof authOptions)[number][],
|
||||
customAuthOptions,
|
||||
smartAccountOptions,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit, (errors) => {
|
||||
console.log(errors);
|
||||
})}
|
||||
className="flex flex-col gap-8"
|
||||
>
|
||||
<SettingsCard
|
||||
bottomText=""
|
||||
errorText=""
|
||||
noPermissionText=""
|
||||
header={{
|
||||
title: "Auth Options",
|
||||
description:
|
||||
"Configure the authentication options your ecosystem supports",
|
||||
}}
|
||||
saveButton={{
|
||||
onClick: form.handleSubmit(onSubmit),
|
||||
disabled: !form.formState.isValid,
|
||||
isPending: isPending,
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{authOptions.map((option) => (
|
||||
<FormField
|
||||
key={option}
|
||||
control={form.control}
|
||||
name="authOptions"
|
||||
render={({ field }) => {
|
||||
const isChecked = field.value?.includes(option);
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center justify-center gap-3 rounded-lg border border-border p-3 hover:bg-muted",
|
||||
isChecked && "bg-muted hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getSocialIcon(option)}
|
||||
alt={option}
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<p className="text-center font-normal">
|
||||
{option === "siwe"
|
||||
? "Wallet"
|
||||
: option.slice(0, 1).toUpperCase() +
|
||||
option.slice(1)}
|
||||
</p>
|
||||
<div className="flex-1" />
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, option])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
(value) => value !== option,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
header={{
|
||||
title: "Custom Auth",
|
||||
description: "Authenticate with a custom endpoint",
|
||||
}}
|
||||
bottomText=""
|
||||
errorText=""
|
||||
noPermissionText=""
|
||||
saveButton={{
|
||||
onClick: form.handleSubmit(onSubmit),
|
||||
disabled: !form.formState.isValid,
|
||||
isPending: isPending,
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useCustomAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="absolute top-6 right-6"
|
||||
aria-label={
|
||||
field.value
|
||||
? "Custom Auth Enabled"
|
||||
: "Custom Auth Disabled"
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("useCustomAuth") && (
|
||||
<div className="mt-1 flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customAuthEndpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Endpoint</FormLabel>
|
||||
<FormDescription>
|
||||
Enter the URL for your own authentication endpoint.{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/custom-auth/configuration#generic-auth"
|
||||
>
|
||||
Learn more.
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="url"
|
||||
placeholder="https://your-custom-auth-endpoint.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customHeaders"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Headers</FormLabel>
|
||||
<FormDescription>
|
||||
Optional: Add headers for your authentication endpoint
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-3">
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="flex gap-3">
|
||||
<Input
|
||||
placeholder="Header Key"
|
||||
{...form.register(`customHeaders.${index}.key`)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header Value"
|
||||
{...form.register(`customHeaders.${index}.value`)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
className="gap-2 self-start"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
bottomText=""
|
||||
errorText=""
|
||||
noPermissionText=""
|
||||
header={{
|
||||
title: "Account Abstraction",
|
||||
description: "Enable smart accounts for your ecosystem",
|
||||
}}
|
||||
saveButton={{
|
||||
onClick: form.handleSubmit(onSubmit),
|
||||
disabled: !form.formState.isValid,
|
||||
isPending: isPending,
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useSmartAccount"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="absolute top-6 right-6"
|
||||
aria-label={
|
||||
field.value ? "Smart Accounts Enabled" : "Smart Accounts"
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("useSmartAccount") && (
|
||||
<div className="mt-1 flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chainIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Supported Chains</FormLabel>
|
||||
<FormDescription>
|
||||
Select the chains you want to support for smart accounts
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<div className="w-full bg-background">
|
||||
<MultiNetworkSelector
|
||||
selectedChainIds={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sponsorGas"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Sponsor Gas</FormLabel>
|
||||
<FormDescription>
|
||||
Enable gas sponsorship for smart accounts
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accountFactoryType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Factory</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select account factory type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="v0.6">
|
||||
Default Account Factory (v0.6)
|
||||
</SelectItem>
|
||||
<SelectItem value="v0.7">
|
||||
Default Account Factory (v0.7)
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">Custom factory</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose a default account factory or select custom to enter
|
||||
your own address
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("accountFactoryType") === "custom" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customAccountFactoryAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom Account Factory Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="0x..." />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your own smart account factory contract address
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
-6
@@ -14,6 +14,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TabLinks } from "@/components/ui/tabs";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckIcon,
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
import { useEcosystemList } from "../../../../hooks/use-ecosystem-list";
|
||||
import type { Ecosystem } from "../../../../types";
|
||||
import { useEcosystem } from "../../hooks/use-ecosystem";
|
||||
@@ -127,6 +127,11 @@ export function EcosystemHeader(props: {
|
||||
|
||||
const ecosystem = fetchedEcosystem ?? props.ecosystem;
|
||||
|
||||
const ecosystemImageLink = resolveSchemeWithErrorHandler({
|
||||
uri: ecosystem.imageUrl,
|
||||
client,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<EcosystemAlertBanner ecosystem={ecosystem} />
|
||||
@@ -136,13 +141,10 @@ export function EcosystemHeader(props: {
|
||||
{!ecosystem.imageUrl ? (
|
||||
<Skeleton className="size-24" />
|
||||
) : (
|
||||
ecosystem.imageUrl && (
|
||||
ecosystemImageLink && (
|
||||
<div className="relative size-24 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src={resolveScheme({
|
||||
uri: ecosystem.imageUrl,
|
||||
client,
|
||||
})}
|
||||
src={ecosystemImageLink}
|
||||
sizes="100px"
|
||||
alt={ecosystem.name}
|
||||
fill
|
||||
|
||||
-13
@@ -1,4 +1,3 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { Ecosystem } from "../../../../types";
|
||||
import {
|
||||
AuthOptionsForm,
|
||||
@@ -8,18 +7,6 @@ import {
|
||||
export function AuthOptionsSection({ ecosystem }: { ecosystem?: Ecosystem }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="font-semibold text-2xl text-foreground">
|
||||
Authentication Options
|
||||
</h4>
|
||||
{ecosystem ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure the authentication options your ecosystem supports.
|
||||
</p>
|
||||
) : (
|
||||
<Skeleton className="h-6 w-[300px]" />
|
||||
)}
|
||||
</div>
|
||||
{ecosystem ? (
|
||||
<AuthOptionsForm ecosystem={ecosystem} />
|
||||
) : (
|
||||
|
||||
+8
-8
@@ -6,24 +6,24 @@ export function EcosystemPartnersSection({
|
||||
ecosystem,
|
||||
}: { ecosystem: Ecosystem }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-8 lg:gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="font-semibold text-2xl text-foreground">
|
||||
<div className="rounded-lg border border-border bg-muted/50 px-4 py-6 lg:px-6">
|
||||
<div className="flex flex-col items-start justify-between max-sm:mb-5 lg:flex-row">
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
Ecosystem partners
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-sm leading-normal">
|
||||
</h3>
|
||||
|
||||
<p className="mt-1.5 mb-5 text-foreground text-sm">
|
||||
Configure apps that can use your wallet. Creating a partner will
|
||||
generate a unique partner ID that can access your ecosystem. <br />{" "}
|
||||
You will need to generate at least one partner ID for use in your
|
||||
own app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AddPartnerDialogButton ecosystem={ecosystem} />
|
||||
</div>
|
||||
|
||||
<PartnersTable ecosystem={ecosystem} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-21
@@ -1,4 +1,3 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { Ecosystem } from "../../../../types";
|
||||
import {
|
||||
IntegrationPermissionsToggle,
|
||||
@@ -9,26 +8,26 @@ export function IntegrationPermissionsSection({
|
||||
ecosystem,
|
||||
}: { ecosystem?: Ecosystem }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="font-semibold text-2xl text-foreground">
|
||||
Integration permissions
|
||||
</h4>
|
||||
{ecosystem ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{ecosystem.permission === "PARTNER_WHITELIST"
|
||||
? "Your ecosystem has an allowlist. Only preset partners can add your wallet to their app."
|
||||
: "Your ecosystem is public. Anyone can add your wallet to their app."}
|
||||
</p>
|
||||
) : (
|
||||
<Skeleton className="h-6 w-[300px]" />
|
||||
)}
|
||||
</div>
|
||||
{ecosystem ? (
|
||||
<IntegrationPermissionsToggle ecosystem={ecosystem} />
|
||||
) : (
|
||||
<IntegrationPermissionsToggleSkeleton />
|
||||
<div className="relative rounded-lg border border-border bg-muted/50 px-4 py-6 lg:px-6">
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
Integration Permissions
|
||||
</h3>
|
||||
|
||||
{ecosystem && (
|
||||
<p className="mt-1.5 mb-4 text-foreground text-sm">
|
||||
{ecosystem.permission === "PARTNER_WHITELIST"
|
||||
? "Your ecosystem has an allowlist. Only preset partners can add your wallet to their app."
|
||||
: "Your ecosystem is public. Anyone can add your wallet to their app."}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 md:gap-8">
|
||||
{ecosystem ? (
|
||||
<IntegrationPermissionsToggle ecosystem={ecosystem} />
|
||||
) : (
|
||||
<IntegrationPermissionsToggleSkeleton />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-8
@@ -83,32 +83,32 @@ function PartnerRow(props: {
|
||||
isDeleting && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
<TableCell className="max-w-32 truncate align-top">
|
||||
<TableCell className="max-w-32 truncate align-center">
|
||||
{props.partner.name}
|
||||
</TableCell>
|
||||
<TableCell className="hidden max-w-32 text-wrap align-top md:table-cell">
|
||||
<TableCell className="hidden max-w-32 text-wrap align-center md:table-cell">
|
||||
{props.partner.allowlistedDomains.map((domain) => (
|
||||
<div key={domain}>{domain}</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="hidden max-w-32 align-top md:table-cell">
|
||||
<TableCell className="hidden max-w-32 align-center md:table-cell">
|
||||
{props.partner.allowlistedBundleIds.map((domain) => (
|
||||
<div key={domain} className="truncate">
|
||||
{domain}
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="hidden max-w-32 align-top sm:table-cell">
|
||||
<TableCell className="hidden max-w-32 align-center sm:table-cell">
|
||||
<ToolTipLabel label={props.partner.id}>
|
||||
<div className="truncate">
|
||||
<div className="flex items-center ">
|
||||
<CopyButton text={props.partner.id} className="mr-1" />
|
||||
{props.partner.id}
|
||||
<span className="truncate">{props.partner.id}</span>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
</TableCell>
|
||||
|
||||
<td className="table-cell py-1 align-middle">
|
||||
<div className="flex justify-end gap-1.5 pr-1.5">
|
||||
<td className="table-cell py-3 align-middle">
|
||||
<div className="flex justify-end gap-3 pr-3">
|
||||
<UpdatePartnerModal
|
||||
partner={props.partner}
|
||||
ecosystem={props.ecosystem}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
export const authOptions = [
|
||||
"email",
|
||||
"phone",
|
||||
"passkey",
|
||||
"siwe",
|
||||
"guest",
|
||||
"google",
|
||||
"facebook",
|
||||
"x",
|
||||
"discord",
|
||||
"farcaster",
|
||||
"telegram",
|
||||
"github",
|
||||
"twitch",
|
||||
"phone",
|
||||
"email",
|
||||
"guest",
|
||||
"apple",
|
||||
"coinbase",
|
||||
"line",
|
||||
@@ -30,12 +33,12 @@ export type Ecosystem = {
|
||||
jwksUri: string;
|
||||
aud: string;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
smartAccountOptions?: {
|
||||
chainIds: number[];
|
||||
sponsorGas: boolean;
|
||||
accountFactoryAddress: string;
|
||||
};
|
||||
} | null;
|
||||
url: string;
|
||||
status: "active" | "requested" | "paymentFailed";
|
||||
createdAt: string;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -7,12 +6,16 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { ContractCard } from "components/explore/contract-card";
|
||||
import {
|
||||
ContractCard,
|
||||
ContractCardSkeleton,
|
||||
} from "components/explore/contract-card";
|
||||
import { DeployUpsellCard } from "components/explore/upsells/deploy-your-own";
|
||||
import { ALL_CATEGORIES, getCategory } from "data/explore";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
type ExploreCategoryPageProps = {
|
||||
params: {
|
||||
@@ -101,35 +104,36 @@ export default async function ExploreCategoryPage(
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractCard
|
||||
<Suspense
|
||||
fallback={<ContractCardSkeleton />}
|
||||
key={publisher + contractId + overrides?.title}
|
||||
publisher={publisher}
|
||||
contractId={contractId}
|
||||
titleOverride={overrides?.title}
|
||||
descriptionOverride={overrides?.description}
|
||||
tracking={{
|
||||
source: category.id,
|
||||
itemIndex: `${idx}`,
|
||||
}}
|
||||
isBeta={category.isBeta}
|
||||
modules={
|
||||
modules?.length
|
||||
? modules.map((m) => ({
|
||||
publisher: m.split("/")[0] || "",
|
||||
moduleId: m.split("/")[1] || "",
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
>
|
||||
<ContractCard
|
||||
publisher={publisher}
|
||||
contractId={contractId}
|
||||
titleOverride={overrides?.title}
|
||||
descriptionOverride={overrides?.description}
|
||||
tracking={{
|
||||
source: category.id,
|
||||
itemIndex: `${idx}`,
|
||||
}}
|
||||
isBeta={category.isBeta}
|
||||
modules={
|
||||
modules?.length
|
||||
? modules.map((m) => ({
|
||||
publisher: m.split("/")[0] || "",
|
||||
moduleId: m.split("/")[1] || "",
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-16" />
|
||||
{/* TODO: remove this once we update the deploy upsell card */}
|
||||
<ChakraProviderSetup>
|
||||
<DeployUpsellCard />
|
||||
</ChakraProviderSetup>
|
||||
<DeployUpsellCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -140,3 +144,6 @@ export async function generateStaticParams() {
|
||||
params: { category },
|
||||
}));
|
||||
}
|
||||
|
||||
// TODO - figure out why this page is not building if we let it be static
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -53,11 +52,11 @@ export default async function ExplorePage() {
|
||||
</div>
|
||||
|
||||
<div className="h-16" />
|
||||
{/* TODO: remove this once we update the deploy upsell card */}
|
||||
<ChakraProviderSetup>
|
||||
<DeployUpsellCard />
|
||||
</ChakraProviderSetup>
|
||||
<DeployUpsellCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO - figure out why this page is not building if we let it be static
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
+4
@@ -28,6 +28,10 @@ export default async function Image(props: {
|
||||
fetchPublisherProfile(publisher),
|
||||
]);
|
||||
|
||||
if (!publishedContracts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publishedContract =
|
||||
publishedContracts.find((p) => p.version === props.params.version) ||
|
||||
publishedContracts[0];
|
||||
|
||||
+15
-3
@@ -29,12 +29,24 @@ export default async function PublishedContractPage(
|
||||
props: PublishedContractDeployPageProps,
|
||||
) {
|
||||
// resolve ENS if required
|
||||
const publisherAddress = isAddress(props.params.publisher)
|
||||
? props.params.publisher
|
||||
: await resolveAddress({
|
||||
let publisherAddress: string | undefined = undefined;
|
||||
|
||||
if (isAddress(props.params.publisher)) {
|
||||
publisherAddress = props.params.publisher;
|
||||
} else {
|
||||
try {
|
||||
publisherAddress = await resolveAddress({
|
||||
client: getThirdwebClient(),
|
||||
name: mapThirdwebPublisher(props.params.publisher),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!publisherAddress) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// get all the published versions of the contract
|
||||
const publishedContractVersions = await fetchPublishedContractVersions(
|
||||
|
||||
+5
@@ -1,3 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PublishedContractBreadcrumbs } from "./components/breadcrumbs.client";
|
||||
import { getPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
@@ -23,6 +24,10 @@ export async function generateMetadata({ params }: { params: Params }) {
|
||||
contract_id: contract_id,
|
||||
});
|
||||
|
||||
if (!publishedContracts) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const publishedContract = publishedContracts[0];
|
||||
|
||||
if (!publishedContract) {
|
||||
|
||||
+4
@@ -27,6 +27,10 @@ export default async function Image(props: {
|
||||
fetchPublisherProfile(publisher),
|
||||
]);
|
||||
|
||||
if (!publishedContracts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publishedContract = publishedContracts[0];
|
||||
|
||||
if (!publishedContract) {
|
||||
|
||||
+4
@@ -22,6 +22,10 @@ export default async function PublishedContractPage(
|
||||
contract_id: props.params.contract_id,
|
||||
});
|
||||
|
||||
if (!publishedContractVersions) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const publishedContract = publishedContractVersions[0];
|
||||
|
||||
if (!publishedContract) {
|
||||
|
||||
+17
-13
@@ -16,19 +16,23 @@ export async function getPublishedContractsWithPublisherMapping(options: {
|
||||
}) {
|
||||
const { publisher, contract_id } = options;
|
||||
|
||||
// resolve ENS
|
||||
const publisherAddress = isAddress(publisher)
|
||||
? publisher
|
||||
: await resolveAddress({
|
||||
client: getThirdwebClient(),
|
||||
name: mapThirdwebPublisher(publisher),
|
||||
});
|
||||
try {
|
||||
// resolve ENS
|
||||
const publisherAddress = isAddress(publisher)
|
||||
? publisher
|
||||
: await resolveAddress({
|
||||
client: getThirdwebClient(),
|
||||
name: mapThirdwebPublisher(publisher),
|
||||
});
|
||||
|
||||
// get all the published versions of the contract
|
||||
const publishedContractVersions = await fetchPublishedContractVersions(
|
||||
publisherAddress,
|
||||
contract_id,
|
||||
);
|
||||
// get all the published versions of the contract
|
||||
const publishedContractVersions = await fetchPublishedContractVersions(
|
||||
publisherAddress,
|
||||
contract_id,
|
||||
);
|
||||
|
||||
return publishedContractVersions;
|
||||
return publishedContractVersions;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
|
||||
export function DeployContractInfo(props: {
|
||||
name: string;
|
||||
@@ -9,20 +9,18 @@ export function DeployContractInfo(props: {
|
||||
}) {
|
||||
const contractNameDisplay = props.displayName || props.name;
|
||||
|
||||
const contractImageLink = resolveSchemeWithErrorHandler({
|
||||
client: getThirdwebClient(),
|
||||
uri: props.logo,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
{props.logo && (
|
||||
{contractImageLink && (
|
||||
<div className="hidden shrink-0 items-center justify-center rounded-xl border border-border p-2 md:flex">
|
||||
{/*eslint-disable-next-line @next/next/no-img-element*/}
|
||||
<img
|
||||
className="size-12"
|
||||
alt={props.name}
|
||||
src={resolveScheme({
|
||||
client: getThirdwebClient(),
|
||||
uri: props.logo,
|
||||
})}
|
||||
/>
|
||||
<img className="size-12" alt={props.name} src={contractImageLink} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { SupportForm_SelectInput } from "components/help/contact-forms/shared/SupportForm_SelectInput";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { createTicketAction } from "./create-ticket.action";
|
||||
|
||||
const ConnectSupportForm = dynamic(
|
||||
() => import("../../../../components/help/contact-forms/connect"),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
const EngineSupportForm = dynamic(
|
||||
() => import("../../../../components/help/contact-forms/engine"),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
const ContractSupportForm = dynamic(
|
||||
() => import("../../../../components/help/contact-forms/contracts"),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
const AccountSupportForm = dynamic(
|
||||
() => import("../../../../components/help/contact-forms/account"),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
const OtherSupportForm = dynamic(
|
||||
() => import("../../../../components/help/contact-forms/other"),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const productOptions: { label: string; component: ReactElement }[] = [
|
||||
{
|
||||
label: "Connect",
|
||||
component: <ConnectSupportForm />,
|
||||
},
|
||||
{
|
||||
label: "Engine",
|
||||
component: <EngineSupportForm />,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
component: <ContractSupportForm />,
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
component: <AccountSupportForm />,
|
||||
},
|
||||
{
|
||||
label: "Other",
|
||||
component: <OtherSupportForm />,
|
||||
},
|
||||
];
|
||||
|
||||
export function CreateTicket() {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [state, formAction] = useFormState(createTicketAction, {
|
||||
message: "",
|
||||
success: false,
|
||||
});
|
||||
const { user } = useLoggedInUser();
|
||||
// needed here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!state.message) {
|
||||
return;
|
||||
}
|
||||
if (state.success) {
|
||||
toast.success(state.message);
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
toast.error(state.message);
|
||||
}
|
||||
}, [state.success, state.message]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
formRef.current?.reset();
|
||||
setProductLabel("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" className="mt-6" size="lg">
|
||||
Get Support
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
{/* Check this */}
|
||||
<DialogContent className="max-h-full overflow-x-auto">
|
||||
<form ref={formRef} action={formAction}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Get in touch</DialogTitle>
|
||||
<DialogDescription>
|
||||
We are here to help. Ask product questions, report problems, or
|
||||
leave feedback.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* render the form */}
|
||||
{user?.jwt ? (
|
||||
<>
|
||||
<div className="mt-3 flex flex-col gap-4 py-4">
|
||||
<SupportForm_SelectInput
|
||||
formLabel="What do you need help with?"
|
||||
name="product"
|
||||
options={productOptions.map((o) => o.label)}
|
||||
promptText="Select a product"
|
||||
onValueChange={setProductLabel}
|
||||
value={productLabel}
|
||||
required={true}
|
||||
/>
|
||||
{
|
||||
productOptions.find((o) => o.label === productLabel)
|
||||
?.component
|
||||
}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
formRef.current?.reset();
|
||||
setProductLabel("");
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<SubmitButton />
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-col gap-2 py-4">
|
||||
<Button variant="default" asChild size="lg">
|
||||
<Link href={`/login?next=${encodeURIComponent("/support")}`}>
|
||||
Sign In
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-warning-foreground text-xs">
|
||||
Please sign in to create a ticket
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button type="submit" disabled={pending} className="flex flex-row gap-2">
|
||||
{pending && <Spinner className="size-4" />}
|
||||
{pending ? "Submitting" : "Submit"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SupportForm_SelectInput } from "components/help/contact-forms/shared/SupportForm_SelectInput";
|
||||
import dynamic from "next/dynamic";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { createTicketAction } from "./create-ticket.action";
|
||||
|
||||
const ConnectSupportForm = dynamic(
|
||||
() => import("../../../../../components/help/contact-forms/connect"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
},
|
||||
);
|
||||
const EngineSupportForm = dynamic(
|
||||
() => import("../../../../../components/help/contact-forms/engine"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
},
|
||||
);
|
||||
const ContractSupportForm = dynamic(
|
||||
() => import("../../../../../components/help/contact-forms/contracts"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
},
|
||||
);
|
||||
const AccountSupportForm = dynamic(
|
||||
() => import("../../../../../components/help/contact-forms/account"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
},
|
||||
);
|
||||
const OtherSupportForm = dynamic(
|
||||
() => import("../../../../../components/help/contact-forms/other"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
},
|
||||
);
|
||||
|
||||
const productOptions: { label: string; component: ReactElement }[] = [
|
||||
{
|
||||
label: "Connect",
|
||||
component: <ConnectSupportForm />,
|
||||
},
|
||||
{
|
||||
label: "Engine",
|
||||
component: <EngineSupportForm />,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
component: <ContractSupportForm />,
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
component: <AccountSupportForm />,
|
||||
},
|
||||
{
|
||||
label: "Other",
|
||||
component: <OtherSupportForm />,
|
||||
},
|
||||
];
|
||||
|
||||
export function CreateTicket() {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
|
||||
const [state, formAction] = useFormState(createTicketAction, {
|
||||
message: "",
|
||||
success: false,
|
||||
});
|
||||
// needed here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!state.message) {
|
||||
return;
|
||||
}
|
||||
if (state.success) {
|
||||
toast.success(state.message);
|
||||
} else {
|
||||
toast.error(state.message);
|
||||
}
|
||||
}, [state.success, state.message]);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
action={formAction}
|
||||
className="rounded-lg border bg-muted/50"
|
||||
>
|
||||
<div className="px-4 py-6 lg:px-6">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Get Support</h2>
|
||||
<p className="text-muted-foreground">
|
||||
We are here to help. Ask product questions, report problems, or leave
|
||||
feedback.
|
||||
</p>
|
||||
|
||||
<div className="h-7" />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<SupportForm_SelectInput
|
||||
formLabel="What do you need help with?"
|
||||
name="product"
|
||||
options={productOptions.map((o) => o.label)}
|
||||
promptText="Select a product"
|
||||
onValueChange={setProductLabel}
|
||||
value={productLabel}
|
||||
required={true}
|
||||
/>
|
||||
{productOptions.find((o) => o.label === productLabel)?.component}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-7" />
|
||||
|
||||
<div className="flex justify-end gap-3 border-t px-4 py-6 lg:px-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
formRef.current?.reset();
|
||||
setProductLabel("");
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<SubmitButton />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="flex min-w-24 flex-row gap-2"
|
||||
>
|
||||
{pending && <Spinner className="size-4" />}
|
||||
{pending ? "Submitting" : "Submit"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { CreateTicket } from "./components/create-ticket.client";
|
||||
|
||||
export default function Page() {
|
||||
const authToken = getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return redirect(
|
||||
`/login?next=${encodeURIComponent("/support/create-ticket")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb className="border-border border-b px-6 py-4">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/support">Support</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Get Support</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="container max-w-[750px] py-10">
|
||||
<CreateTicket />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import discordIllustration from "../../../../public/assets/support/discord-illus
|
||||
import engineIcon from "../../../../public/assets/support/engine.png";
|
||||
import miscIcon from "../../../../public/assets/support/misc.svg";
|
||||
import connectIcon from "../../../../public/assets/support/wallets.png";
|
||||
import { CreateTicket } from "./components/create-ticket.client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "thirdweb Support",
|
||||
@@ -139,7 +138,9 @@ export default function SupportPage() {
|
||||
Our dedicated support team is here to help you with any questions
|
||||
or issues you may have. Contact us today and let us assist you.
|
||||
</p>
|
||||
<CreateTicket />
|
||||
<Button variant="default" className="mt-6" size="lg" asChild>
|
||||
<Link href="/support/create-ticket">Get Support</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const customClaimAmounts: Record<number, number> = {
|
||||
// Aavegotchi Polter
|
||||
631571: 0.1,
|
||||
// Aleph Zero
|
||||
2039: 0.1,
|
||||
};
|
||||
|
||||
const defaultClaimAmount = 0.01;
|
||||
|
||||
export function getFaucetClaimAmount(chainId: number) {
|
||||
return customClaimAmounts[chainId] || defaultClaimAmount;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { startOfToday } from "date-fns";
|
||||
import { cacheGet, cacheSet } from "lib/redis";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { getFaucetClaimAmount } from "./claim-amount";
|
||||
|
||||
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
@@ -109,6 +110,7 @@ export const POST = async (req: NextRequest) => {
|
||||
);
|
||||
const todayUTCSeconds = Math.floor(todayUTC.getTime() / 1000);
|
||||
const idempotencyKey = `${ipCacheKey}:${todayUTCSeconds}`;
|
||||
const amountToClaim = getFaucetClaimAmount(chainId).toString();
|
||||
|
||||
try {
|
||||
// Store the claim request for 24 hours.
|
||||
@@ -129,7 +131,7 @@ export const POST = async (req: NextRequest) => {
|
||||
body: JSON.stringify({
|
||||
to: toAddress,
|
||||
currencyAddress: ZERO_ADDRESS,
|
||||
amount: "0.01",
|
||||
amount: amountToClaim,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -144,5 +146,5 @@ export const POST = async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ amount: "0.01" }, { status: 200 });
|
||||
return NextResponse.json({ amount: amountToClaim }, { status: 200 });
|
||||
};
|
||||
|
||||
+6
-8
@@ -7,11 +7,11 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
|
||||
type UpdateTeamField = (team: Partial<Team>) => Promise<void>;
|
||||
|
||||
@@ -152,12 +152,10 @@ function TeamAvatarFormControl(props: {
|
||||
avatar: string | undefined;
|
||||
}) {
|
||||
const client = useThirdwebClient();
|
||||
const teamUrl = props.avatar
|
||||
? resolveScheme({
|
||||
client: client,
|
||||
uri: props.avatar,
|
||||
})
|
||||
: undefined;
|
||||
const teamAvatarUrl = resolveSchemeWithErrorHandler({
|
||||
client: client,
|
||||
uri: props.avatar,
|
||||
});
|
||||
|
||||
const [teamAvatar, setTeamAvatar] = useState<File | undefined>();
|
||||
|
||||
@@ -200,7 +198,7 @@ function TeamAvatarFormControl(props: {
|
||||
setValue={setTeamAvatar}
|
||||
className="w-20 rounded-full lg:w-28"
|
||||
disableHelperText
|
||||
fileUrl={teamUrl}
|
||||
fileUrl={teamAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
+12
-6
@@ -10,10 +10,10 @@ import {
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
|
||||
import { SmartWallets } from "components/smart-wallets";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useActiveWalletChain } from "thirdweb/react";
|
||||
import { AccountFactories } from "../../../../../../components/smart-wallets/AccountFactories";
|
||||
import { AAFooterSection } from "./AAFooterSection";
|
||||
import { isOpChainId } from "./isOpChain";
|
||||
|
||||
@@ -22,7 +22,11 @@ const TRACKING_CATEGORY = "smart-wallet";
|
||||
// TODO - the factories shown on this page is not project specific, need to revamp this page
|
||||
|
||||
export function AccountAbstractionPage(props: {
|
||||
projectSlug: string;
|
||||
teamSlug: string;
|
||||
projectKey: string;
|
||||
apiKeyServices: ApiKeyService[];
|
||||
tab?: string;
|
||||
}) {
|
||||
const { apiKeyServices } = props;
|
||||
const looggedInUserQuery = useLoggedInUser();
|
||||
@@ -48,7 +52,6 @@ export function AccountAbstractionPage(props: {
|
||||
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
Account Abstraction
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Easily integrate Account abstraction (ERC-4337) compliant smart accounts
|
||||
into your apps.{" "}
|
||||
@@ -62,9 +65,7 @@ export function AccountAbstractionPage(props: {
|
||||
View Documentation
|
||||
</TrackedLinkTW>
|
||||
</p>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{looggedInUserQuery.isPending ? (
|
||||
<div className="flex h-[400px] items-center justify-center rounded-lg border border-border">
|
||||
<Spinner className="size-14" />
|
||||
@@ -89,10 +90,15 @@ export function AccountAbstractionPage(props: {
|
||||
)
|
||||
)}
|
||||
|
||||
<AccountFactories trackingCategory={TRACKING_CATEGORY} />
|
||||
<SmartWallets
|
||||
smartWalletsLayoutSlug={`/team/${props.teamSlug}/${props.projectSlug}/connect/account-abstraction`}
|
||||
apiKeyServices={apiKeyServices}
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
clientId={props.projectKey}
|
||||
tab={props.tab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-14" />
|
||||
<AAFooterSection trackingCategory={TRACKING_CATEGORY} />
|
||||
</div>
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@ import { AccountAbstractionPage } from "./AccountAbstractionPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: { team_slug: string; project_slug: string };
|
||||
searchParams: { tab?: string };
|
||||
}) {
|
||||
const { team_slug, project_slug } = props.params;
|
||||
const project = await getProject(team_slug, project_slug);
|
||||
@@ -22,7 +23,13 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<AccountAbstractionPage apiKeyServices={apiKey.services || []} />
|
||||
<AccountAbstractionPage
|
||||
projectSlug={project.slug}
|
||||
teamSlug={team_slug}
|
||||
projectKey={project.publishableKey}
|
||||
apiKeyServices={apiKey.services || []}
|
||||
tab={props.searchParams.tab}
|
||||
/>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
+11
-153
@@ -1,24 +1,22 @@
|
||||
"use client";
|
||||
import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useUserOpUsageAggregate,
|
||||
useUserOpUsagePeriod,
|
||||
useWalletUsageAggregate,
|
||||
useWalletUsagePeriod,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { differenceInDays, format, subDays } from "date-fns";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
type Range,
|
||||
getLastNDaysRange,
|
||||
} from "components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "components/analytics/interval-selector";
|
||||
import { differenceInDays } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import { ConnectAnalyticsDashboardUI } from "./ConnectAnalyticsDashboardUI";
|
||||
|
||||
export function ConnectAnalyticsDashboard(props: {
|
||||
clientId: string;
|
||||
connectLayoutSlug: string;
|
||||
}) {
|
||||
const [range, setRange] = useState<Range>(() =>
|
||||
getLastNDaysRange("last-120"),
|
||||
@@ -26,7 +24,6 @@ export function ConnectAnalyticsDashboard(props: {
|
||||
|
||||
// use date-fns to calculate the number of days in the range
|
||||
const daysInRange = differenceInDays(range.to, range.from);
|
||||
|
||||
const [intervalType, setIntervalType] = useState<"day" | "week">(
|
||||
daysInRange > 30 ? "week" : "day",
|
||||
);
|
||||
@@ -44,17 +41,8 @@ export function ConnectAnalyticsDashboard(props: {
|
||||
to: range.to,
|
||||
});
|
||||
|
||||
const userOpUsageQuery = useUserOpUsagePeriod({
|
||||
const userOpAggregateQuery = useUserOpUsageAggregate({
|
||||
clientId: props.clientId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: intervalType,
|
||||
});
|
||||
|
||||
const userOpUsageAggregateQuery = useUserOpUsageAggregate({
|
||||
clientId: props.clientId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -77,8 +65,8 @@ export function ConnectAnalyticsDashboard(props: {
|
||||
<ConnectAnalyticsDashboardUI
|
||||
walletUsage={walletUsageQuery.data || []}
|
||||
aggregateWalletUsage={walletUsageAggregateQuery.data || []}
|
||||
userOpUsage={userOpUsageQuery.data || []}
|
||||
aggregateUserOpUsage={userOpUsageAggregateQuery.data || []}
|
||||
aggregateUserOpUsageQuery={userOpAggregateQuery.data}
|
||||
connectLayoutSlug={props.connectLayoutSlug}
|
||||
isPending={
|
||||
walletUsageQuery.isPending || walletUsageAggregateQuery.isPending
|
||||
}
|
||||
@@ -86,133 +74,3 @@ export function ConnectAnalyticsDashboard(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const durationPresets = [
|
||||
{
|
||||
name: "Last 7 Days",
|
||||
id: "last-7",
|
||||
days: 7,
|
||||
},
|
||||
{
|
||||
name: "Last 30 Days",
|
||||
id: "last-30",
|
||||
days: 30,
|
||||
},
|
||||
{
|
||||
name: "Last 60 Days",
|
||||
id: "last-60",
|
||||
days: 60,
|
||||
},
|
||||
{
|
||||
name: "Last 120 Days",
|
||||
id: "last-120",
|
||||
days: 120,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type DurationId = (typeof durationPresets)[number]["id"];
|
||||
|
||||
type Range = {
|
||||
type: DurationId | "custom";
|
||||
label?: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
|
||||
function getLastNDaysRange(id: DurationId) {
|
||||
const durationInfo = durationPresets.find((preset) => preset.id === id);
|
||||
if (!durationInfo) {
|
||||
throw new Error("Invalid duration id");
|
||||
}
|
||||
|
||||
const todayDate = new Date();
|
||||
|
||||
const value: Range = {
|
||||
type: id,
|
||||
from: subDays(todayDate, durationInfo.days),
|
||||
to: todayDate,
|
||||
label: durationInfo.name,
|
||||
};
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function DateRangeSelector(props: {
|
||||
range: Range;
|
||||
setRange: (range: Range) => void;
|
||||
}) {
|
||||
const { range, setRange } = props;
|
||||
|
||||
return (
|
||||
<DatePickerWithRange
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
setFrom={(from) =>
|
||||
setRange({
|
||||
from,
|
||||
to: range.to,
|
||||
type: "custom",
|
||||
})
|
||||
}
|
||||
setTo={(to) =>
|
||||
setRange({
|
||||
from: range.from,
|
||||
to,
|
||||
type: "custom",
|
||||
})
|
||||
}
|
||||
header={
|
||||
<div className="mb-2 border-border border-b p-4">
|
||||
<Select
|
||||
value={range.type}
|
||||
onValueChange={(id: DurationId) => {
|
||||
setRange(getLastNDaysRange(id));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex bg-transparent">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{durationPresets.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{range.type === "custom" && (
|
||||
<SelectItem value="custom">
|
||||
{format(range.from, "LLL dd, y")} -{" "}
|
||||
{format(range.to, "LLL dd, y")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
labelOverride={range.label}
|
||||
className="w-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IntervalSelector(props: {
|
||||
intervalType: "day" | "week";
|
||||
setIntervalType: (intervalType: "day" | "week") => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={props.intervalType}
|
||||
onValueChange={(value: "day" | "week") => {
|
||||
props.setIntervalType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-auto hover:bg-muted">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="week"> Weekly </SelectItem>
|
||||
<SelectItem value="day"> Daily</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-73
@@ -1,23 +1,19 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { UserOpStats, WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
ActivityIcon,
|
||||
CableIcon,
|
||||
CoinsIcon,
|
||||
WalletCardsIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Stat } from "components/analytics/stat";
|
||||
import { AccountAbstractionSummary } from "components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
|
||||
import { CableIcon, WalletCardsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { DailyConnectionsChartCard } from "./_components/DailyConnectionsChartCard";
|
||||
import { SponsoredTransactionsChartCard } from "./_components/SponsoredTransactionsChartCard";
|
||||
import { TotalSponsoredChartCard } from "./_components/TotalSponsoredChartCard";
|
||||
import { WalletConnectorsChartCard } from "./_components/WalletConnectorsChartCard";
|
||||
import { WalletDistributionChartCard } from "./_components/WalletDistributionChartCard";
|
||||
|
||||
export function ConnectAnalyticsDashboardUI(props: {
|
||||
walletUsage: WalletStats[];
|
||||
aggregateWalletUsage: WalletStats[];
|
||||
userOpUsage: UserOpStats[];
|
||||
aggregateUserOpUsage: UserOpStats[];
|
||||
aggregateUserOpUsageQuery?: UserOpStats;
|
||||
connectLayoutSlug: string;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { totalWallets, uniqueWallets } = useMemo(() => {
|
||||
@@ -31,17 +27,6 @@ export function ConnectAnalyticsDashboardUI(props: {
|
||||
);
|
||||
}, [props.aggregateWalletUsage]);
|
||||
|
||||
const { totalSponsoredTransactions, totalSponsoredUsd } = useMemo(() => {
|
||||
return props.aggregateUserOpUsage.reduce(
|
||||
(acc, curr) => {
|
||||
acc.totalSponsoredTransactions += curr.successful;
|
||||
acc.totalSponsoredUsd += curr.sponsoredUsd;
|
||||
return acc;
|
||||
},
|
||||
{ totalSponsoredTransactions: 0, totalSponsoredUsd: 0 },
|
||||
);
|
||||
}, [props.aggregateUserOpUsage]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:gap-6">
|
||||
{/* Connections */}
|
||||
@@ -54,6 +39,38 @@ export function ConnectAnalyticsDashboardUI(props: {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4">
|
||||
<div className="w-full border-muted border-b" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="font-medium text-xl tracking-tight">
|
||||
Account Abstraction
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Sponsor and batch transactions for your users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button asChild variant="primary" size="sm" className="w-full">
|
||||
<Link href={`${props.connectLayoutSlug}/account-abstraction`}>
|
||||
View More Stats
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AccountAbstractionSummary
|
||||
aggregateUserOpUsageQuery={props.aggregateUserOpUsageQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4">
|
||||
<div className="w-full border-muted border-b" />
|
||||
</div>
|
||||
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={props.walletUsage}
|
||||
isPending={props.isPending}
|
||||
@@ -68,57 +85,6 @@ export function ConnectAnalyticsDashboardUI(props: {
|
||||
walletStats={props.walletUsage}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
|
||||
{/* Connections */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:gap-6">
|
||||
<Stat
|
||||
label="Sponsored Transactions"
|
||||
value={totalSponsoredTransactions}
|
||||
icon={ActivityIcon}
|
||||
/>
|
||||
<Stat
|
||||
label="Total Sponsored"
|
||||
value={totalSponsoredUsd}
|
||||
formatter={(value) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(value)
|
||||
}
|
||||
icon={CoinsIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TotalSponsoredChartCard
|
||||
userOpStats={props.userOpUsage}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
|
||||
<SponsoredTransactionsChartCard
|
||||
userOpStats={props.userOpUsage}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Stat: React.FC<{
|
||||
label: string;
|
||||
value?: number;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
formatter?: (value: number) => string;
|
||||
}> = ({ label, value, formatter, icon: Icon }) => {
|
||||
return (
|
||||
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/50 p-4 lg:p-6">
|
||||
<div>
|
||||
<dd className="font-semibold text-3xl tracking-tight lg:text-5xl">
|
||||
{value && formatter ? formatter(value) : value?.toLocaleString()}
|
||||
</dd>
|
||||
<dt className="font-medium text-muted-foreground text-sm tracking-tight lg:text-lg">
|
||||
{label}
|
||||
</dt>
|
||||
</div>
|
||||
<Icon className="hidden size-12 text-muted-foreground opacity-50 lg:block" />
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-3
@@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { createUserOpStatsStub } from "components/smart-wallets/AccountAbstractionAnalytics/storyUtils";
|
||||
import { mobileViewport } from "../../../../../../../stories/utils";
|
||||
import { ConnectAnalyticsDashboardUI } from "../ConnectAnalyticsDashboardUI";
|
||||
import { createUserOpStatsStub, createWalletStatsStub } from "./storyUtils";
|
||||
import { createWalletStatsStub } from "./storyUtils";
|
||||
|
||||
const meta = {
|
||||
title: "Charts/Connect/Analytics Dashboard",
|
||||
@@ -31,8 +32,8 @@ function Component() {
|
||||
<ConnectAnalyticsDashboardUI
|
||||
walletUsage={createWalletStatsStub(30)}
|
||||
aggregateWalletUsage={createWalletStatsStub(30)}
|
||||
userOpUsage={createUserOpStatsStub(30)}
|
||||
aggregateUserOpUsage={createUserOpStatsStub(1)}
|
||||
aggregateUserOpUsageQuery={createUserOpStatsStub(1)?.[0]}
|
||||
connectLayoutSlug="connectLayoutSlug"
|
||||
isPending={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+49
-2
@@ -15,10 +15,19 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, LabelList, XAxis } from "recharts";
|
||||
import { EmptyChartState, LoadingChartState } from "./EmptyChartState";
|
||||
|
||||
type ChartToShow = "uniqueWallets" | "totalWallets";
|
||||
|
||||
@@ -129,7 +138,45 @@ export function DailyConnectionsChartCard(props: {
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : barChartData.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
Send your first connect event
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/getting-started"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react-native/v5"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/dotnet/getting-started"
|
||||
label="Unity"
|
||||
icon={UnityIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unreal-engine/getting-started"
|
||||
label="Unreal Engine"
|
||||
icon={UnrealIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/dotnet/getting-started"
|
||||
label=".NET"
|
||||
icon={DotNetIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
|
||||
+31
-2
@@ -17,10 +17,16 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import { EmptyChartState, LoadingChartState } from "./EmptyChartState";
|
||||
|
||||
type ChartToShow = "uniqueWalletsConnected" | "totalConnections";
|
||||
|
||||
@@ -166,7 +172,30 @@ export function WalletConnectorsChartCard(props: {
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
Connect any wallet to your app
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
|
||||
+31
-2
@@ -17,9 +17,15 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Pie, PieChart } from "recharts";
|
||||
import { EmptyChartState, LoadingChartState } from "./EmptyChartState";
|
||||
|
||||
type ChartToShow = "totalConnections" | "uniqueWalletsConnected";
|
||||
|
||||
@@ -192,7 +198,30 @@ export function WalletDistributionChartCard(props: {
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
Connect any wallet to your app
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
|
||||
+1
-24
@@ -1,4 +1,4 @@
|
||||
import type { UserOpStats, WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { WalletId } from "thirdweb/wallets";
|
||||
|
||||
const walletsToPickFrom: WalletId[] = [
|
||||
@@ -50,26 +50,3 @@ export function createWalletStatsStub(days: number): WalletStats[] {
|
||||
|
||||
return stubbedData;
|
||||
}
|
||||
|
||||
export function createUserOpStatsStub(days: number): UserOpStats[] {
|
||||
const stubbedData: UserOpStats[] = [];
|
||||
|
||||
let d = days;
|
||||
while (d !== 0) {
|
||||
const successful = Math.floor(Math.random() * 100);
|
||||
const failed = Math.floor(Math.random() * 100);
|
||||
const sponsoredUsd = Math.floor(Math.random() * 100);
|
||||
stubbedData.push({
|
||||
date: new Date(2024, 1, d).toLocaleString(),
|
||||
successful,
|
||||
failed,
|
||||
sponsoredUsd,
|
||||
});
|
||||
|
||||
if (Math.random() > 0.7) {
|
||||
d--;
|
||||
}
|
||||
}
|
||||
|
||||
return stubbedData;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ export default async function Page(props: {
|
||||
</div>
|
||||
|
||||
<div className="h-6 lg:h-8" />
|
||||
<ConnectAnalyticsDashboard clientId={project.publishableKey} />
|
||||
<ConnectAnalyticsDashboard
|
||||
clientId={project.publishableKey}
|
||||
connectLayoutSlug={`/team/${props.params.team_slug}/${props.params.project_slug}/connect`}
|
||||
/>
|
||||
<div className="h-4 lg:h-8" />
|
||||
<ConnectSDKCard description="Add the Connect SDK to your app to get started collecting analytics." />
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DownloadIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../../../@/components/ui/button";
|
||||
import { ImportModal } from "../../../../../components/contract-components/import-contract/modal";
|
||||
|
||||
export function DeployedContractsPageHeader() {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { format, subDays } from "date-fns";
|
||||
|
||||
export function DateRangeSelector(props: {
|
||||
range: Range;
|
||||
setRange: (range: Range) => void;
|
||||
}) {
|
||||
const { range, setRange } = props;
|
||||
|
||||
return (
|
||||
<DatePickerWithRange
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
setFrom={(from) =>
|
||||
setRange({
|
||||
from,
|
||||
to: range.to,
|
||||
type: "custom",
|
||||
})
|
||||
}
|
||||
setTo={(to) =>
|
||||
setRange({
|
||||
from: range.from,
|
||||
to,
|
||||
type: "custom",
|
||||
})
|
||||
}
|
||||
header={
|
||||
<div className="mb-2 border-border border-b p-4">
|
||||
<Select
|
||||
value={range.type}
|
||||
onValueChange={(id: DurationId) => {
|
||||
setRange(getLastNDaysRange(id));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex bg-transparent">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{durationPresets.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{range.type === "custom" && (
|
||||
<SelectItem value="custom">
|
||||
{format(range.from, "LLL dd, y")} -{" "}
|
||||
{format(range.to, "LLL dd, y")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
labelOverride={range.label}
|
||||
className="w-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function getLastNDaysRange(id: DurationId) {
|
||||
const durationInfo = durationPresets.find((preset) => preset.id === id);
|
||||
if (!durationInfo) {
|
||||
throw new Error("Invalid duration id");
|
||||
}
|
||||
|
||||
const todayDate = new Date();
|
||||
|
||||
const value: Range = {
|
||||
type: id,
|
||||
from: subDays(todayDate, durationInfo.days),
|
||||
to: todayDate,
|
||||
label: durationInfo.name,
|
||||
};
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const durationPresets = [
|
||||
{
|
||||
name: "Last 7 Days",
|
||||
id: "last-7",
|
||||
days: 7,
|
||||
},
|
||||
{
|
||||
name: "Last 30 Days",
|
||||
id: "last-30",
|
||||
days: 30,
|
||||
},
|
||||
{
|
||||
name: "Last 60 Days",
|
||||
id: "last-60",
|
||||
days: 60,
|
||||
},
|
||||
{
|
||||
name: "Last 120 Days",
|
||||
id: "last-120",
|
||||
days: 120,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type DurationId = (typeof durationPresets)[number]["id"];
|
||||
|
||||
export type Range = {
|
||||
type: DurationId | "custom";
|
||||
label?: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
+4
-4
@@ -22,14 +22,14 @@ const skeletonChartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function EmptyChartState() {
|
||||
export function EmptyChartState({ children }: { children?: React.ReactNode }) {
|
||||
const barChartData = useMemo(() => generateRandomData(), []);
|
||||
|
||||
return (
|
||||
<div className="relative z-0 h-full w-full">
|
||||
<span className="absolute inset-0 z-[1] flex items-center justify-center font-semibold text-base text-muted-foreground">
|
||||
No data available
|
||||
</span>
|
||||
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center font-semibold text-base text-muted-foreground">
|
||||
{children ?? "No data available"}
|
||||
</div>
|
||||
<SkeletonBarChart data={barChartData} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function IntervalSelector(props: {
|
||||
intervalType: "day" | "week";
|
||||
setIntervalType: (intervalType: "day" | "week") => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={props.intervalType}
|
||||
onValueChange={(value: "day" | "week") => {
|
||||
props.setIntervalType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-auto hover:bg-muted">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="week"> Weekly </SelectItem>
|
||||
<SelectItem value="day"> Daily</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const Stat: React.FC<{
|
||||
label: string;
|
||||
value?: number;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
formatter?: (value: number) => string;
|
||||
}> = ({ label, value, formatter, icon: Icon }) => {
|
||||
return (
|
||||
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/50 p-4 lg:p-6">
|
||||
<div>
|
||||
<dd className="font-semibold text-3xl tracking-tight lg:text-5xl">
|
||||
{value && formatter ? formatter(value) : value?.toLocaleString()}
|
||||
</dd>
|
||||
<dt className="font-medium text-muted-foreground text-sm tracking-tight lg:text-lg">
|
||||
{label}
|
||||
</dt>
|
||||
</div>
|
||||
<Icon className="hidden size-12 text-muted-foreground opacity-50 lg:block" />
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "thirdweb/react";
|
||||
import { privateKeyToAccount } from "thirdweb/wallets";
|
||||
import { Button, type ButtonProps, Card, Heading, Text } from "tw-components";
|
||||
import { getFaucetClaimAmount } from "../../app/api/testnet-faucet/claim/claim-amount";
|
||||
import { THIRDWEB_API_HOST } from "../../constants/urls";
|
||||
import { useAllChainsData } from "../../hooks/chains/allChains";
|
||||
import { useV5DashboardChain } from "../../lib/v5-adapter";
|
||||
@@ -354,8 +355,7 @@ function NoFundsDialogContent(props: {
|
||||
function GetFundsFromFaucet(props: {
|
||||
chain: ChainMetadata;
|
||||
}) {
|
||||
// TODO - improvement for later -> estimate gas required for transaction, and use that as the amount to give
|
||||
const amountToGive = "0.01";
|
||||
const amountToGive = getFaucetClaimAmount(props.chain.chainId);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center rounded-lg border border-border px-4 py-6">
|
||||
|
||||
+8
@@ -18,6 +18,7 @@ import {
|
||||
FormControl,
|
||||
} from "@chakra-ui/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { verifyContract } from "app/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/ContractSourcesPage";
|
||||
import { NetworkSelectorButton } from "components/selects/NetworkSelectorButton";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
@@ -537,6 +538,13 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
// do the actual deployment
|
||||
const contractAddr = await deployMutation.mutateAsync(formData);
|
||||
|
||||
// send verification request - no need to await
|
||||
verifyContract({
|
||||
address: contractAddr,
|
||||
chain: walletChain,
|
||||
client: thirdwebClient,
|
||||
});
|
||||
|
||||
trackEvent({
|
||||
category: "custom-contract",
|
||||
action: "deploy",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchDeployMetadata as sdkFetchDeployMetadata } from "thirdweb/contract";
|
||||
import { removeUndefinedFromObjectDeep } from "../../utils/object";
|
||||
import { toContractIdIpfsHash } from "./hooks";
|
||||
import type { ContractId } from "./types";
|
||||
|
||||
// metadata PRE publish, only has the compiler output info (from CLI)
|
||||
|
||||
@@ -15,3 +15,10 @@ export async function fetchDeployMetadata(contractId: string) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function toContractIdIpfsHash(contractId: ContractId) {
|
||||
if (contractId?.startsWith("ipfs://")) {
|
||||
return contractId;
|
||||
}
|
||||
return `ipfs://${contractId}`;
|
||||
}
|
||||
|
||||
@@ -131,13 +131,6 @@ export function useFunctionParamsFromABI(abi?: Abi, functionName?: string) {
|
||||
}, [abi, functionName]);
|
||||
}
|
||||
|
||||
export function toContractIdIpfsHash(contractId: ContractId) {
|
||||
if (contractId?.startsWith("ipfs://")) {
|
||||
return contractId;
|
||||
}
|
||||
return `ipfs://${contractId}`;
|
||||
}
|
||||
|
||||
export type PublishedContractDetails = Awaited<
|
||||
ReturnType<typeof fetchPublishedContracts>
|
||||
>[number];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import {
|
||||
MaskedAvatar,
|
||||
type MaskedAvatarProps,
|
||||
@@ -22,17 +22,16 @@ export const PublisherAvatar: React.FC<PublisherAvatarProps> = ({
|
||||
const publisherProfile = usePublisherProfile(
|
||||
ensQuery.data?.address || undefined,
|
||||
);
|
||||
|
||||
const publisherImageUrl = resolveSchemeWithErrorHandler({
|
||||
uri: publisherProfile.data?.avatar,
|
||||
client,
|
||||
});
|
||||
|
||||
return (
|
||||
<MaskedAvatar
|
||||
isPending={isPending || ensQuery.isPending || publisherProfile.isPending}
|
||||
src={
|
||||
publisherProfile.data?.avatar
|
||||
? resolveScheme({
|
||||
uri: publisherProfile.data.avatar,
|
||||
client,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
src={publisherImageUrl || ""}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,139 +1,147 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useEns,
|
||||
usePublishedContractsFromDeploy,
|
||||
} from "components/contract-components/hooks";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { ContractCard } from "components/explore/contract-card";
|
||||
import { THIRDWEB_DEPLOYER_ADDRESS } from "constants/addresses";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { polygon } from "thirdweb/chains";
|
||||
import { getBytecode, getContract } from "thirdweb/contract";
|
||||
import { getPublishedUriFromCompilerUri } from "thirdweb/extensions/thirdweb";
|
||||
import { getInstalledModules } from "thirdweb/modules";
|
||||
import { useActiveAccount, useReadContract } from "thirdweb/react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { extractIPFSUri } from "thirdweb/utils";
|
||||
import { getAuthTokenWalletAddress } from "../../../app/api/lib/getAuthToken";
|
||||
import { isEnsName, resolveEns } from "../../../lib/ens";
|
||||
import { fetchPublishedContractsFromDeploy } from "../fetchPublishedContractsFromDeploy";
|
||||
|
||||
interface PublishedByProps {
|
||||
contract: ThirdwebContract;
|
||||
}
|
||||
|
||||
export const PublishedBy: React.FC<PublishedByProps> = ({ contract }) => {
|
||||
const publishedContractsFromDeploy =
|
||||
usePublishedContractsFromDeploy(contract);
|
||||
type ModuleMetadataPickedKeys = {
|
||||
publisher: string;
|
||||
moduleId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
const publishedContractToShow = useMemo(() => {
|
||||
const reversedPublishedContractsFromDeploy = [
|
||||
...(publishedContractsFromDeploy.data || []),
|
||||
].reverse();
|
||||
|
||||
return (
|
||||
reversedPublishedContractsFromDeploy.find(
|
||||
(publishedContract) => publishedContract.publisher === address,
|
||||
) ||
|
||||
reversedPublishedContractsFromDeploy.find(
|
||||
(publishedContract) =>
|
||||
publishedContract.publisher === THIRDWEB_DEPLOYER_ADDRESS,
|
||||
) ||
|
||||
reversedPublishedContractsFromDeploy[
|
||||
reversedPublishedContractsFromDeploy.length - 1
|
||||
] ||
|
||||
undefined
|
||||
);
|
||||
}, [publishedContractsFromDeploy.data, address]);
|
||||
|
||||
const publisherEnsQuery = useEns(publishedContractToShow?.publisher);
|
||||
const publisherAddress =
|
||||
publisherEnsQuery.data?.ensName || publisherEnsQuery.data?.address;
|
||||
|
||||
const installedModules = useReadContract(getInstalledModules, {
|
||||
export const PublishedBy: React.FC<PublishedByProps> = async ({ contract }) => {
|
||||
const client = getThirdwebClient();
|
||||
const publishedContractsFromDeploy = await fetchPublishedContractsFromDeploy({
|
||||
contract,
|
||||
queryOptions: {
|
||||
enabled: publishedContractToShow?.routerType === "modular" && !!contract,
|
||||
},
|
||||
client,
|
||||
});
|
||||
|
||||
// this handles all the logic for modules in the published contract card
|
||||
const installedModulesQuery = useQuery({
|
||||
queryKey: [
|
||||
"published-by-modules",
|
||||
contract,
|
||||
installedModules.data?.map((m) => m.implementation),
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!installedModules.data?.length) {
|
||||
return [];
|
||||
}
|
||||
const address = getAuthTokenWalletAddress();
|
||||
|
||||
const moduleContracts = installedModules.data.map((module) => {
|
||||
return getContract({ ...contract, address: module.implementation });
|
||||
});
|
||||
const metadataUris = await Promise.allSettled(
|
||||
moduleContracts.map(async (c) => {
|
||||
const byteCode = await getBytecode(c);
|
||||
const ipfsUri = extractIPFSUri(byteCode);
|
||||
const reversedPublishedContractsFromDeploy = [
|
||||
...(publishedContractsFromDeploy || []),
|
||||
].reverse();
|
||||
|
||||
if (!ipfsUri) {
|
||||
throw new Error("No IPFS URI found in bytecode");
|
||||
}
|
||||
let uris = await getPublishedUriFromCompilerUri({
|
||||
contract: {
|
||||
chain: polygon,
|
||||
client: contract.client,
|
||||
address: "0xf5b896Ddb5146D5dA77efF4efBb3Eae36E300808",
|
||||
},
|
||||
compilerMetadataUri: ipfsUri,
|
||||
}).catch((e) => {
|
||||
console.error("Error fetching published URI", e);
|
||||
return [];
|
||||
});
|
||||
const publishedContractToShow =
|
||||
reversedPublishedContractsFromDeploy.find(
|
||||
(publishedContract) => publishedContract.publisher === address,
|
||||
) ||
|
||||
reversedPublishedContractsFromDeploy.find(
|
||||
(publishedContract) =>
|
||||
publishedContract.publisher === THIRDWEB_DEPLOYER_ADDRESS,
|
||||
) ||
|
||||
reversedPublishedContractsFromDeploy[
|
||||
reversedPublishedContractsFromDeploy.length - 1
|
||||
] ||
|
||||
undefined;
|
||||
|
||||
uris = uris.filter((uri) => uri.length > 0);
|
||||
if (uris.length === 0) {
|
||||
throw new Error("No published URI found");
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
uris.map(async (uri) => {
|
||||
const content = await download({
|
||||
uri,
|
||||
client: contract.client,
|
||||
});
|
||||
return JSON.parse(await content.text());
|
||||
}),
|
||||
);
|
||||
|
||||
return results
|
||||
.filter((r) => r.status === "fulfilled")
|
||||
.map((r) => r.value);
|
||||
}),
|
||||
);
|
||||
|
||||
const filtered = metadataUris
|
||||
.filter((m) => m.status === "fulfilled")
|
||||
.map((m) => m.value);
|
||||
|
||||
return filtered.map((m) => m[0]);
|
||||
},
|
||||
enabled: !!installedModules.data?.length,
|
||||
});
|
||||
|
||||
if (!publishedContractToShow || !publisherAddress) {
|
||||
if (!publishedContractToShow || !publishedContractToShow.publisher) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get publisher address/ens
|
||||
let publisherAddressOrEns = publishedContractToShow.publisher;
|
||||
if (!isEnsName(publishedContractToShow.publisher)) {
|
||||
try {
|
||||
const res = await resolveEns(publishedContractToShow.publisher);
|
||||
if (res.ensName) {
|
||||
publisherAddressOrEns = res.ensName;
|
||||
}
|
||||
} catch {
|
||||
// no op
|
||||
}
|
||||
}
|
||||
|
||||
// Get Modules metadata for modular contract
|
||||
let modules: ModuleMetadataPickedKeys[] = [];
|
||||
if (publishedContractToShow?.routerType === "modular") {
|
||||
try {
|
||||
const installedModules = await getInstalledModules({
|
||||
contract,
|
||||
});
|
||||
|
||||
if (installedModules.length !== 0) {
|
||||
const moduleContracts = installedModules.map((module) => {
|
||||
return getContract({ ...contract, address: module.implementation });
|
||||
});
|
||||
|
||||
const metadataUris = await Promise.allSettled(
|
||||
moduleContracts.map(async (c) => {
|
||||
const byteCode = await getBytecode(c);
|
||||
const ipfsUri = extractIPFSUri(byteCode);
|
||||
|
||||
if (!ipfsUri) {
|
||||
throw new Error("No IPFS URI found in bytecode");
|
||||
}
|
||||
let uris = await getPublishedUriFromCompilerUri({
|
||||
contract: {
|
||||
chain: polygon,
|
||||
client: contract.client,
|
||||
address: "0xf5b896Ddb5146D5dA77efF4efBb3Eae36E300808",
|
||||
},
|
||||
compilerMetadataUri: ipfsUri,
|
||||
}).catch((e) => {
|
||||
console.error("Error fetching published URI", e);
|
||||
return [];
|
||||
});
|
||||
|
||||
uris = uris.filter((uri) => uri.length > 0);
|
||||
if (uris.length === 0) {
|
||||
throw new Error("No published URI found");
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
uris.map(async (uri) => {
|
||||
const content = await download({
|
||||
uri,
|
||||
client: contract.client,
|
||||
});
|
||||
return JSON.parse(
|
||||
await content.text(),
|
||||
) as ModuleMetadataPickedKeys;
|
||||
}),
|
||||
);
|
||||
|
||||
return results
|
||||
.filter((r) => r.status === "fulfilled")
|
||||
.map((r) => r.value);
|
||||
}),
|
||||
);
|
||||
|
||||
const filtered = metadataUris
|
||||
.filter((m) => m.status === "fulfilled")
|
||||
.map((m) => m.value);
|
||||
|
||||
modules = filtered.map((m) => m[0]).filter((m) => m !== undefined);
|
||||
}
|
||||
} catch {
|
||||
// no op
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractCard
|
||||
contractId={publishedContractToShow.name}
|
||||
publisher={publisherAddress}
|
||||
publisher={publisherAddressOrEns}
|
||||
version={publishedContractToShow.version}
|
||||
isBeta={(publishedContractToShow.displayName || "")
|
||||
.toLowerCase()
|
||||
.includes("beta")}
|
||||
modules={installedModulesQuery.data?.map((m) => ({
|
||||
modules={modules.map((m) => ({
|
||||
publisher: m.publisher,
|
||||
moduleId: m.name,
|
||||
version: m.version,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
|
||||
import type { BasicContract } from "contract-ui/types/types";
|
||||
import { useChainSlug } from "hooks/chains/chainSlug";
|
||||
@@ -16,13 +17,14 @@ import { usePublishedContractsFromDeploy } from "../hooks";
|
||||
|
||||
interface AsyncContractNameCellProps {
|
||||
cell: BasicContract;
|
||||
linkOverlay?: boolean;
|
||||
}
|
||||
|
||||
// The row components for the contract table, in the <Your contracts> page
|
||||
// url: /dashboard/contracts/deploy
|
||||
|
||||
export const AsyncContractNameCell = memo(
|
||||
({ cell }: AsyncContractNameCellProps) => {
|
||||
({ cell, linkOverlay }: AsyncContractNameCellProps) => {
|
||||
const chainSlug = useChainSlug(cell.chainId);
|
||||
const chain = useV5DashboardChain(cell.chainId);
|
||||
const client = useThirdwebClient();
|
||||
@@ -46,7 +48,10 @@ export const AsyncContractNameCell = memo(
|
||||
<Link
|
||||
href={`/${chainSlug}/${cell.address}`}
|
||||
passHref
|
||||
className="text-foreground"
|
||||
className={cn(
|
||||
"text-foreground",
|
||||
linkOverlay && "before:absolute before:inset-0",
|
||||
)}
|
||||
>
|
||||
{v || shortenIfAddress(cell.address)}
|
||||
</Link>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import {
|
||||
type useAllContractList,
|
||||
useRemoveContractMutation,
|
||||
@@ -38,7 +37,6 @@ import {
|
||||
useTable,
|
||||
} from "react-table";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
import { useChainSlug } from "../../../hooks/chains/chainSlug";
|
||||
import { AsyncContractNameCell, AsyncContractTypeCell } from "./cells";
|
||||
import { ShowMoreButton } from "./show-more-button";
|
||||
|
||||
@@ -174,7 +172,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
|
||||
accessor: (row) => row.address,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: (cell: any) => {
|
||||
return <AsyncContractNameCell cell={cell.row.original} />;
|
||||
return <AsyncContractNameCell cell={cell.row.original} linkOverlay />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -231,6 +229,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
|
||||
copyIconPosition="left"
|
||||
address={cell.row.original.address}
|
||||
variant="ghost"
|
||||
className="relative z-10"
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -245,6 +244,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="relative z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
@@ -367,8 +367,6 @@ const ContractTable: React.FC<ContractTableProps> = ({
|
||||
};
|
||||
|
||||
const ContractTableRow = memo(({ row }: { row: Row<BasicContract> }) => {
|
||||
const chainSlug = useChainSlug(row.original.chainId);
|
||||
const router = useDashboardRouter();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { key, ...rowProps } = row.getRowProps();
|
||||
|
||||
@@ -376,11 +374,7 @@ const ContractTableRow = memo(({ row }: { row: Row<BasicContract> }) => {
|
||||
<TableRow
|
||||
{...rowProps}
|
||||
role="group"
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
// TODO - replace this with before:absolute thing
|
||||
onClick={() => {
|
||||
router.push(`/${chainSlug}/${row.original.address}`);
|
||||
}}
|
||||
className="relative cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -34,7 +36,7 @@ import * as ERC721Ext from "thirdweb/extensions/erc721";
|
||||
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { toFunctionSelector } from "thirdweb/utils";
|
||||
import { Badge, Button, Card, Heading, Text } from "tw-components";
|
||||
import { Button, Card, Heading, Text } from "tw-components";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useContractFunctionSelectors } from "../../contract-ui/hooks/useContractFunctionSelectors";
|
||||
import {
|
||||
@@ -85,6 +87,11 @@ function ContractFunctionInner({ contract, fn }: ContractFunctionProps) {
|
||||
return undefined;
|
||||
}, [isERC20, isERC721Query.data, isERC1155Query.data]);
|
||||
|
||||
const functionSelector = useMemo(
|
||||
() => (fn?.type === "function" ? toFunctionSelector(fn) : undefined),
|
||||
[fn],
|
||||
);
|
||||
|
||||
if (!fn) {
|
||||
return null;
|
||||
}
|
||||
@@ -111,21 +118,22 @@ function ContractFunctionInner({ contract, fn }: ContractFunctionProps) {
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={1.5}>
|
||||
<Flex
|
||||
alignItems={{ base: "start", md: "center" }}
|
||||
gap={2}
|
||||
direction={{ base: "column", md: "row" }}
|
||||
>
|
||||
<Flex alignItems="center" gap={2} direction="row" flexWrap="wrap">
|
||||
<Flex alignItems="baseline" gap={1} flexWrap="wrap">
|
||||
<Heading size="subtitle.md">{camelToTitle(fn.name)}</Heading>
|
||||
<Heading size="subtitle.sm" className="text-muted-foreground">
|
||||
({fn.name})
|
||||
</Heading>
|
||||
</Flex>
|
||||
{isFunction && (
|
||||
<Badge size="label.sm" variant="subtle" colorScheme="green">
|
||||
{fn.stateMutability}
|
||||
</Badge>
|
||||
{isFunction && <Badge variant="success">{fn.stateMutability}</Badge>}
|
||||
{functionSelector && (
|
||||
<CopyTextButton
|
||||
textToCopy={functionSelector}
|
||||
textToShow={functionSelector}
|
||||
copyIconPosition="right"
|
||||
tooltip="The selector of this function"
|
||||
className="ml-auto text-xs"
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -173,11 +181,7 @@ function ContractFunctionInputs(props: {
|
||||
({fn.name})
|
||||
</Heading>
|
||||
</Flex>
|
||||
{isFunction && (
|
||||
<Badge size="label.sm" variant="subtle" colorScheme="green">
|
||||
{fn.stateMutability}
|
||||
</Badge>
|
||||
)}
|
||||
{isFunction && <Badge variant="success">{fn.stateMutability}</Badge>}
|
||||
</Flex>
|
||||
|
||||
{fn.inputs?.length ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import {
|
||||
@@ -416,7 +417,15 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = ({
|
||||
) : formattedResponseData ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Heading size="label.sm">Output</Heading>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Heading size="label.sm">Output</Heading>
|
||||
{/* Show the Solidity type of the function's output */}
|
||||
{abiFunction.outputs.length > 0 && (
|
||||
<Badge variant="default">
|
||||
{abiFunction.outputs[0]?.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CodeBlock
|
||||
w="full"
|
||||
position="relative"
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton, SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { moduleToBase64 } from "app/(dashboard)/published-contract/utils/module-base-64";
|
||||
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
import invariant from "tiny-invariant";
|
||||
import { fetchPublishedContractVersion } from "../../contract-components/fetch-contracts-with-versions";
|
||||
import { ContractPublisher, replaceDeployerAddress } from "../publisher";
|
||||
|
||||
@@ -80,7 +76,7 @@ function getContractUrl(
|
||||
return replaceDeployerAddress(pathName);
|
||||
}
|
||||
|
||||
export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
export async function ContractCard({
|
||||
publisher,
|
||||
contractId,
|
||||
titleOverride,
|
||||
@@ -89,20 +85,28 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
tracking,
|
||||
modules = [],
|
||||
isBeta,
|
||||
}) => {
|
||||
const client = useThirdwebClient();
|
||||
const publishedContractResult = usePublishedContract(
|
||||
`${publisher}/${contractId}/${version}`,
|
||||
);
|
||||
}: ContractCardProps) {
|
||||
const client = getThirdwebClient();
|
||||
const publishedContractResult = await fetchPublishedContractVersion(
|
||||
publisher,
|
||||
contractId,
|
||||
version,
|
||||
).catch(() => null);
|
||||
|
||||
const showSkeleton = publishedContractResult.isPending;
|
||||
if (!publishedContractResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const auditLink = resolveSchemeWithErrorHandler({
|
||||
uri: publishedContractResult.audit,
|
||||
client,
|
||||
});
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"relative flex min-h-[220px] flex-col rounded-lg border border-border p-4",
|
||||
!showSkeleton ? "bg-muted/50 hover:bg-muted" : "pointer-events-none",
|
||||
)}
|
||||
className={
|
||||
"relative flex min-h-[220px] flex-col rounded-lg border border-border bg-muted/50 p-4 hover:bg-muted"
|
||||
}
|
||||
>
|
||||
<TrackedLinkTW
|
||||
className="absolute inset-0 z-0 cursor-pointer"
|
||||
@@ -127,15 +131,12 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Audited */}
|
||||
{publishedContractResult.data?.audit && (
|
||||
{auditLink && (
|
||||
<>
|
||||
<Link
|
||||
target="_blank"
|
||||
className="relative z-1 flex items-center gap-1 font-medium text-sm text-success-text hover:underline"
|
||||
href={resolveScheme({
|
||||
uri: publishedContractResult.data.audit,
|
||||
client,
|
||||
})}
|
||||
href={auditLink}
|
||||
>
|
||||
<ShieldCheckIcon className="size-4" />
|
||||
Audited
|
||||
@@ -145,17 +146,12 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
)}
|
||||
|
||||
{/* Version */}
|
||||
<SkeletonContainer
|
||||
skeletonData="0.0.0"
|
||||
loadedData={publishedContractResult.data?.version}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
v{v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{publishedContractResult.version && (
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
v{publishedContractResult.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
@@ -168,34 +164,20 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
|
||||
<div className="h-3.5" />
|
||||
|
||||
<SkeletonContainer
|
||||
className="inline-block"
|
||||
skeletonData="Edition Drop"
|
||||
loadedData={
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
{(
|
||||
titleOverride ||
|
||||
publishedContractResult.data?.displayName ||
|
||||
publishedContractResult.data?.name
|
||||
}
|
||||
render={(v) => {
|
||||
return (
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
{v.replace("[Beta]", "")}
|
||||
</h3>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
publishedContractResult.displayName ||
|
||||
publishedContractResult.name
|
||||
).replace("[Beta]", "")}
|
||||
</h3>
|
||||
|
||||
{/* Desc */}
|
||||
<p className="mt-1 text-muted-foreground text-sm leading-5">
|
||||
{descriptionOverride || publishedContractResult.description}
|
||||
</p>
|
||||
|
||||
{publishedContractResult.data ? (
|
||||
<p className="mt-1 text-muted-foreground text-sm leading-5">
|
||||
{descriptionOverride || publishedContractResult.data?.description}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-1">
|
||||
<Skeleton className="h-4 w-[80%]" />
|
||||
<div className="h-1" />
|
||||
<Skeleton className="h-4 w-[60%]" />
|
||||
</div>
|
||||
)}
|
||||
{modules.length ? (
|
||||
<div className="mt-auto flex flex-row flex-wrap gap-1 pt-3">
|
||||
{modules.slice(0, 2).map((m) => (
|
||||
@@ -214,10 +196,9 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
!modules?.length && "mt-auto",
|
||||
)}
|
||||
>
|
||||
<ContractPublisher
|
||||
addressOrEns={publishedContractResult.data?.publisher}
|
||||
showSkeleton={showSkeleton}
|
||||
/>
|
||||
{publishedContractResult.publisher && (
|
||||
<ContractPublisher addressOrEns={publishedContractResult.publisher} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
@@ -245,22 +226,8 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
// data fetching
|
||||
type PublishedContractId =
|
||||
| `${string}/${string}`
|
||||
| `${string}/${string}/${string}`;
|
||||
|
||||
function usePublishedContract(publishedContractId: PublishedContractId) {
|
||||
const [publisher, contractId, version] = publishedContractId.split("/");
|
||||
return useQuery({
|
||||
queryKey: ["published-contract", { publishedContractId }],
|
||||
queryFn: () => {
|
||||
invariant(publisher, "publisher is required");
|
||||
invariant(contractId, "contractId is required");
|
||||
return fetchPublishedContractVersion(publisher, contractId, version);
|
||||
},
|
||||
enabled: !!publisher || !!contractId,
|
||||
});
|
||||
}
|
||||
|
||||
export function ContractCardSkeleton() {
|
||||
return <Skeleton className="h-[218px] border" />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ExploreCategory } from "data/explore";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ContractCard } from "../contract-card";
|
||||
import { Suspense } from "react";
|
||||
import { ContractCard, ContractCardSkeleton } from "../contract-card";
|
||||
|
||||
interface ContractRowProps {
|
||||
category: ExploreCategory;
|
||||
@@ -69,26 +70,30 @@ export function ContractRow({ category }: ContractRowProps) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ContractCard
|
||||
<Suspense
|
||||
key={publisher + contractId + overrides?.title}
|
||||
publisher={publisher}
|
||||
contractId={contractId}
|
||||
titleOverride={overrides?.title}
|
||||
descriptionOverride={overrides?.description}
|
||||
tracking={{
|
||||
source: category.id,
|
||||
itemIndex: `${idx}`,
|
||||
}}
|
||||
isBeta={category.isBeta}
|
||||
modules={
|
||||
modules?.length
|
||||
? modules.map((m) => ({
|
||||
publisher: m.split("/")[0] || "",
|
||||
moduleId: m.split("/")[1] || "",
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
fallback={<ContractCardSkeleton />}
|
||||
>
|
||||
<ContractCard
|
||||
publisher={publisher}
|
||||
contractId={contractId}
|
||||
titleOverride={overrides?.title}
|
||||
descriptionOverride={overrides?.description}
|
||||
tracking={{
|
||||
source: category.id,
|
||||
itemIndex: `${idx}`,
|
||||
}}
|
||||
isBeta={category.isBeta}
|
||||
modules={
|
||||
modules?.length
|
||||
? modules.map((m) => ({
|
||||
publisher: m.split("/")[0] || "",
|
||||
moduleId: m.split("/")[1] || "",
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { useEns } from "components/contract-components/hooks";
|
||||
import { PublisherAvatar } from "components/contract-components/publisher/masked-avatar";
|
||||
import Link from "next/link";
|
||||
import type { RequiredParam } from "utils/types";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
import { isEnsName, resolveEns } from "../../../lib/ens";
|
||||
|
||||
interface ContractPublisherProps {
|
||||
addressOrEns: RequiredParam<string>;
|
||||
showSkeleton?: boolean;
|
||||
addressOrEns: string;
|
||||
}
|
||||
|
||||
export const ContractPublisher: React.FC<ContractPublisherProps> = ({
|
||||
export const ContractPublisher: React.FC<ContractPublisherProps> = async ({
|
||||
addressOrEns,
|
||||
showSkeleton,
|
||||
}) => {
|
||||
const ensQuery = useEns(addressOrEns || undefined);
|
||||
let ensOrAddressToShow = addressOrEns;
|
||||
|
||||
if (!isEnsName(addressOrEns)) {
|
||||
try {
|
||||
const res = await resolveEns(addressOrEns);
|
||||
if (res.ensName) {
|
||||
ensOrAddressToShow = res.ensName;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="flex shrink-0 items-center gap-1.5 hover:underline"
|
||||
href={replaceDeployerAddress(
|
||||
`/${ensQuery.data?.ensName || ensQuery.data?.address || addressOrEns}`,
|
||||
)}
|
||||
href={replaceDeployerAddress(`/${ensOrAddressToShow}`)}
|
||||
>
|
||||
<PublisherAvatar
|
||||
isPending={showSkeleton}
|
||||
isPending={false}
|
||||
boxSize={5}
|
||||
address={addressOrEns || ""}
|
||||
/>
|
||||
|
||||
<SkeletonContainer
|
||||
loadedData={
|
||||
ensQuery.data?.ensName || ensQuery.data?.address || addressOrEns || ""
|
||||
}
|
||||
skeletonData=""
|
||||
render={(v) => <p className="text-xs"> {treatAddress(v)} </p>}
|
||||
/>
|
||||
<p className="text-xs"> {treatAddress(ensOrAddressToShow)} </p>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { PlainTextCodeBlock } from "@/components/ui/code";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { CodeBlock } from "tw-components";
|
||||
|
||||
export const DeployUpsellCard: React.FC = () => {
|
||||
return (
|
||||
@@ -29,11 +27,7 @@ export const DeployUpsellCard: React.FC = () => {
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<CodeBlock
|
||||
code="npx thirdweb create contract"
|
||||
language="bash"
|
||||
prefix="$"
|
||||
/>
|
||||
<PlainTextCodeBlock code="npx thirdweb create contract" />
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
@@ -53,7 +47,7 @@ export const DeployUpsellCard: React.FC = () => {
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<CodeBlock code="npx thirdweb deploy" language="bash" prefix="$" />
|
||||
<PlainTextCodeBlock code="npx thirdweb deploy" />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import Image from "next/image";
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Code,
|
||||
type CodeProps,
|
||||
Flex,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { CheckIcon, CopyIcon, ZapIcon } from "lucide-react";
|
||||
import { Highlight, themes } from "prism-react-renderer";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { Text } from "tw-components";
|
||||
|
||||
const darkThemeDefault = themes.vsDark;
|
||||
const lightThemeDefault = themes.vsLight;
|
||||
|
||||
type PrismTheme = typeof darkThemeDefault;
|
||||
|
||||
interface CodeBlockProps extends Omit<CodeProps, "size"> {
|
||||
code: string;
|
||||
language: string;
|
||||
canCopy?: boolean;
|
||||
wrap?: boolean;
|
||||
prefix?: string;
|
||||
darkTheme?: PrismTheme;
|
||||
lightTheme?: PrismTheme;
|
||||
autoType?: boolean;
|
||||
typingSpeed?: number;
|
||||
title?: string;
|
||||
titleColor?: string;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
containerHeight?: any;
|
||||
}
|
||||
export const HomePageCodeBlock: React.FC<CodeBlockProps> = ({
|
||||
code,
|
||||
language,
|
||||
px = 4,
|
||||
py = 2,
|
||||
w = "full",
|
||||
borderRadius = "md",
|
||||
borderColor = "borderColor",
|
||||
borderWidth = "1px",
|
||||
fontFamily = "mono",
|
||||
backgroundColor,
|
||||
prefix,
|
||||
canCopy = true,
|
||||
wrap = true,
|
||||
darkTheme = themes.vsDark,
|
||||
lightTheme,
|
||||
autoType = false,
|
||||
typingSpeed = 50,
|
||||
title,
|
||||
titleColor,
|
||||
...restCodeProps
|
||||
}) => {
|
||||
const { ref, inView } = useInView({
|
||||
threshold: 0.1,
|
||||
triggerOnce: true,
|
||||
});
|
||||
|
||||
const theme = useColorModeValue(
|
||||
lightTheme || lightThemeDefault,
|
||||
darkTheme || darkThemeDefault,
|
||||
);
|
||||
const { onCopy, hasCopied } = useClipboard(code);
|
||||
const [speedUpEnabled, setSpeedUpEnabled] = useState(false);
|
||||
const [currentCodeIndex, setCurrentCodeIndex] = useState(0);
|
||||
const [currentTypingSpeed] = useState(typingSpeed);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const chakraTheme = useTheme();
|
||||
|
||||
// legitimate use-case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!inView) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
if (currentCodeIndex < code.length) {
|
||||
setCurrentCodeIndex((prev) => prev + 1);
|
||||
} else {
|
||||
// clear the interval when the code is fully typed
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, currentTypingSpeed);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentCodeIndex, currentTypingSpeed, code, inView]);
|
||||
|
||||
// legitimate use-case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
<Flex
|
||||
direction="column"
|
||||
w="full"
|
||||
h="full"
|
||||
border="1px solid"
|
||||
borderColor="borderColor"
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px={2}
|
||||
py={2}
|
||||
bg="#161b22"
|
||||
roundedTop="lg"
|
||||
w="full"
|
||||
>
|
||||
{canCopy && code && autoType && currentCodeIndex < code.length ? (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setSpeedUpEnabled((prev) => {
|
||||
setCurrentCodeIndex(code.length);
|
||||
return !prev;
|
||||
});
|
||||
}}
|
||||
aria-label="Copy"
|
||||
borderRadius="md"
|
||||
variant="ghost"
|
||||
colorScheme="gray"
|
||||
size="sm"
|
||||
icon={<ZapIcon className="size-4" />}
|
||||
/>
|
||||
) : (
|
||||
<Box w="32px" />
|
||||
)}
|
||||
{title && (
|
||||
<Text
|
||||
fontSize="large"
|
||||
fontWeight="bold"
|
||||
position="static"
|
||||
color={titleColor ? titleColor : "white"}
|
||||
px={4}
|
||||
py={2}
|
||||
h="120%"
|
||||
mb={-2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{canCopy && code && (
|
||||
<IconButton
|
||||
onClick={onCopy}
|
||||
aria-label="Copy"
|
||||
borderRadius="md"
|
||||
variant="ghost"
|
||||
colorScheme="gray"
|
||||
size="sm"
|
||||
icon={
|
||||
hasCopied ? (
|
||||
<CheckIcon className="size-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
<Highlight
|
||||
code={
|
||||
prefix
|
||||
? `${prefix} ${code}`
|
||||
: autoType
|
||||
? speedUpEnabled
|
||||
? code
|
||||
: code.slice(0, currentCodeIndex)
|
||||
: code
|
||||
}
|
||||
language={language}
|
||||
theme={{
|
||||
...theme,
|
||||
plain: {
|
||||
backgroundColor:
|
||||
(backgroundColor as string) ||
|
||||
"var(--chakra-colors-backgroundHighlight)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
borderRadius={borderRadius}
|
||||
py={py}
|
||||
px={px}
|
||||
w={w}
|
||||
borderWidth={borderWidth}
|
||||
borderColor={borderColor}
|
||||
position="relative"
|
||||
className={className}
|
||||
style={style}
|
||||
fontFamily={fontFamily}
|
||||
whiteSpace={wrap ? "pre-wrap" : "pre"}
|
||||
overflowY="auto"
|
||||
{...restCodeProps}
|
||||
as={Code}
|
||||
h="full"
|
||||
>
|
||||
<div>
|
||||
<Box as="span" display="block" my={1} color="heading">
|
||||
{tokens.map((line, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Box key={i} {...getLineProps({ line })}>
|
||||
<LineNumbers
|
||||
lineNumber={i + 1}
|
||||
lineHeight={chakraTheme.sizes["5"]}
|
||||
totalLines={tokens.length}
|
||||
/>
|
||||
{line.map((token, key) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Highlight>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface LineNumbersProps {
|
||||
lineNumber: number;
|
||||
lineHeight: string | number;
|
||||
totalLines: number;
|
||||
}
|
||||
|
||||
const LineNumbers: React.FC<LineNumbersProps> = ({
|
||||
lineNumber,
|
||||
lineHeight,
|
||||
totalLines,
|
||||
}) => {
|
||||
const maxLineNumber = totalLines.toString().length;
|
||||
const filledLineNumber = lineNumber.toString().padStart(maxLineNumber, " ");
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
display="inline-block"
|
||||
textAlign="right"
|
||||
paddingRight="1em"
|
||||
userSelect="none"
|
||||
opacity={0.3}
|
||||
lineHeight={lineHeight}
|
||||
>
|
||||
{filledLineNumber}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Box, Flex, Image } from "@chakra-ui/react";
|
||||
import { HomepageSection } from "components/product-pages/homepage/HomepageSection";
|
||||
import { ZapIcon } from "lucide-react";
|
||||
import { Heading, LinkButton, Text } from "tw-components";
|
||||
|
||||
export const AnyEVMSection = () => {
|
||||
return (
|
||||
<HomepageSection id="any-evm" position="relative" pb={{ base: 36, md: 64 }}>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
pb={24}
|
||||
pt={{ base: 12, md: 24 }}
|
||||
align="center"
|
||||
gap={{ base: 6, md: 8 }}
|
||||
>
|
||||
<Heading
|
||||
bg="linear-gradient(350.21deg, #FFFFFF -13.99%, rgba(255, 255, 255, 0) 136.74%)"
|
||||
bgClip="text"
|
||||
as="h2"
|
||||
size="title.2xl"
|
||||
textAlign="center"
|
||||
letterSpacing="-0.04em"
|
||||
>
|
||||
Build on 1,000+ EVM chains
|
||||
</Heading>
|
||||
<Text
|
||||
bg="linear-gradient(29.47deg, #FFFFFF -12.61%, rgba(255, 255, 255, 0) 152.49%)"
|
||||
bgClip="text"
|
||||
textAlign="center"
|
||||
as="h3"
|
||||
maxW={480}
|
||||
size="label.xl"
|
||||
lineHeight={1.2}
|
||||
>
|
||||
Our tools work with any contract deployed on any EVM-compatible chain.
|
||||
</Text>
|
||||
|
||||
<Flex
|
||||
flexDir="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
marginTop="16px"
|
||||
w="100%"
|
||||
>
|
||||
<LinkButton
|
||||
href="/chainlist"
|
||||
px={4}
|
||||
py={7}
|
||||
// h={{ base: "48px", md: "68px" }}
|
||||
fontSize="20px"
|
||||
leftIcon={<ZapIcon className="size-5" />}
|
||||
color="black"
|
||||
flexShrink={0}
|
||||
background="rgba(255,255,255,1)"
|
||||
_hover={{
|
||||
background: "rgba(255,255,255,0.9)!important",
|
||||
}}
|
||||
maxW="320px"
|
||||
width="100%"
|
||||
>
|
||||
See the Chainlist
|
||||
</LinkButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box
|
||||
pointerEvents="none"
|
||||
zIndex={-1}
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
h="0"
|
||||
w="full"
|
||||
>
|
||||
<Image
|
||||
transform="translate(-50%, -60%)"
|
||||
ml="50%"
|
||||
alt=""
|
||||
w="1500px"
|
||||
maxW="250vw"
|
||||
src="/assets/landingpage/any-evm.png"
|
||||
justifySelf="flex-end"
|
||||
/>
|
||||
</Box>
|
||||
</HomepageSection>
|
||||
);
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
import { Flex, SimpleGrid } from "@chakra-ui/react";
|
||||
import { ChakraNextImage } from "components/Image";
|
||||
import { HomepageSection } from "components/product-pages/homepage/HomepageSection";
|
||||
import { ZapIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Heading, Text, TrackedLink, TrackedLinkButton } from "tw-components";
|
||||
import { getCookie } from "../../../stores/SyncStoreToCookies";
|
||||
import { Aurora } from "../Aurora";
|
||||
import styles from "../category/categories.module.css";
|
||||
import { OpenSource } from "../open-source/OpenSource";
|
||||
|
||||
interface HeroSectionProps {
|
||||
TRACKING_CATEGORY: string;
|
||||
}
|
||||
|
||||
export const HeroSection = ({ TRACKING_CATEGORY }: HeroSectionProps) => {
|
||||
return (
|
||||
<HomepageSection id="home">
|
||||
{/* top */}
|
||||
<Aurora
|
||||
pos={{ left: "50%", top: "0%" }}
|
||||
size={{ width: "2400px", height: "1400px" }}
|
||||
color="hsl(260deg 78% 35% / 40%)"
|
||||
/>
|
||||
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, md: 2 }}
|
||||
gap={{ base: 12, md: 8 }}
|
||||
mt={{ base: 4, md: 20 }}
|
||||
placeItems="center"
|
||||
>
|
||||
<Flex flexDir="column" gap={{ base: 6, md: 8 }}>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Heading
|
||||
as="h1"
|
||||
size="title.2xl"
|
||||
fontWeight={800}
|
||||
px={{ base: 2, md: 0 }}
|
||||
fontSize={{ base: "36px", sm: "45px" }}
|
||||
>
|
||||
Full-stack, <OpenSource TRACKING_CATEGORY={TRACKING_CATEGORY} />{" "}
|
||||
web3 development platform
|
||||
</Heading>
|
||||
</Flex>
|
||||
<Text size="body.xl" mr={6}>
|
||||
Frontend, backend, and onchain tools to build complete web3 apps —
|
||||
on every EVM chain.
|
||||
</Text>
|
||||
|
||||
<Flex
|
||||
flexDirection={{ base: "column", sm: "row" }}
|
||||
gap={{ base: 4, md: 6 }}
|
||||
>
|
||||
<GetStartedButtonLink trackingCategory={TRACKING_CATEGORY} />
|
||||
|
||||
<TrackedLinkButton
|
||||
variant="outline"
|
||||
w="full"
|
||||
py={6}
|
||||
px={8}
|
||||
href="https://portal.thirdweb.com"
|
||||
category={TRACKING_CATEGORY}
|
||||
label="see-docs"
|
||||
maxW={{ base: "full", sm: "fit-content" }}
|
||||
isExternal
|
||||
>
|
||||
See the docs
|
||||
</TrackedLinkButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex
|
||||
minW={{ base: "auto", md: "420px" }}
|
||||
maxW={{ base: "280px", sm: "400px", md: "500px" }}
|
||||
position="relative"
|
||||
className={styles.heroContainer}
|
||||
>
|
||||
<TrackedLink
|
||||
href="/connect"
|
||||
category={TRACKING_CATEGORY}
|
||||
label="connect-icon"
|
||||
>
|
||||
<ChakraNextImage
|
||||
src={require("../../../../public/assets/landingpage/desktop/icon-frontend.png")}
|
||||
alt="icon-frontend"
|
||||
maxW={{ base: "45px", sm: "60px", md: "75px" }}
|
||||
zIndex={2}
|
||||
cursor="pointer"
|
||||
className={styles.connect}
|
||||
/>
|
||||
</TrackedLink>
|
||||
|
||||
<TrackedLink
|
||||
href="/engine"
|
||||
category={TRACKING_CATEGORY}
|
||||
label="backend-icon"
|
||||
>
|
||||
<ChakraNextImage
|
||||
src={require("../../../../public/assets/landingpage/desktop/icon-backend.png")}
|
||||
alt="icon-backend"
|
||||
maxW={{ base: "45px", sm: "60px", md: "75px" }}
|
||||
position="absolute"
|
||||
zIndex={2}
|
||||
cursor="pointer"
|
||||
className={styles.backend}
|
||||
/>
|
||||
</TrackedLink>
|
||||
|
||||
<TrackedLink
|
||||
href="/contracts"
|
||||
category={TRACKING_CATEGORY}
|
||||
label="onchain-icon"
|
||||
>
|
||||
<ChakraNextImage
|
||||
src={require("../../../../public/assets/landingpage/desktop/icon-onchain.png")}
|
||||
alt="icon-onchain"
|
||||
maxW={{ base: "45px", sm: "60px", md: "75px" }}
|
||||
position="absolute"
|
||||
zIndex={2}
|
||||
cursor="pointer"
|
||||
className={styles.onchain}
|
||||
/>
|
||||
</TrackedLink>
|
||||
|
||||
<ChakraNextImage
|
||||
src={require("../../../../public/assets/landingpage/desktop/hero-homepage.png")}
|
||||
alt="hero-image"
|
||||
className={styles.heroImageV1}
|
||||
/>
|
||||
|
||||
<ChakraNextImage
|
||||
src={require("../../../../public/assets/landingpage/desktop/hero-homepage-v2.png")}
|
||||
alt="hero-image"
|
||||
className={styles.heroImageV2}
|
||||
/>
|
||||
</Flex>
|
||||
</SimpleGrid>
|
||||
</HomepageSection>
|
||||
);
|
||||
};
|
||||
|
||||
function GetStartedButtonLink(props: {
|
||||
trackingCategory: string;
|
||||
showTeamLayout?: boolean;
|
||||
}) {
|
||||
// using state+effect here to avoid hydration errors
|
||||
const [showTeamLayout, setShowTeamLayout] = useState(false);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (getCookie("x-dashboard-type") === "team") {
|
||||
setShowTeamLayout(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TrackedLinkButton
|
||||
leftIcon={<ZapIcon className="size-4 fill-black" />}
|
||||
py={6}
|
||||
px={8}
|
||||
w="full"
|
||||
bgColor="white"
|
||||
_hover={{
|
||||
bgColor: "white",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
color="black"
|
||||
href={showTeamLayout ? "/team" : "/dashboard"}
|
||||
category={props.trackingCategory}
|
||||
label="get-started"
|
||||
fontWeight="bold"
|
||||
maxW={{ base: "full", sm: "fit-content" }}
|
||||
>
|
||||
Get started
|
||||
</TrackedLinkButton>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user