Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e46b5b94e | ||
|
|
e0dcc64256 | ||
|
|
f0d6e343ee | ||
|
|
cf4443a9eb | ||
|
|
355795a2d4 | ||
|
|
5fda794569 | ||
|
|
93b3bad783 | ||
|
|
f5952621ab | ||
|
|
1d9f4e5e3e | ||
|
|
2e99057f90 | ||
|
|
13764f1abb | ||
|
|
997c3945a7 | ||
|
|
2bf2e1eec4 | ||
|
|
9b3725a56e | ||
|
|
beafe43f46 | ||
|
|
214a74c36a | ||
|
|
d0e3c4defc | ||
|
|
0cdf34ea98 | ||
|
|
b1632a3df1 | ||
|
|
d3f43a757b | ||
|
|
825352476c | ||
|
|
8caa095a67 | ||
|
|
997e1197e9 | ||
|
|
c9ac25fe3f | ||
|
|
79260c8cd0 | ||
|
|
cf8badbc46 | ||
|
|
09e4d80b89 | ||
|
|
1edd6c7e4c | ||
|
|
588f19639f | ||
|
|
f2c12781ef | ||
|
|
9d7d513d37 | ||
|
|
10de2712dd | ||
|
|
0ca1a79342 | ||
|
|
be344ac8c4 | ||
|
|
754ad99cfd | ||
|
|
d6e4898458 | ||
|
|
191fb3a3a6 | ||
|
|
03a809a33f | ||
|
|
874ef7af4e | ||
|
|
ef9c7df220 | ||
|
|
a640a1e147 | ||
|
|
52678e02a8 | ||
|
|
b9ed7535a4 | ||
|
|
70d4a5a3db | ||
|
|
ea28704a04 | ||
|
|
814024ae24 | ||
|
|
9d3fa73da7 | ||
|
|
fea4a4afe8 | ||
|
|
2e16f9ecef | ||
|
|
1032bc7cc4 | ||
|
|
7e6d1decfc | ||
|
|
73b2c512ce | ||
|
|
13cda91d10 | ||
|
|
e91421a252 | ||
|
|
74c6bca538 | ||
|
|
23beb9d263 | ||
|
|
69657377f4 | ||
|
|
9db4b293a5 | ||
|
|
867d2dae8a | ||
|
|
cb27dafca7 | ||
|
|
6b7eedeecb | ||
|
|
53da850397 | ||
|
|
de1bd6e903 | ||
|
|
ff7f61c038 | ||
|
|
2408a7f9ed | ||
|
|
98bb37c1a1 | ||
|
|
3c32b99b3a | ||
|
|
e5a046ecdd | ||
|
|
237f5faf42 | ||
|
|
ad2588b5de | ||
|
|
db8695d0e8 | ||
|
|
2d4a5c3384 | ||
|
|
dbf3873aaf | ||
|
|
b15aa4cdff | ||
|
|
689b8dc160 | ||
|
|
218355bf95 | ||
|
|
9be0a2fe85 | ||
|
|
22b335b1f2 | ||
|
|
dfbaeb1c3d | ||
|
|
dc99b88cac | ||
|
|
66906dbef3 | ||
|
|
7a68e3b8ed | ||
|
|
cb4b6fde85 | ||
|
|
141fd53759 | ||
|
|
478d843975 | ||
|
|
386fe1ce68 | ||
|
|
539bdab403 | ||
|
|
0e3cb1338b | ||
|
|
34cdd4ce95 | ||
|
|
c31617436c | ||
|
|
40fbe9abb9 | ||
|
|
74e005214d | ||
|
|
1c48c20785 | ||
|
|
7befc61b70 | ||
|
|
6b4503a91c |
@@ -19,7 +19,21 @@ env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
jobs:
|
||||
optimize_ci:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.check_skip.outputs.skip }}
|
||||
steps:
|
||||
- name: Optimize CI
|
||||
id: check_skip
|
||||
uses: withgraphite/graphite-ci-action@main
|
||||
with:
|
||||
graphite_token: ${{ secrets.GRAPHITE_OMTIMIZE_TOKEN }}
|
||||
|
||||
|
||||
build:
|
||||
needs: optimize_ci
|
||||
if: needs.optimize_ci.outputs.skip == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Packages
|
||||
steps:
|
||||
@@ -33,6 +47,8 @@ jobs:
|
||||
run: pnpm build:packages
|
||||
|
||||
lint:
|
||||
needs: optimize_ci
|
||||
if: needs.optimize_ci.outputs.skip == 'false'
|
||||
timeout-minutes: 15
|
||||
name: Lint Packages
|
||||
runs-on: ubuntu-latest
|
||||
@@ -51,6 +67,8 @@ jobs:
|
||||
- run: pnpm lint
|
||||
|
||||
test:
|
||||
needs: optimize_ci
|
||||
if: needs.optimize_ci.outputs.skip == 'false'
|
||||
timeout-minutes: 15
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -77,6 +95,8 @@ jobs:
|
||||
verbose: true
|
||||
|
||||
e2e:
|
||||
needs: optimize_ci
|
||||
if: needs.optimize_ci.outputs.skip == 'false'
|
||||
timeout-minutes: 15
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -144,8 +164,9 @@ jobs:
|
||||
fi
|
||||
|
||||
size:
|
||||
needs: optimize_ci
|
||||
# only run on pull requests
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && needs.optimize_ci.outputs.skip == 'false'
|
||||
timeout-minutes: 15
|
||||
name: "Size"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -27,7 +27,20 @@ env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
jobs:
|
||||
optimize_ci:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.check_skip.outputs.skip }}
|
||||
steps:
|
||||
- name: Optimize CI
|
||||
id: check_skip
|
||||
uses: withgraphite/graphite-ci-action@main
|
||||
with:
|
||||
graphite_token: ${{ secrets.GRAPHITE_OMTIMIZE_TOKEN }}
|
||||
|
||||
build:
|
||||
needs: optimize_ci
|
||||
if: needs.optimize_ci.outputs.skip == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Packages
|
||||
steps:
|
||||
@@ -41,6 +54,8 @@ jobs:
|
||||
run: pnpm build:legacy_packages
|
||||
|
||||
lint:
|
||||
needs: optimize_ci
|
||||
if: needs.optimize_ci.outputs.skip == 'false'
|
||||
timeout-minutes: 15
|
||||
name: Lint Packages
|
||||
runs-on: ubuntu-latest
|
||||
@@ -54,6 +69,8 @@ jobs:
|
||||
- run: pnpm lint:legacy
|
||||
|
||||
test:
|
||||
needs: optimize_ci
|
||||
if: needs.optimize_ci.outputs.skip == 'false'
|
||||
timeout-minutes: 15
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -74,6 +91,8 @@ jobs:
|
||||
verbose: true
|
||||
|
||||
e2e:
|
||||
needs: optimize_ci
|
||||
if: needs.optimize_ci.outputs.skip == 'false'
|
||||
timeout-minutes: 15
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -18,5 +18,8 @@
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ NEXT_PUBLIC_STRIPE_KEY=
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PAYMENT_METHOD_CFG_ID=
|
||||
|
||||
# Needed for contract analytics / blockchain data information
|
||||
CHAINSAW_API_KEY=
|
||||
|
||||
#
|
||||
# Private (server)
|
||||
#
|
||||
|
||||
@@ -80,6 +80,12 @@ module.exports = {
|
||||
message:
|
||||
"Import from `@chakra-ui/react` instead of `@chakra-ui/menu`.",
|
||||
},
|
||||
{
|
||||
name: "next/navigation",
|
||||
importNames: ["useRouter"],
|
||||
message:
|
||||
'Use `import { useDashboardRouter } from "@/lib/DashboardRouter";` instead',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@coinbase/onchainkit": "^0.14.2",
|
||||
"@emotion/react": "11.11.4",
|
||||
"@emotion/styled": "11.11.5",
|
||||
"@hookform/resolvers": "^3.6.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@n8tb1t/use-scroll-position": "^2.0.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
@@ -40,12 +40,13 @@
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "1.1.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"@stripe/react-stripe-js": "^2.7.2",
|
||||
"@stripe/react-stripe-js": "^2.7.3",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
"@tanstack/query-async-storage-persister": "^4.36.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
@@ -118,7 +119,7 @@
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@next/bundle-analyzer": "^14.2.4",
|
||||
"@next/eslint-plugin-next": "^14.2.4",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@playwright/test": "1.44.1",
|
||||
"@types/color": "^3.0.6",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"applinks": {},
|
||||
"webcredentials": {
|
||||
"apps": ["XYHGSFAMHX.com.thirdweb.demo"]
|
||||
},
|
||||
"appclips": {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
"delegate_permission/common.get_login_creds"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.thirdweb.demo",
|
||||
"sha256_cert_fingerprints": [
|
||||
"40:EB:0D:96:57:F3:D8:1F:BA:87:B8:E4:26:E0:3A:DB:C8:35:96:A8:A2:B2:55:F0:B1:64:F1:39:F8:6F:7E:EB"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 839 KiB |
|
After Width: | Height: | Size: 779 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 833 KiB |
|
After Width: | Height: | Size: 694 KiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 646 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -282,6 +282,13 @@ function redirects() {
|
||||
"https://support.thirdweb.com/troubleshooting-errors/7Y1BqKNvtLdBv5fZkRZZB3/issue-linking-device-on-the-authorization-page-via-thirdweb-cli/cn9LRA3ax7XCP6uxwRYdvx",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
// temporary redirect gas -> explore page
|
||||
{
|
||||
source: "/gas",
|
||||
destination: "/explore",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
// metadata
|
||||
title: string;
|
||||
description?: string;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
showLegend?: boolean;
|
||||
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
props: ThirdwebAreaChartProps<TConfig>,
|
||||
) {
|
||||
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
{props.description && (
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<defs>
|
||||
{configKeys.map((key) => (
|
||||
<linearGradient
|
||||
key={key}
|
||||
id={`fill_${key}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={`var(--color-${key})`}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={`var(--color-${key})`}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{configKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
dataKey={key}
|
||||
type="natural"
|
||||
fill={`url(#fill_${key})`}
|
||||
fillOpacity={0.4}
|
||||
stroke={`var(--color-${key})`}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showLegend && (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
)}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
// metadata
|
||||
title: string;
|
||||
description?: string;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
showLegend?: boolean;
|
||||
variant?: "stacked" | "grouped";
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
};
|
||||
|
||||
export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
props: ThirdwebBarChartProps<TConfig>,
|
||||
) {
|
||||
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
|
||||
// if there are more than 4 keys then we should stack them by default
|
||||
const variant =
|
||||
props.variant || configKeys.length > 4 ? "stacked" : "grouped";
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
{props.description && (
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
<BarChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
{props.showLegend && (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
)}
|
||||
{configKeys.map((key, idx) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
// if stacked then they should all be the same stackId
|
||||
// if grouped then they should all be unique stackId (so the key works great)
|
||||
stackId={variant === "stacked" ? "a" : key}
|
||||
fill={`var(--color-${key})`}
|
||||
// if stacked then we need to figure out the radius based on the index in the array
|
||||
// if grouped then we can just use the same radius for all
|
||||
radius={
|
||||
variant === "stacked"
|
||||
? idx === 0
|
||||
? [0, 0, 4, 4]
|
||||
: idx === configKeys.length - 1
|
||||
? [4, 4, 0, 0]
|
||||
: [0, 0, 0, 0]
|
||||
: [4, 4, 4, 4]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Highlight } from "prism-react-renderer";
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Card } from "./card";
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
className?: string;
|
||||
canCopy?: boolean;
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language = "sh",
|
||||
canCopy = true,
|
||||
}: CodeBlockProps) => {
|
||||
return (
|
||||
<Highlight code={code} language={language}>
|
||||
{({ className, tokens, getLineProps, getTokenProps }) => (
|
||||
<Card className={className}>
|
||||
{canCopy && (
|
||||
<CopyButton text={code} className="float-right p-2 m-2" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"block font-mono text-sm whitespace-pre-wrap p-4 overflow-auto",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index IS the key here
|
||||
<div {...getLineProps({ line, key: i })} key={i}>
|
||||
{line.map((token, key) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index IS the key here
|
||||
<span {...getTokenProps({ token, key })} key={key} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect";
|
||||
import type React from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function DynamicHeight(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -37,7 +38,7 @@ export function useHeightObserver() {
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState<number | undefined>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.unorderedList,
|
||||
.orderedList {
|
||||
@apply pl-5 leading-relaxed list-none text-secondary-foreground;
|
||||
}
|
||||
|
||||
.unorderedList > li {
|
||||
@apply pb-1 pl-2 list-disc;
|
||||
}
|
||||
|
||||
.orderedList > li {
|
||||
@apply pb-1 pl-2 list-decimal;
|
||||
}
|
||||
|
||||
.unorderedList > li::marker {
|
||||
@apply text-muted-foreground !m-0 text-left;
|
||||
}
|
||||
|
||||
.orderedList > li::marker {
|
||||
@apply text-muted-foreground text-left;
|
||||
}
|
||||
|
||||
.unorderedList > li:hover::marker,
|
||||
.orderedList > li:hover::marker {
|
||||
@apply text-foreground;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import styles from "./List.module.css";
|
||||
|
||||
export function UnorderedList(props: { children?: React.ReactNode }) {
|
||||
return <ul className={styles.unorderedList}>{props.children}</ul>;
|
||||
}
|
||||
|
||||
export function OrderedList(props: { children?: React.ReactNode }) {
|
||||
return <ul className={styles.orderedList}>{props.children}</ul>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { useRef } from "react";
|
||||
import styles from "./ScrollShadow.module.css";
|
||||
|
||||
export function ScrollShadow(props: {
|
||||
@@ -17,7 +18,7 @@ export function ScrollShadow(props: {
|
||||
const shadowRightEl = useRef<HTMLDivElement>(null);
|
||||
const wrapperEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const content = scrollableEl.current;
|
||||
const shadowTop = shadowTopEl.current;
|
||||
const shadowBottom = shadowBottomEl.current;
|
||||
|
||||
@@ -10,7 +10,7 @@ const alertVariants = cva(
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 border-destructive/50 dark:border-destructive [&>svg]:text-destructive",
|
||||
"bg-destructive/30 border-destructive/50 dark:border-destructive [&>svg]:text-destructive",
|
||||
info: "bg-primary/10 border-primary/50 dark:border-primary [&>svg]:text-primary",
|
||||
warning:
|
||||
"bg-warning/10 border-warning/50 dark:border-warning [&>svg]:text-warning",
|
||||
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -283,7 +283,7 @@ const ChartLegendContent = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
"flex items-center justify-center gap-4 overflow-x-auto",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-foreground data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ScrollShadow } from "./ScrollShadow/ScrollShadow";
|
||||
import { Button } from "./button";
|
||||
|
||||
@@ -126,7 +129,7 @@ function useUnderline<El extends HTMLElement>() {
|
||||
setActiveTabEl(el);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (activeTabEl && containerRef.current && lineRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const lineEl = lineRef.current;
|
||||
|
||||
@@ -31,7 +31,7 @@ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
export function ToolTipLabel(props: {
|
||||
children: React.ReactNode;
|
||||
label: string | undefined;
|
||||
label: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
leftIcon?: React.ReactNode;
|
||||
}) {
|
||||
@@ -47,7 +47,7 @@ export function ToolTipLabel(props: {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className="max-w-[300px] leading-relaxed"
|
||||
className="max-w-[400px] leading-relaxed whitespace-normal"
|
||||
>
|
||||
<div className="p-2 text-sm flex items-center gap-1.5">
|
||||
{props.leftIcon}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { createStore } from "./reactive";
|
||||
|
||||
// Using useDashboardRouter instead of useRouter gives us a nice progress bar on top of the page when navigating using router.push or router.replace
|
||||
|
||||
// using a store instead of context to avoid triggering re-renders on root component
|
||||
export const LoadingRouteHref = createStore<string | undefined>(undefined);
|
||||
|
||||
export function useDashboardRouter() {
|
||||
const router = useRouter();
|
||||
return useMemo(() => {
|
||||
return {
|
||||
...router,
|
||||
replace(href: string, options?: { scroll?: boolean }) {
|
||||
LoadingRouteHref.setValue(href);
|
||||
router.replace(href, options);
|
||||
},
|
||||
push(href: string, options?: { scroll?: boolean }) {
|
||||
LoadingRouteHref.setValue(href);
|
||||
router.push(href, options);
|
||||
},
|
||||
};
|
||||
}, [router]);
|
||||
}
|
||||
|
||||
function useRouterLoadingStatus() {
|
||||
const loadingHref = useSyncExternalStore(
|
||||
LoadingRouteHref.subscribe,
|
||||
LoadingRouteHref.getValue,
|
||||
LoadingRouteHref.getValue,
|
||||
);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParamsStr = searchParams?.toString();
|
||||
|
||||
const routerHref = pathname + (searchParamsStr ? `?${searchParamsStr}` : "");
|
||||
const isLoading = loadingHref && loadingHref !== routerHref;
|
||||
|
||||
// reset loading on route load
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
LoadingRouteHref.setValue(undefined);
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Render this on root
|
||||
function DashboardRouterTopProgressBarInner() {
|
||||
const isLoading = useRouterLoadingStatus();
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const progressStartedRef = useRef(false);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setProgress(0);
|
||||
progressStartedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
progressStartedRef.current = true;
|
||||
|
||||
async function updateProgressBar(progress: number, delay: number) {
|
||||
if (!isMounted) {
|
||||
if (!isLoading) {
|
||||
setProgress(100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(progress);
|
||||
await wait(delay);
|
||||
|
||||
// increase progress by 10% of the remaining progress
|
||||
// increase the delay by 5% to slow down number of updates for slower progress
|
||||
updateProgressBar(progress + (100 - progress) / 10, delay * 1.05);
|
||||
}
|
||||
|
||||
async function startEffect() {
|
||||
// if the loading state remains for atleast 500ms start the progress bar
|
||||
await wait(500);
|
||||
if (isMounted) {
|
||||
updateProgressBar(0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
startEffect();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isLoading]);
|
||||
|
||||
const width = isLoading ? progress : 100;
|
||||
return (
|
||||
<span
|
||||
className="block fixed top-0 h-[2px] bg-blue-500"
|
||||
style={{
|
||||
opacity: isLoading ? "100" : "0",
|
||||
width: `${width}%`,
|
||||
transition: width === 0 ? "none" : "width 0.2s ease, opacity 0.3s ease",
|
||||
zIndex: "100000000",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// need to wrap with suspense because of useSearchParams usage
|
||||
export function DashboardRouterTopProgressBar() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DashboardRouterTopProgressBarInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type Store<T> = {
|
||||
getValue(): T;
|
||||
setValue(newValue: T): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a reactive value store
|
||||
* @param initialValue - The initial value to store
|
||||
* @example
|
||||
* ```ts
|
||||
* const store = createStore(0);
|
||||
* ```
|
||||
* @returns A store object
|
||||
* @internal
|
||||
*/
|
||||
export function createStore<T>(initialValue: T): Store<T> {
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let value = initialValue;
|
||||
|
||||
const notify = () => {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
return value;
|
||||
},
|
||||
setValue(newValue: T) {
|
||||
if (newValue === value) {
|
||||
return;
|
||||
}
|
||||
value = newValue;
|
||||
notify();
|
||||
},
|
||||
subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
|
||||
export const useIsomorphicLayoutEffect =
|
||||
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||
@@ -94,19 +94,22 @@
|
||||
/* shadcn charts */
|
||||
@layer base {
|
||||
:root {
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
/* below is ai generated based on the initial */
|
||||
--chart-6: 200 65% 50%;
|
||||
--chart-7: 120 70% 45%;
|
||||
--chart-8: 45 75% 55%;
|
||||
--chart-9: 260 60% 60%;
|
||||
--chart-10: 0 70% 50%;
|
||||
--chart-11: 180 60% 50%;
|
||||
--chart-12: 100 65% 55%;
|
||||
--chart-13: 360 70% 55%;
|
||||
--chart-14: 240 65% 50%;
|
||||
--chart-15: 80 60% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,8 +90,10 @@ export const engineKeys = {
|
||||
health: (instance: string) =>
|
||||
[...engineKeys.all, instance, "health"] as const,
|
||||
latestVersion: () => [...engineKeys.all, "latestVersion"] as const,
|
||||
metrics: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "metrics"] as const,
|
||||
systemMetrics: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "systemMetrics"] as const,
|
||||
queueMetrics: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "queueMetrics"] as const,
|
||||
};
|
||||
|
||||
export const contractKeys = {
|
||||
|
||||
@@ -913,12 +913,7 @@ export function useAuthorizedWallets() {
|
||||
export async function fetchChainsFromApi() {
|
||||
// always fetch from prod for chains for now
|
||||
// TODO: re-visit this
|
||||
const res = await fetch("https://api.thirdweb.com/v1/chains", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const res = await fetch("https://api.thirdweb.com/v1/chains");
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { getCompilerMetadata } from "thirdweb/contract";
|
||||
import { getContractMetadata, name, symbol } from "thirdweb/extensions/common";
|
||||
|
||||
/**
|
||||
* Fetch the contract metadata for Dashboard
|
||||
*
|
||||
* If the contract itself doesn't implement the metadata extension,
|
||||
* we try to fall back to:
|
||||
* 1. name(), symbol()
|
||||
* 2. our API endpoint
|
||||
*/
|
||||
export function useDashboardContractMetadata(contract: ThirdwebContract) {
|
||||
return useQuery(
|
||||
["contract-metadata-header", contract.chain.id, contract.address],
|
||||
async () => {
|
||||
const [contractMetadata, _name, _symbol, compilerMetadata] =
|
||||
await Promise.all([
|
||||
getContractMetadata({ contract }).catch(() => ({
|
||||
image: "",
|
||||
name: "",
|
||||
symbol: "",
|
||||
})),
|
||||
name({ contract }).catch(() => ""),
|
||||
symbol({ contract }).catch(() => ""),
|
||||
getCompilerMetadata(contract).catch(() => undefined),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Handle the case where the contract.thirdweb.com endpoint returns a very long name
|
||||
* example url: https://contract.thirdweb.com/metadata/324/0x66E298335c5992d599132582De8eaAF75348e6e6
|
||||
* In that case, we split the solidity name on ":" and take the second part
|
||||
*/
|
||||
if (compilerMetadata?.name.includes(":")) {
|
||||
const _name = compilerMetadata.name.split(":")[1];
|
||||
if (_name) {
|
||||
compilerMetadata.name = compilerMetadata.name.split(":")[1];
|
||||
}
|
||||
}
|
||||
|
||||
const contractName =
|
||||
contractMetadata?.name || _name || compilerMetadata?.name || "";
|
||||
const contractSymbol = contractMetadata?.symbol || _symbol || "";
|
||||
|
||||
return {
|
||||
name: contractName,
|
||||
symbol: contractSymbol,
|
||||
image: contractMetadata.image || "",
|
||||
contractType: compilerMetadata?.name || "",
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -126,9 +126,9 @@ export function useEngineSystemHealth(
|
||||
instanceUrl: string,
|
||||
pollInterval: number | false = false,
|
||||
) {
|
||||
return useQuery(
|
||||
engineKeys.health(instanceUrl),
|
||||
async () => {
|
||||
return useQuery({
|
||||
queryKey: engineKeys.health(instanceUrl),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${instanceUrl}system/health`, {
|
||||
headers: getEngineRequestHeaders(null),
|
||||
});
|
||||
@@ -138,23 +138,57 @@ export function useEngineSystemHealth(
|
||||
const json = (await res.json()) as EngineSystemHealth;
|
||||
return json;
|
||||
},
|
||||
{
|
||||
enabled: !!instanceUrl,
|
||||
refetchInterval: pollInterval,
|
||||
enabled: !!instanceUrl,
|
||||
refetchInterval: pollInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export interface EngineSystemQueueMetrics {
|
||||
result: {
|
||||
queued: number;
|
||||
pending: number;
|
||||
latency?: {
|
||||
msToSend: { p50: number; p90: number };
|
||||
msToMine: { p50: number; p90: number };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function useEngineQueueMetrics(
|
||||
instanceUrl: string,
|
||||
pollInterval: number | false = false,
|
||||
) {
|
||||
const token = useLoggedInUser().user?.jwt ?? null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: engineKeys.queueMetrics(instanceUrl),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${instanceUrl}system/queue`, {
|
||||
headers: getEngineRequestHeaders(token),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
return (await res.json()) as EngineSystemQueueMetrics;
|
||||
},
|
||||
);
|
||||
enabled: !!instanceUrl && !!token,
|
||||
refetchInterval: pollInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEngineLatestVersion() {
|
||||
return useQuery(engineKeys.latestVersion(), async () => {
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/engine/latest-version`, {
|
||||
method: "GET",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.data.version as string;
|
||||
return useQuery({
|
||||
queryKey: engineKeys.latestVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/engine/latest-version`, {
|
||||
method: "GET",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.data.version as string;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -299,6 +333,7 @@ export type Transaction = {
|
||||
gasPrice?: string | null;
|
||||
maxFeePerGas?: string | null;
|
||||
maxPriorityFeePerGas?: string | null;
|
||||
effectiveGasPrice?: string | null;
|
||||
transactionType?: number | null;
|
||||
transactionHash?: string | null;
|
||||
queuedAt?: string | null;
|
||||
@@ -1462,11 +1497,11 @@ export interface EngineResourceMetrics {
|
||||
};
|
||||
}
|
||||
|
||||
export function useEngineResourceMetrics(engineId: string) {
|
||||
export function useEngineSystemMetrics(engineId: string) {
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
return useQuery(
|
||||
engineKeys.metrics(engineId),
|
||||
engineKeys.systemMetrics(engineId),
|
||||
async () => {
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/${engineId}/metrics`,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { isLoginRequired } from "@/constants/auth";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { EnsureLoginResponse } from "app/api/auth/ensure-login/route";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
useActiveAccount,
|
||||
@@ -20,7 +21,7 @@ export function useLoggedInUser(): {
|
||||
isLoggedIn: boolean;
|
||||
user: { address: string; jwt?: string } | null;
|
||||
} {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const connectedAddress = useActiveAccount()?.address;
|
||||
const connectionStatus = useActiveWalletConnectionStatus();
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
import { useSDKChainId } from "@thirdweb-dev/react";
|
||||
import type { Split } from "@thirdweb-dev/sdk";
|
||||
import type {
|
||||
BalanceQueryRequest,
|
||||
BalanceQueryResponse,
|
||||
} from "pages/api/moralis/balances";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { distribute, distributeByToken } from "thirdweb/extensions/split";
|
||||
import { useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { parseErrorToMessage } from "utils/errorParser";
|
||||
import { splitsKeys } from "..";
|
||||
import {
|
||||
useMutationWithInvalidate,
|
||||
useQueryWithNetwork,
|
||||
} from "./query/useQueryWithNetwork";
|
||||
|
||||
export function useSplitData(contract?: Split) {
|
||||
return useQueryWithNetwork(
|
||||
splitsKeys.list(contract?.getAddress()),
|
||||
async () => contract?.getAllRecipients(),
|
||||
{
|
||||
enabled: !!contract,
|
||||
},
|
||||
);
|
||||
}
|
||||
export function useSplitBalances(contractAddress?: string) {
|
||||
const chainId = useSDKChainId();
|
||||
export function useSplitBalances(contract: ThirdwebContract) {
|
||||
const chainId = contract.chain.id;
|
||||
const contractAddress = contract.address;
|
||||
const currencies = useQueryWithNetwork(
|
||||
splitsKeys.currencies(contractAddress),
|
||||
async () => {
|
||||
@@ -48,72 +40,31 @@ export function useSplitBalances(contractAddress?: string) {
|
||||
return currencies;
|
||||
}
|
||||
|
||||
export function useSplitDistributeFunds(contract?: Split) {
|
||||
const contractAddress = contract?.getAddress();
|
||||
const balances = useSplitBalances(contractAddress);
|
||||
const toast = useToast();
|
||||
export function useSplitDistributeFunds(contract: ThirdwebContract) {
|
||||
const contractAddress = contract.address;
|
||||
const balances = useSplitBalances(contract);
|
||||
const { mutateAsync } = useSendAndConfirmTransaction();
|
||||
|
||||
return useMutationWithInvalidate(
|
||||
async () => {
|
||||
invariant(contract, "split contract is not ready");
|
||||
invariant(balances.data, "No balances to distribute");
|
||||
|
||||
const distributions = (balances.data || [])
|
||||
.filter((token) => token.display_balance !== "0.0")
|
||||
.map(async (currency) => {
|
||||
if (currency.name === "Native Token") {
|
||||
await contract
|
||||
.distribute()
|
||||
.then(() => {
|
||||
toast({
|
||||
position: "bottom",
|
||||
variant: "solid",
|
||||
title: "Success",
|
||||
description: `Successfully distributed ${currency.name}`,
|
||||
status: "success",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
const transaction =
|
||||
currency.name === "Native Token"
|
||||
? distribute({ contract })
|
||||
: distributeByToken({
|
||||
contract,
|
||||
tokenAddress: currency.token_address,
|
||||
});
|
||||
return void 0;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
toast({
|
||||
position: "bottom",
|
||||
variant: "solid",
|
||||
title: `Error distributing ${currency.name}`,
|
||||
description: parseErrorToMessage(err),
|
||||
status: "error",
|
||||
duration: 9000,
|
||||
isClosable: true,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await contract
|
||||
.distributeToken(currency.token_address)
|
||||
.then(() => {
|
||||
toast({
|
||||
position: "bottom",
|
||||
variant: "solid",
|
||||
title: "Success",
|
||||
description: `Successfully distributed ${currency.name}`,
|
||||
status: "success",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
return void 0;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
toast({
|
||||
position: "bottom",
|
||||
variant: "solid",
|
||||
title: `Error distributing ${currency.name}`,
|
||||
description: parseErrorToMessage(err),
|
||||
status: "error",
|
||||
duration: 9000,
|
||||
isClosable: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
const promise = mutateAsync(transaction);
|
||||
toast.promise(promise, {
|
||||
success: `Successfully distributed ${currency.name}`,
|
||||
error: `Error distributing ${currency.name}`,
|
||||
loading: "Distributing funds",
|
||||
});
|
||||
});
|
||||
|
||||
return await Promise.all(distributions);
|
||||
|
||||
@@ -4,16 +4,15 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { THIRDWEB_ENGINE_FAUCET_WALLET } from "@/constants/env";
|
||||
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
|
||||
import { mapV4ChainToV5Chain } from "contexts/map-chains";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { thirdwebClient } from "lib/thirdweb-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { toUnits } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../../../contexts/map-chains";
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
@@ -48,6 +47,9 @@ export function FaucetButton({
|
||||
client: thirdwebClient,
|
||||
});
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const claimMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
trackEvent({
|
||||
@@ -64,7 +66,6 @@ export function FaucetButton({
|
||||
body: JSON.stringify({
|
||||
chainId: chainId,
|
||||
toAddress: address,
|
||||
amount,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -80,7 +81,6 @@ export function FaucetButton({
|
||||
label: "success",
|
||||
chain_id: chainId,
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
@@ -91,6 +91,12 @@ export function FaucetButton({
|
||||
errorMsg: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries([
|
||||
"testnet-faucet-can-claim",
|
||||
chainId,
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const canClaimFaucetQuery = useQuery({
|
||||
@@ -107,8 +113,6 @@ export function FaucetButton({
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isFaucetEmpty =
|
||||
faucetWalletBalanceQuery.data !== undefined &&
|
||||
faucetWalletBalanceQuery.data.value < toUnits("1", 17);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { thirdwebClient } from "@/constants/client";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { defineDashboardChain } from "lib/defineDashboardChain";
|
||||
import { useTheme } from "next-themes";
|
||||
import { PayEmbed } from "thirdweb/react";
|
||||
@@ -9,10 +10,21 @@ import { getSDKTheme } from "../../../../../../components/sdk-component-theme";
|
||||
|
||||
export function PayModalButton(props: { chainId: number; label: string }) {
|
||||
const { theme } = useTheme();
|
||||
const trackEvent = useTrack();
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary" className="w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "pay",
|
||||
action: "buy",
|
||||
label: "attempt",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -26,6 +38,34 @@ export function PayModalButton(props: { chainId: number; label: string }) {
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
className="!w-auto"
|
||||
payOptions={{
|
||||
onPurchaseSuccess(info) {
|
||||
if (
|
||||
info.type === "crypto" &&
|
||||
info.status.status !== "NOT_FOUND"
|
||||
) {
|
||||
trackEvent({
|
||||
category: "pay",
|
||||
action: "buy",
|
||||
label: "success",
|
||||
type: info.type,
|
||||
chainId: info.status.quote.toToken.chainId,
|
||||
tokenAddress: info.status.quote.toToken.tokenAddress,
|
||||
amount: info.status.quote.toAmount,
|
||||
});
|
||||
}
|
||||
|
||||
if (info.type === "fiat" && info.status.status !== "NOT_FOUND") {
|
||||
trackEvent({
|
||||
category: "pay",
|
||||
action: "buy",
|
||||
label: "success",
|
||||
type: info.type,
|
||||
chainId: info.status.quote.toToken.chainId,
|
||||
tokenAddress: info.status.quote.toToken.tokenAddress,
|
||||
amount: info.status.quote.estimatedToTokenAmount,
|
||||
});
|
||||
}
|
||||
},
|
||||
prefillBuy: {
|
||||
// Do not include local chain overrides for chain pages
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FaucetButton } from "../client/FaucetButton";
|
||||
import { GiftIcon } from "../icons/GiftIcon";
|
||||
import { SectionTitle } from "./SectionTitle";
|
||||
|
||||
const amountToGive = "0.1";
|
||||
const amountToGive = "0.01";
|
||||
|
||||
export async function FaucetSection(props: { chain: ChainMetadata }) {
|
||||
const { chain } = props;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type SortBy, fetchTopContracts } from "lib/search";
|
||||
import { fetchTopContracts } from "lib/search";
|
||||
import { ArrowRightIcon, CircleAlertIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TrendingContractSection } from "../../../trending/components/trending-table";
|
||||
@@ -14,7 +14,6 @@ import { ExplorersSection } from "./components/server/explorer-section";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: { chain_id: string };
|
||||
searchParams: { page?: number; sortBy?: SortBy };
|
||||
}) {
|
||||
const chain = await getChain(props.params.chain_id);
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
@@ -22,8 +21,6 @@ export default async function Page(props: {
|
||||
|
||||
const topContracts = await fetchTopContracts({
|
||||
chainId: chain.chainId,
|
||||
page: props.searchParams.page,
|
||||
sortBy: props.searchParams.sortBy,
|
||||
perPage: 3,
|
||||
timeRange: "month",
|
||||
});
|
||||
@@ -83,8 +80,8 @@ export default async function Page(props: {
|
||||
<TrendingContractSection
|
||||
topContracts={topContracts}
|
||||
chainId={chain.chainId}
|
||||
searchParams={props.searchParams}
|
||||
showPagination={false}
|
||||
searchParams={undefined}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,9 @@ import React from "react";
|
||||
import { TrendingContractSection } from "../../../../trending/components/trending-table";
|
||||
import { getChain } from "../../../utils";
|
||||
|
||||
// we're using searchParams here - use dynamic rendering
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: { chain_id: string };
|
||||
searchParams: { page?: number; sortBy?: SortBy };
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { ChevronDownIcon, Filter, XIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import type React from "react";
|
||||
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
||||
import { products } from "../../../components/server/products";
|
||||
@@ -73,7 +74,7 @@ type FilterResetButtonProps = {
|
||||
const FilterResetButton: React.FC<
|
||||
PropsWithChildren<FilterResetButtonProps>
|
||||
> = ({ filters, children }) => {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const mutableSearchParams = useMemo(() => {
|
||||
@@ -124,7 +125,7 @@ type ChainTypeFilterProps = { sectionOnly?: boolean };
|
||||
export const ChainTypeFilter: React.FC<ChainTypeFilterProps> = ({
|
||||
sectionOnly,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -228,7 +229,7 @@ type ChainOptionsFilterProps = { sectionOnly?: boolean };
|
||||
export const ChainOptionsFilter: React.FC<ChainOptionsFilterProps> = ({
|
||||
sectionOnly,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const mutableSearchParams = useMemo(() => {
|
||||
@@ -332,7 +333,7 @@ type ChainServiceFilterProps = { sectionOnly?: boolean };
|
||||
export const ChainServiceFilter: React.FC<ChainServiceFilterProps> = ({
|
||||
sectionOnly,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const mutableSearchParams = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PaginationButtons } from "@/components/pagination-buttons";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type ChainlistPaginationProps = {
|
||||
@@ -15,7 +16,7 @@ export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(pageNumber: number) => {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { SearchIcon, XCircleIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
@@ -15,7 +16,7 @@ function cleanUrl(url: string) {
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { Grid2X2Icon, ListIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type hainListViewProps = {
|
||||
@@ -12,7 +13,7 @@ type hainListViewProps = {
|
||||
export const ChainListView: React.FC<hainListViewProps> = ({ activeView }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(view: "grid" | "table") => {
|
||||
|
||||
@@ -96,12 +96,6 @@ async function getChainsToRender(params: SearchParams) {
|
||||
// if the chain does not have all of the services in the filter, return false to filter it out
|
||||
if (
|
||||
!urlServiceArray.every((service) => {
|
||||
// TODO improve this
|
||||
// We don't have "faucet" added in services array, so for now we just check if it's testnet or not
|
||||
if (service === "faucet") {
|
||||
return chain.testnet;
|
||||
}
|
||||
|
||||
return chain.services.find((s) => s.enabled && s.service === service);
|
||||
})
|
||||
) {
|
||||
@@ -154,6 +148,10 @@ export const metadata: Metadata = {
|
||||
"A list of EVM networks with RPCs, smart contracts, block explorers & faucets. Deploy smart contracts to all EVM chains with thirdweb.",
|
||||
};
|
||||
|
||||
// we use headers() to determine if we should default to table or grid view by checking viewport
|
||||
// so this page needs to be forced as dynamic
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ChainListPage(props: { searchParams: SearchParams }) {
|
||||
const headersList = headers();
|
||||
const viewportWithHint = Number(
|
||||
|
||||
|
After Width: | Height: | Size: 604 KiB |
|
After Width: | Height: | Size: 745 KiB |
@@ -6,12 +6,18 @@ export type ChainSupportedService =
|
||||
| "engine"
|
||||
| "account-abstraction"
|
||||
| "pay"
|
||||
| "faucet"
|
||||
| "rpc-edge";
|
||||
| "rpc-edge"
|
||||
| "chainsaw";
|
||||
|
||||
export type ChainService = {
|
||||
service: ChainSupportedService;
|
||||
enabled: boolean;
|
||||
status: "enabled" | "disabled";
|
||||
};
|
||||
|
||||
export type ChainServices = {
|
||||
chainId: number;
|
||||
services: Array<ChainService>;
|
||||
};
|
||||
|
||||
export type ChainMetadataWithServices = ChainMetadata & {
|
||||
|
||||
@@ -20,48 +20,75 @@ import rootStockCTABG from "./temp-assets/rootstock-cta.png";
|
||||
import superpositionBanner from "./temp-assets/superpositionBanner.png";
|
||||
import superpositionCTA from "./temp-assets/superpositionCTA.png";
|
||||
import thirdwebBanner from "./temp-assets/thirdweb-banner.png";
|
||||
import treasureBanner from "./temp-assets/treasureBanner.png";
|
||||
import treasureCTA from "./temp-assets/treasureCta.png";
|
||||
import vanarBanner from "./temp-assets/vanar-banner.png";
|
||||
import vanarCTABG from "./temp-assets/vanar-cta.png";
|
||||
import xaiBanner from "./temp-assets/xai-banner.jpg";
|
||||
|
||||
import type { ChainMetadataWithServices } from "./types/chain";
|
||||
|
||||
// END TEMPORARY
|
||||
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type {
|
||||
ChainMetadataWithServices,
|
||||
ChainService,
|
||||
ChainServices,
|
||||
} from "./types/chain";
|
||||
|
||||
const THIRDWEB_API_HOST =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
|
||||
|
||||
export async function getChains() {
|
||||
const response = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/chains?includeServices=true`,
|
||||
// revalidate every hour
|
||||
{ next: { revalidate: 60 * 60 } },
|
||||
);
|
||||
const [chains, chainServices] = await Promise.all([
|
||||
fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/chains`,
|
||||
// revalidate every 60 minutes
|
||||
{ next: { revalidate: 60 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{ data: ChainMetadata[] }>,
|
||||
fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/chains/services`,
|
||||
// revalidate every 60 minutes
|
||||
{ next: { revalidate: 60 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{
|
||||
data: Record<number, Array<ChainService>>;
|
||||
}>,
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
response.body?.cancel();
|
||||
if (!chains.data.length) {
|
||||
throw new Error("Failed to fetch chains");
|
||||
}
|
||||
return (await response.json()).data as ChainMetadataWithServices[];
|
||||
return chains.data.map((c) => ({
|
||||
...c,
|
||||
services: chainServices.data[c.chainId] || [],
|
||||
})) satisfies ChainMetadataWithServices[];
|
||||
}
|
||||
|
||||
export async function getChain(
|
||||
chainIdOrSlug: string,
|
||||
): Promise<ChainMetadataWithServices> {
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/chains/${chainIdOrSlug}?includeServices=true`,
|
||||
// revalidate every 15 minutes
|
||||
{ next: { revalidate: 15 * 60 } },
|
||||
);
|
||||
const [chain, chainServices] = await Promise.all([
|
||||
fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/chains/${chainIdOrSlug}`,
|
||||
// revalidate every 15 minutes
|
||||
{ next: { revalidate: 15 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{ data: ChainMetadata }>,
|
||||
fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/chains/${chainIdOrSlug}/services`,
|
||||
// revalidate every 15 minutes
|
||||
{ next: { revalidate: 15 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{ data: ChainServices }>,
|
||||
]);
|
||||
|
||||
const result = await res.json();
|
||||
if (!result.data) {
|
||||
if (!chain.data) {
|
||||
redirect("/404");
|
||||
}
|
||||
return result.data as ChainMetadataWithServices;
|
||||
return {
|
||||
...chain.data,
|
||||
services: chainServices.data.services,
|
||||
} satisfies ChainMetadataWithServices;
|
||||
}
|
||||
|
||||
type ChainMetadata = Partial<{
|
||||
type ExtraChainMetadata = Partial<{
|
||||
headerImgUrl: string;
|
||||
about: string;
|
||||
gasSponsored: boolean;
|
||||
@@ -321,12 +348,24 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
},
|
||||
} satisfies Record<number, ChainMetadata>;
|
||||
//treasure
|
||||
978657: {
|
||||
headerImgUrl: treasureBanner.src,
|
||||
about:
|
||||
'Treasure is the decentralized game console. Powered by $MAGIC, the Treasure L2 serves as the base layer for the best cryptonative games and projects. Treasure and its network of "Infinity Chains" L3s offers EVM compatibility, massive scale, and decentralized infrastructure enshrined throughout. Combined with a passionate community and builder support, developers on Treasure are equipped with all of the tools they need to not only build great games and products, but also distribute to the masses.',
|
||||
cta: {
|
||||
backgroundImageUrl: treasureCTA.src,
|
||||
title: "Start building on Treasure!",
|
||||
buttonLink: "https://portal.treasure.lol",
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
},
|
||||
} satisfies Record<number, ExtraChainMetadata>;
|
||||
// END TEMPORARY
|
||||
|
||||
export async function getChainMetadata(
|
||||
chainId: number,
|
||||
): Promise<ChainMetadata | null> {
|
||||
): Promise<ExtraChainMetadata | null> {
|
||||
// TODO: fetch this from the API
|
||||
if (chainId in chainMetaRecord) {
|
||||
return chainMetaRecord[chainId as keyof typeof chainMetaRecord];
|
||||
|
||||
@@ -51,3 +51,6 @@ export default async function Layout({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// because cookies() is used
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,11 +15,11 @@ import { ImageUpload } from "@/components/ui/image-upload";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -49,7 +49,7 @@ export function CreateEcosystemForm() {
|
||||
>();
|
||||
const { data: billingAccountInfo } = useAccount();
|
||||
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
|
||||
@@ -113,3 +113,6 @@ export default async function Page() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// because cookies() is used
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -79,3 +79,6 @@ export default async function DashboardConnectPayPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// because cookies() is used
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -7,6 +7,7 @@ const links: {
|
||||
name: string;
|
||||
segment: string;
|
||||
}[] = [
|
||||
{ name: "Transaction Simulator", segment: "transaction-simulator" },
|
||||
{ name: "Wei Converter", segment: "wei-converter" },
|
||||
{ name: "Hex Converter", segment: "hex-converter" },
|
||||
{ name: "Unix Time Converter", segment: "unixtime-converter" },
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { CodeBlock } from "@/components/ui/CodeBlock";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import type { Abi, AbiFunction } from "abitype";
|
||||
import { thirdwebClient } from "lib/thirdweb-client";
|
||||
import { useV5DashboardChain } from "lib/v5-adapter";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { MdOutlineAccountBalanceWallet } from "react-icons/md";
|
||||
import {
|
||||
getContract,
|
||||
prepareContractCall,
|
||||
resolveMethod,
|
||||
simulateTransaction,
|
||||
toSerializableTransaction,
|
||||
toWei,
|
||||
} from "thirdweb";
|
||||
import { resolveContractAbi } from "thirdweb/contract";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { parseAbiParams } from "thirdweb/utils";
|
||||
import { ShareButton } from "../../components/share";
|
||||
|
||||
export type SimulateTransactionForm = {
|
||||
chainId: number;
|
||||
from: string;
|
||||
to: string;
|
||||
functionName: string;
|
||||
functionArgs: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
codeExample: string;
|
||||
shareUrl: string;
|
||||
};
|
||||
|
||||
export const TransactionSimulator = (props: {
|
||||
searchParams: Partial<SimulateTransactionForm>;
|
||||
}) => {
|
||||
const activeAccount = useActiveAccount();
|
||||
const initialFormValues = props.searchParams;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [state, setState] = useState<State>({
|
||||
success: false,
|
||||
message: "",
|
||||
codeExample: "",
|
||||
shareUrl: "",
|
||||
});
|
||||
const form = useForm<SimulateTransactionForm>();
|
||||
const chainId = form.watch("chainId");
|
||||
const chain = useV5DashboardChain(
|
||||
Number.isInteger(Number(chainId)) ? chainId : undefined,
|
||||
);
|
||||
|
||||
async function handleSimulation(data: SimulateTransactionForm) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { from, to, value, functionArgs, functionName } = data;
|
||||
if (!chain) {
|
||||
throw new Error("Invalid chainId");
|
||||
}
|
||||
const contract = getContract({
|
||||
client: thirdwebClient,
|
||||
chain,
|
||||
address: to,
|
||||
});
|
||||
const abi = (await resolveContractAbi(contract)) as Abi;
|
||||
const abiItem = abi.find(
|
||||
(o) => o.type === "function" && o.name === functionName,
|
||||
) as AbiFunction;
|
||||
if (!abiItem) {
|
||||
throw new Error(`Contract does not have method \`${functionName}]\``);
|
||||
}
|
||||
const inputParams = functionArgs
|
||||
? functionArgs.split(/[\n,]+/).map((arg) => arg.trim())
|
||||
: [];
|
||||
if (abiItem.inputs.length !== inputParams.length) {
|
||||
throw new Error(
|
||||
`Param length mismatch. Expected ${abiItem.inputs.length} params. Received ${
|
||||
inputParams.length
|
||||
}`,
|
||||
);
|
||||
}
|
||||
// Parse to proper types
|
||||
const types = abiItem.inputs.map((o) => o.type);
|
||||
const params = parseAbiParams(types, inputParams);
|
||||
const codeExample = getCodeExample(
|
||||
{
|
||||
chainId: chain.id,
|
||||
from,
|
||||
to,
|
||||
value,
|
||||
functionArgs,
|
||||
functionName,
|
||||
},
|
||||
params,
|
||||
);
|
||||
const shareUrl = getShareUrl({
|
||||
chainId: chain.id,
|
||||
from,
|
||||
to,
|
||||
value,
|
||||
functionArgs,
|
||||
functionName,
|
||||
});
|
||||
const transaction = prepareContractCall({
|
||||
contract,
|
||||
method: resolveMethod(functionName),
|
||||
params,
|
||||
value: value ? toWei(value) : undefined,
|
||||
});
|
||||
const [simulateResult, populatedTransaction] = await Promise.all([
|
||||
simulateTransaction({
|
||||
from,
|
||||
transaction,
|
||||
}),
|
||||
toSerializableTransaction({
|
||||
from,
|
||||
transaction,
|
||||
}),
|
||||
]);
|
||||
setState({
|
||||
success: true,
|
||||
message: `result: ${simulateResult.length > 0 ? simulateResult.join(",") : "Method did not return a result."}\n
|
||||
${Object.keys(populatedTransaction)
|
||||
.map((key) => {
|
||||
let _val = populatedTransaction[key as keyof typeof populatedTransaction];
|
||||
if (key === "value" && !_val) {
|
||||
_val = 0;
|
||||
}
|
||||
return `${key}: ${_val}\n`;
|
||||
})
|
||||
.join("")}`,
|
||||
codeExample,
|
||||
shareUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
setState({
|
||||
success: false,
|
||||
message: `${err}`,
|
||||
codeExample: "",
|
||||
shareUrl: "",
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
return (
|
||||
<div className="max-w-[800px] space-y-4">
|
||||
<p>
|
||||
Simulate a transaction on any EVM chain. Account Abstraction support is
|
||||
coming soon.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSimulation)}
|
||||
className="space-y-4 flex-col"
|
||||
>
|
||||
<Card className="flex flex-col gap-4 p-4">
|
||||
<div className="flex gap-2 sm:items-center flex-col sm:flex-row">
|
||||
<Label htmlFor="chainId" className="min-w-60">
|
||||
Chain ID
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
required
|
||||
placeholder="The chain ID of the sender wallet"
|
||||
defaultValue={initialFormValues.chainId}
|
||||
{...form.register("chainId", { required: true })}
|
||||
// Hide spinner.
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 sm:items-center flex-col sm:flex-row">
|
||||
<Label htmlFor="from" className="min-w-60">
|
||||
From Address
|
||||
</Label>
|
||||
<Input
|
||||
required
|
||||
placeholder="The sender wallet address"
|
||||
defaultValue={initialFormValues.from}
|
||||
{...form.register("from", { required: true })}
|
||||
/>
|
||||
{activeAccount && (
|
||||
<ToolTipLabel label="Use current wallet address">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => form.setValue("from", activeAccount.address)}
|
||||
>
|
||||
<MdOutlineAccountBalanceWallet />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<ArrowDown />
|
||||
</div>
|
||||
|
||||
<Card className="flex flex-col gap-4 p-4">
|
||||
<div className="flex gap-2 sm:items-center flex-col sm:flex-row">
|
||||
<Label htmlFor="to" className="min-w-60">
|
||||
Contract Address
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="The contract address to call"
|
||||
defaultValue={initialFormValues.to}
|
||||
{...form.register("to", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:items-center flex-col sm:flex-row">
|
||||
<Label htmlFor="functionName" className="min-w-60">
|
||||
Function Name
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="The contract function name (i.e. mintTo)"
|
||||
defaultValue={initialFormValues.functionName}
|
||||
{...form.register("functionName", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:items-center flex-col sm:flex-row">
|
||||
<Label htmlFor="functionArgs" className="min-w-60">
|
||||
Function Arguments
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="Comma-separated arguments to call the function"
|
||||
rows={3}
|
||||
className="bg-transparent"
|
||||
{...form.register("functionArgs")}
|
||||
defaultValue={
|
||||
initialFormValues.functionArgs
|
||||
? decodeURIComponent(initialFormValues.functionArgs)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:items-center flex-col sm:flex-row">
|
||||
<Label htmlFor="value" className="min-w-60">
|
||||
Value
|
||||
</Label>
|
||||
<Input
|
||||
{...form.register("value")}
|
||||
placeholder={`The amount of native currency to send (e.g "0.01")`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner className="w-4 h-4 mr-2" />
|
||||
Simulating
|
||||
</>
|
||||
) : (
|
||||
"Simulate Transaction"
|
||||
)}
|
||||
</Button>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
{state.message && (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<ArrowDown />
|
||||
</div>
|
||||
<Card className="max-w-[800px] p-4">
|
||||
<p className="text-sm font-mono whitespace-pre-wrap overflow-auto">
|
||||
{state.success
|
||||
? "--- ✅ Simulation succeeded ---\n"
|
||||
: "--- ❌ Simulation failed ---\n"}
|
||||
{state.message}
|
||||
</p>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
{state.shareUrl && (
|
||||
<CopyTextButton
|
||||
textToShow="Copy Simulation Link"
|
||||
tooltip="Copy Simulation Link"
|
||||
textToCopy={state.shareUrl}
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
)}
|
||||
<ShareButton
|
||||
cta="Share on X"
|
||||
href="https://twitter.com/intent/tweet?text=Easy-to-use%20transaction%20simulator%20by%20thirdweb%20%F0%9F%98%8D&url=https%3A%2F%2Fthirdweb.com%2Ftools%2Ftransaction-simulator"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.codeExample && (
|
||||
<div className="max-w-[800px] flex flex-col gap-2 pt-16 ">
|
||||
<a
|
||||
href="https://portal.thirdweb.com/references/typescript/v5/simulateTransaction"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Connect SDK example
|
||||
</a>
|
||||
<CodeBlock code={state.codeExample} language="typescript" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Generate code example with input values.
|
||||
const getCodeExample = (
|
||||
parsedData: SimulateTransactionForm,
|
||||
params: unknown[],
|
||||
) => {
|
||||
const displayParams = params.map((item) => {
|
||||
if (typeof item === "bigint") {
|
||||
return `${item.toString()}n`;
|
||||
}
|
||||
if (typeof item === "string") {
|
||||
return `"${item}"`;
|
||||
}
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
!Array.isArray(item) &&
|
||||
Object.prototype.toString.call(item) === "[object Object]"
|
||||
) {
|
||||
return JSON.stringify(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return `import {
|
||||
getContract,
|
||||
defineChain,
|
||||
prepareContractCall,
|
||||
createThirdwebClient
|
||||
} from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
// use secretKey instead of clientId in backend environment
|
||||
clientId: "your-client-id",
|
||||
});
|
||||
|
||||
const contract = getContract({
|
||||
client,
|
||||
chain: defineChain(${parsedData.chainId}),
|
||||
address: "${parsedData.to}",
|
||||
});
|
||||
|
||||
const transaction = prepareContractCall({
|
||||
contract,
|
||||
method: resolveMethod("${parsedData.functionName}"),
|
||||
params: [${displayParams.join(", ")}],
|
||||
value: ${parsedData.value ? `${parsedData.value}n` : ""},
|
||||
});
|
||||
|
||||
const result = await simulateTransaction({
|
||||
from: "${parsedData.from}",
|
||||
transaction,
|
||||
});`;
|
||||
};
|
||||
|
||||
// Generate share link from input values.
|
||||
const getShareUrl = (parsedData: SimulateTransactionForm) => {
|
||||
const url = new URL(`${window.location.origin}/tools/transaction-simulator`);
|
||||
url.searchParams.set("chainId", parsedData.chainId.toString());
|
||||
url.searchParams.set("from", parsedData.from);
|
||||
url.searchParams.set("to", parsedData.to);
|
||||
url.searchParams.set("functionName", parsedData.functionName);
|
||||
url.searchParams.set(
|
||||
"functionArgs",
|
||||
encodeURIComponent(parsedData.functionArgs),
|
||||
);
|
||||
url.searchParams.set("value", parsedData.value);
|
||||
return url.href;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
type SimulateTransactionForm,
|
||||
TransactionSimulator,
|
||||
} from "./components/TransactionSimulator";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "thirdweb Transaction Simulator",
|
||||
description:
|
||||
"Simulate any EVM transaction. Get gas estimates and onchain error messages to debug your contract calls.",
|
||||
};
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Partial<SimulateTransactionForm>;
|
||||
}) {
|
||||
return <TransactionSimulator searchParams={props.searchParams} />;
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export function TablePagination() {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const path = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const page = Number(searchParams?.get("page") || 1);
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { SortBy, TimeRange } from "../../../../lib/search";
|
||||
|
||||
export type SortingHeaderProps = {
|
||||
sortBy: string;
|
||||
title: string;
|
||||
searchParams:
|
||||
| { timeRange?: TimeRange; page?: number; sortBy?: SortBy }
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export function SortingHeader(props: SortingHeaderProps) {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const path = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const range = searchParams?.get("timeRange");
|
||||
const page = searchParams?.get("page");
|
||||
const currentSort = searchParams?.get("sortBy") || "transactionCount";
|
||||
const enableSorting = !!props.searchParams;
|
||||
const { timeRange, page, sortBy } = props.searchParams || {};
|
||||
const currentSort = sortBy || "transactionCount";
|
||||
const isCurrentSort = currentSort === props.sortBy;
|
||||
const justify =
|
||||
props.sortBy === "transactionCountChange" ? "justify-start" : "justify-end";
|
||||
|
||||
if (!enableSorting) {
|
||||
return <div className={`flex flex-row ${justify}`}>{props.title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-row ${justify}`}>
|
||||
<Button
|
||||
@@ -26,7 +34,7 @@ export function SortingHeader(props: SortingHeaderProps) {
|
||||
variant={"link"}
|
||||
onClick={() => {
|
||||
router.replace(
|
||||
`${path}?sortBy=${props.sortBy}${range?.length ? `&timeRange=${range}` : ""}${page?.length ? `&page=${page}` : ""}`,
|
||||
`${path}?sortBy=${props.sortBy}${timeRange?.length ? `&timeRange=${timeRange}` : ""}${page ? `&page=${page}` : ""}`,
|
||||
{
|
||||
scroll: false,
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { TimeRange } from "lib/search";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export function TimeRangeSwitcher() {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const path = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const currentSort = searchParams?.get("sortBy") || "transactionCount";
|
||||
|
||||
@@ -17,14 +17,16 @@ import { ChainIcon } from "../../(chain)/components/server/chain-icon";
|
||||
import { TablePagination } from "./pagination.client";
|
||||
import { SortingHeader } from "./sorting-header.client";
|
||||
|
||||
export async function TrendingContractSection(props: {
|
||||
export function TrendingContractSection(props: {
|
||||
topContracts: TrendingContract[];
|
||||
chainId?: number;
|
||||
perPage?: number;
|
||||
searchParams: { timeRange?: TimeRange; page?: number; sortBy?: SortBy };
|
||||
searchParams:
|
||||
| { timeRange?: TimeRange; page?: number; sortBy?: SortBy }
|
||||
| undefined;
|
||||
showPagination: boolean;
|
||||
}) {
|
||||
const { page } = props.searchParams;
|
||||
const { page } = props.searchParams || {};
|
||||
const firstIndex = Math.max(0, ((page || 1) - 1) * (props.perPage || 10));
|
||||
const showChainColumn = !props.chainId;
|
||||
|
||||
@@ -44,16 +46,29 @@ export async function TrendingContractSection(props: {
|
||||
<SortingHeader
|
||||
title="Transactions"
|
||||
sortBy="transactionCount"
|
||||
searchParams={props.searchParams}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortingHeader title="Wallets" sortBy="walletCount" />
|
||||
<SortingHeader
|
||||
title="Wallets"
|
||||
sortBy="walletCount"
|
||||
searchParams={props.searchParams}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortingHeader title="Gas Usage" sortBy="gasUsage" />
|
||||
<SortingHeader
|
||||
title="Gas Usage"
|
||||
sortBy="gasUsage"
|
||||
searchParams={props.searchParams}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortingHeader title="Value Moved" sortBy="totalValueMoved" />
|
||||
<SortingHeader
|
||||
title="Value Moved"
|
||||
sortBy="totalValueMoved"
|
||||
searchParams={props.searchParams}
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -11,6 +11,9 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
// we're using searchParams here - use dynamic rendering
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardContractTrendingPage(props: {
|
||||
searchParams: { timeRange?: TimeRange; page?: number; sortBy?: SortBy };
|
||||
}) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getIpAddress } from "lib/ip";
|
||||
import { cacheTtl } from "lib/redis";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -46,12 +45,21 @@ export const GET = async (req: NextRequest) => {
|
||||
return NextResponse.json(res);
|
||||
}
|
||||
|
||||
const ipAddress = getIpAddress();
|
||||
// vercel provides this for us in the request object || fall back to the header
|
||||
const ipAddress = req.ip || req.headers.get("X-Forwarded-For");
|
||||
if (!ipAddress) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Could not validate elligibility.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const cacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
|
||||
const ttlSeconds = await cacheTtl(cacheKey);
|
||||
|
||||
const res: CanClaimResponseType = {
|
||||
canClaim: ttlSeconds > 0,
|
||||
canClaim: ttlSeconds <= 0,
|
||||
ttlSeconds,
|
||||
type: "throttle",
|
||||
};
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { getIpAddress } from "lib/ip";
|
||||
import { cacheDeleteKey, cacheExists, cacheSet } from "lib/redis";
|
||||
import { NextResponse } from "next/server";
|
||||
import { cacheGet, cacheSet } from "lib/redis";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
|
||||
interface RequestTestnetFundsPayload {
|
||||
chainId: number;
|
||||
toAddress: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
// Note: This handler cannot use "edge" runtime because of Redis usage.
|
||||
export const POST = async (req: Request) => {
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const requestBody = (await req.json()) as RequestTestnetFundsPayload;
|
||||
const { chainId, toAddress, amount } = requestBody;
|
||||
const { chainId, toAddress } = requestBody;
|
||||
if (Number.isNaN(chainId)) {
|
||||
throw new Error("Invalid chain ID.");
|
||||
}
|
||||
@@ -33,55 +31,59 @@ export const POST = async (req: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
const ipAddress = getIpAddress();
|
||||
// vercel provides this for us in the request object || fall back to the header
|
||||
const ipAddress = req.ip || req.headers.get("X-Forwarded-For");
|
||||
if (!ipAddress) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Could not validate elligibility.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
|
||||
|
||||
// Assert 1 request per IP/chain every 24 hours.
|
||||
if (await cacheExists(cacheKey)) {
|
||||
// get the cached value
|
||||
const cachedValue = await cacheGet(cacheKey);
|
||||
// if we have a cached value, return an error
|
||||
if (cachedValue !== null) {
|
||||
return NextResponse.json(
|
||||
{ error: "Already requested funds on this chain in the past 24 hours." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Store the claim request for 24 hours.
|
||||
await cacheSet(cacheKey, "claimed", 24 * 60 * 60);
|
||||
try {
|
||||
// Store the claim request for 24 hours.
|
||||
await cacheSet(cacheKey, "claimed", 24 * 60 * 60);
|
||||
// then actually transfer the funds
|
||||
const url = `${THIRDWEB_ENGINE_URL}/backend-wallet/${chainId}/transfer`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-backend-wallet-address": NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
Authorization: `Bearer ${THIRDWEB_ACCESS_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: toAddress,
|
||||
currencyAddress: ZERO_ADDRESS,
|
||||
amount: "0.01",
|
||||
}),
|
||||
});
|
||||
|
||||
// Assert max amount.
|
||||
if (Number.parseFloat(amount) > 0.1) {
|
||||
return NextResponse.json({ error: "Invalid amount." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (amount !== "0") {
|
||||
try {
|
||||
const url = `${THIRDWEB_ENGINE_URL}/backend-wallet/${chainId}/transfer`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-backend-wallet-address": NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
Authorization: `Bearer ${THIRDWEB_ACCESS_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: toAddress,
|
||||
currencyAddress: ZERO_ADDRESS,
|
||||
amount,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw error.error;
|
||||
}
|
||||
} catch (error) {
|
||||
// Remove user from cache for retry on transfer failure.
|
||||
cacheDeleteKey(cacheKey);
|
||||
return NextResponse.json(
|
||||
{ error: `${(error as Error)?.message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw error.error;
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: `${(error as Error)?.message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ amount: amount.toString() }, { status: 200 });
|
||||
return NextResponse.json({ amount: "0.01" }, { status: 200 });
|
||||
};
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect";
|
||||
import { Menu, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { headerLinks } from "./headerLinks";
|
||||
|
||||
export function MobileBurgerMenu() {
|
||||
const [showBurgerMenu, setShowBurgerMenu] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (showBurgerMenu) {
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
}, [showBurgerMenu]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@/styles/globals.css";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Metadata } from "next";
|
||||
import PlausibleProvider from "next-plausible";
|
||||
@@ -70,6 +71,7 @@ export default function RootLayout({
|
||||
<PostHogPageView />
|
||||
<AppRouterProviders>{children}</AppRouterProviders>
|
||||
<Toaster richColors />
|
||||
<DashboardRouterTopProgressBar />
|
||||
<NextTopLoader
|
||||
color="hsl(var(--primary))"
|
||||
height={2}
|
||||
|
||||
@@ -197,7 +197,7 @@ export async function isLoggedIn(address: string) {
|
||||
return false;
|
||||
}
|
||||
// if the address matches what we expect, we're logged in
|
||||
if (getAddress(json.address) === getAddress(address)) {
|
||||
if (getAddress(json.data.creatorWalletAddress) === getAddress(address)) {
|
||||
// set the active account cookie again
|
||||
cookieStore.set(COOKIE_ACTIVE_ACCOUNT, getAddress(address), {
|
||||
httpOnly: false,
|
||||
|
||||
@@ -129,13 +129,11 @@ export const BarChart = <
|
||||
tickFormatter={(payload) =>
|
||||
index.format
|
||||
? index.format(payload)
|
||||
: index.type === "date"
|
||||
? new Date(payload).toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
})
|
||||
: payload
|
||||
: new Date(payload).toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
})
|
||||
}
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "@tanstack/react-query-persist-client";
|
||||
import { shouldNeverPersistQuery } from "@thirdweb-dev/react";
|
||||
import { ConfigureNetworkModal } from "components/configure-networks/ConfigureNetworkModal";
|
||||
import { DeployModalProvider } from "components/contract-components/contract-deploy-form/deploy-context-modal";
|
||||
import { AppShell, type AppShellProps } from "components/layout/app-shell";
|
||||
import { Onboarding as OnboardingModal } from "components/onboarding";
|
||||
import { OpCreditsGrantedModalWrapper } from "components/onboarding/OpCreditsGrantedModalWrapper";
|
||||
@@ -125,25 +124,23 @@ export const AppLayout: ComponentWithChildren<AppLayoutProps> = (props) => {
|
||||
>
|
||||
<Hydrate state={props.dehydratedState}>
|
||||
<ErrorProvider>
|
||||
<DeployModalProvider>
|
||||
<AllChainsProvider>
|
||||
<ChainsProvider>
|
||||
<EVMContractInfoProvider value={props.contractInfo}>
|
||||
<DashboardThirdwebProvider>
|
||||
<SanctionedAddressesChecker>
|
||||
<PosthogIdentifier />
|
||||
<ConfigModal />
|
||||
<AllChainsProvider>
|
||||
<ChainsProvider>
|
||||
<EVMContractInfoProvider value={props.contractInfo}>
|
||||
<DashboardThirdwebProvider>
|
||||
<SanctionedAddressesChecker>
|
||||
<PosthogIdentifier />
|
||||
<ConfigModal />
|
||||
|
||||
<OnboardingModal />
|
||||
<OpCreditsGrantedModalWrapper />
|
||||
<OnboardingModal />
|
||||
<OpCreditsGrantedModalWrapper />
|
||||
|
||||
<AppShell {...props} />
|
||||
</SanctionedAddressesChecker>
|
||||
</DashboardThirdwebProvider>
|
||||
</EVMContractInfoProvider>
|
||||
</ChainsProvider>
|
||||
</AllChainsProvider>
|
||||
</DeployModalProvider>
|
||||
<AppShell {...props} />
|
||||
</SanctionedAddressesChecker>
|
||||
</DashboardThirdwebProvider>
|
||||
</EVMContractInfoProvider>
|
||||
</ChainsProvider>
|
||||
</AllChainsProvider>
|
||||
</ErrorProvider>
|
||||
</Hydrate>
|
||||
</PersistQueryClientProvider>
|
||||
|
||||
@@ -25,11 +25,15 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { AiOutlineWarning } from "@react-icons/all-files/ai/AiOutlineWarning";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSDK, useSDKChainId } from "@thirdweb-dev/react";
|
||||
import { useSDKChainId } from "@thirdweb-dev/react";
|
||||
import { FaucetButton } from "app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton";
|
||||
import { GiftIcon } from "app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/icons/GiftIcon";
|
||||
import type { ChainMetadataWithServices } from "app/(dashboard)/(chain)/types/chain";
|
||||
import type {
|
||||
ChainMetadataWithServices,
|
||||
ChainServices,
|
||||
} from "app/(dashboard)/(chain)/types/chain";
|
||||
import { getSDKTheme } from "app/components/sdk-component-theme";
|
||||
import { LOCAL_NODE_PKEY } from "constants/misc";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useSupportedChain } from "hooks/chains/configureChains";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
@@ -38,6 +42,7 @@ import Link from "next/link";
|
||||
import { forwardRef, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { VscDebugDisconnect } from "react-icons/vsc";
|
||||
import { toast } from "sonner";
|
||||
import { prepareTransaction, sendTransaction, toWei } from "thirdweb";
|
||||
import { type Chain, type ChainMetadata, localhost } from "thirdweb/chains";
|
||||
import {
|
||||
PayEmbed,
|
||||
@@ -48,6 +53,7 @@ import {
|
||||
useSwitchActiveWalletChain,
|
||||
useWalletBalance,
|
||||
} from "thirdweb/react";
|
||||
import { privateKeyToAccount } from "thirdweb/wallets";
|
||||
import { Button, type ButtonProps, Card, Heading, Text } from "tw-components";
|
||||
import { THIRDWEB_API_HOST } from "../../constants/urls";
|
||||
import { useV5DashboardChain } from "../../lib/v5-adapter";
|
||||
@@ -74,7 +80,6 @@ export const MismatchButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const wallet = useActiveWallet();
|
||||
const activeWalletChain = useActiveWalletChain();
|
||||
const [dialog, setDialog] = useState<undefined | "no-funds" | "pay">();
|
||||
|
||||
const { theme } = useTheme();
|
||||
const evmBalance = useWalletBalance({
|
||||
address: account?.address,
|
||||
@@ -119,6 +124,12 @@ export const MismatchButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
loadingText={loadingText}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (networksMismatch) {
|
||||
eventRef.current = e;
|
||||
return;
|
||||
}
|
||||
|
||||
if (notEnoughBalance) {
|
||||
trackEvent({
|
||||
category: "no-funds",
|
||||
@@ -129,11 +140,6 @@ export const MismatchButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
if (networksMismatch) {
|
||||
eventRef.current = e;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return onClick(e);
|
||||
}
|
||||
@@ -196,7 +202,14 @@ export const MismatchButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{dialog === "no-funds" && (
|
||||
<NoFundsDialogContent
|
||||
chain={activeWalletChain}
|
||||
openPayModal={() => setDialog("pay")}
|
||||
openPayModal={() => {
|
||||
trackEvent({
|
||||
category: "pay",
|
||||
action: "buy",
|
||||
label: "attempt",
|
||||
});
|
||||
setDialog("pay");
|
||||
}}
|
||||
onCloseModal={() => setDialog(undefined)}
|
||||
/>
|
||||
)}
|
||||
@@ -207,6 +220,37 @@ export const MismatchButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
className="!w-auto"
|
||||
payOptions={{
|
||||
onPurchaseSuccess(info) {
|
||||
if (
|
||||
info.type === "crypto" &&
|
||||
info.status.status !== "NOT_FOUND"
|
||||
) {
|
||||
trackEvent({
|
||||
category: "pay",
|
||||
action: "buy",
|
||||
label: "success",
|
||||
type: info.type,
|
||||
chainId: info.status.quote.toToken.chainId,
|
||||
tokenAddress: info.status.quote.toToken.tokenAddress,
|
||||
amount: info.status.quote.toAmount,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
info.type === "fiat" &&
|
||||
info.status.status !== "NOT_FOUND"
|
||||
) {
|
||||
trackEvent({
|
||||
category: "pay",
|
||||
action: "buy",
|
||||
label: "success",
|
||||
type: info.type,
|
||||
chainId: info.status.quote.toToken.chainId,
|
||||
tokenAddress: info.status.quote.toToken.tokenAddress,
|
||||
amount: info.status.quote.estimatedToTokenAmount,
|
||||
});
|
||||
}
|
||||
},
|
||||
prefillBuy: {
|
||||
chain: activeWalletChain,
|
||||
},
|
||||
@@ -229,20 +273,23 @@ function NoFundsDialogContent(props: {
|
||||
onCloseModal: () => void;
|
||||
}) {
|
||||
const chainWithServiceInfoQuery = useQuery({
|
||||
queryKey: ["chain", props.chain.id],
|
||||
queryKey: ["chain-with-services", props.chain.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/chains/${props.chain.id}?includeServices=true`,
|
||||
);
|
||||
const [chain, chainServices] = await Promise.all([
|
||||
fetch(`${THIRDWEB_API_HOST}/v1/chains/${props.chain.id}`).then((res) =>
|
||||
res.json(),
|
||||
) as Promise<{ data: ChainMetadata }>,
|
||||
fetch(`${THIRDWEB_API_HOST}/v1/chains/${props.chain.id}/services`).then(
|
||||
(res) => res.json(),
|
||||
) as Promise<{ data: ChainServices }>,
|
||||
]);
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("Failed to fetch chain metadata with services");
|
||||
}
|
||||
|
||||
return result.data as ChainMetadataWithServices;
|
||||
return {
|
||||
...chain.data,
|
||||
services: chainServices.data.services,
|
||||
} satisfies ChainMetadataWithServices;
|
||||
},
|
||||
enabled: !!props.chain.id,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -261,22 +308,23 @@ function NoFundsDialogContent(props: {
|
||||
</DialogHeader>
|
||||
|
||||
{/* Get Funds content */}
|
||||
|
||||
{!chainWithServiceInfoQuery.data ? (
|
||||
<div className="h-[300px] flex justify-center items-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{props.chain.id === localhost.id && <GetLocalHostTestnetFunds />}
|
||||
|
||||
{props.chain.id !== localhost.id &&
|
||||
chainWithServiceInfoQuery.data.testnet && (
|
||||
<GetFundsFromFaucet chain={chainWithServiceInfoQuery.data} />
|
||||
)}
|
||||
|
||||
{chainWithServiceInfoQuery.data.services.find(
|
||||
(x) => x.enabled && x.service === "pay",
|
||||
) && (
|
||||
{props.chain.id === localhost.id ? (
|
||||
// localhost case
|
||||
<GetLocalHostTestnetFunds />
|
||||
) : chainWithServiceInfoQuery.data.testnet ? (
|
||||
// faucet case
|
||||
<GetFundsFromFaucet chain={chainWithServiceInfoQuery.data} />
|
||||
) : chainWithServiceInfoQuery.data.services.find(
|
||||
(x) => x.enabled && x.service === "pay",
|
||||
) ? (
|
||||
// pay case
|
||||
<ButtonShadcn
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
@@ -284,7 +332,8 @@ function NoFundsDialogContent(props: {
|
||||
>
|
||||
Buy Funds
|
||||
</ButtonShadcn>
|
||||
)}
|
||||
) : // no funds options available
|
||||
null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -313,7 +362,7 @@ 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.1";
|
||||
const amountToGive = "0.01";
|
||||
|
||||
return (
|
||||
<div className="border border-border px-4 py-6 rounded-lg flex justify-center">
|
||||
@@ -439,16 +488,30 @@ const MismatchNotice: React.FC<{
|
||||
};
|
||||
|
||||
const GetLocalHostTestnetFunds: React.FC = () => {
|
||||
const sdk = useSDK();
|
||||
const address = useActiveAccount()?.address;
|
||||
const requestFunds = async () => {
|
||||
if (sdk) {
|
||||
try {
|
||||
await sdk.wallet.requestFunds(10);
|
||||
toast.success("Successfully got funds from localhost");
|
||||
} catch (e) {
|
||||
toast.error("Failed to get funds from localhost");
|
||||
}
|
||||
if (!address) {
|
||||
return toast.error("No active account detected");
|
||||
}
|
||||
const faucet = privateKeyToAccount({
|
||||
privateKey: LOCAL_NODE_PKEY,
|
||||
client: thirdwebClient,
|
||||
});
|
||||
const transaction = prepareTransaction({
|
||||
to: address,
|
||||
chain: localhost,
|
||||
client: thirdwebClient,
|
||||
value: toWei("10"),
|
||||
});
|
||||
const promise = sendTransaction({
|
||||
account: faucet,
|
||||
transaction,
|
||||
});
|
||||
toast.promise(promise, {
|
||||
error: "Failed to get funds from localhost",
|
||||
success: "Successfully got funds from localhost",
|
||||
loading: "Requesting funds",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -235,7 +235,7 @@ export const CmdKSearch: React.FC = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-1">Search Contracts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search a contract by name or contract address accross all
|
||||
Search a contract by name or contract address across all
|
||||
blockchains
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import type { StoredChain } from "contexts/configured-chains";
|
||||
@@ -16,7 +14,7 @@ import { useSupportedChainsNameRecord } from "hooks/chains/configureChains";
|
||||
import { useRemoveChainModification } from "hooks/chains/useModifyChain";
|
||||
import { getDashboardChainRpc } from "lib/rpc";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button, FormErrorMessage, FormLabel, Text } from "tw-components";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { ChainIdInput } from "./Form/ChainIdInput";
|
||||
import { ConfirmationPopover } from "./Form/ConfirmationPopover";
|
||||
import { IconUpload } from "./Form/IconUpload";
|
||||
@@ -217,11 +215,7 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
|
||||
const submitBtn = (
|
||||
<Button
|
||||
background="bgBlack"
|
||||
color="bgWhite"
|
||||
_hover={{
|
||||
background: "bgBlack",
|
||||
}}
|
||||
variant="primary"
|
||||
type={disableSubmit ? "submit" : overwritingChain ? "button" : "submit"}
|
||||
disabled={disableSubmit}
|
||||
>
|
||||
@@ -243,9 +237,6 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="e.g. My Network"
|
||||
_placeholder={{
|
||||
fontWeight: 500,
|
||||
}}
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
@@ -272,9 +263,9 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
{/* Slug URL */}
|
||||
<NetworkIDInput form={form} disabled={!isFullyEditable} />
|
||||
|
||||
<Flex direction="column" gap={6} mt={6}>
|
||||
<div className="flex flex-col gap-6 mt-6">
|
||||
{/* Chain ID + Currency Symbol */}
|
||||
<SimpleGrid columns={{ md: 2, base: 1 }} gap={4}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ChainIdInput form={form} disabled={!isFullyEditable} />
|
||||
|
||||
{/* Currency Symbol */}
|
||||
@@ -284,33 +275,29 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
disabled={!isFullyEditable}
|
||||
placeholder="e.g. ETH"
|
||||
autoComplete="off"
|
||||
_placeholder={{
|
||||
fontWeight: 500,
|
||||
}}
|
||||
type="text"
|
||||
{...form.register("currencySymbol", { required: true })}
|
||||
/>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<SimpleGrid columns={{ md: 2, base: 1 }} gap={4}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Testnet / Mainnet */}
|
||||
<FormControl>
|
||||
<FormLabel display="flex">
|
||||
<FormLabel className="!flex gap-1 items-center">
|
||||
Network type
|
||||
<TooltipBox
|
||||
content={
|
||||
<>
|
||||
<Text color="heading" display="inline-block" mb={2}>
|
||||
{" "}
|
||||
<span className="mb-2 block">
|
||||
The network type indicates whether it is intended for
|
||||
production or testing.
|
||||
</Text>
|
||||
</span>
|
||||
|
||||
<Text color="heading">
|
||||
<span className="block">
|
||||
It{`'`}s only used for displaying network type on the
|
||||
dashboard and does not affect functionality.
|
||||
</Text>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -324,10 +311,10 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
}}
|
||||
value={form.watch("type")}
|
||||
>
|
||||
<Stack direction="row" gap={4} mt={3}>
|
||||
<div className="flex gap-4 mt-3">
|
||||
<Radio value="mainnet">Mainnet</Radio>
|
||||
<Radio value="testnet">Testnet</Radio>
|
||||
</Stack>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
@@ -335,19 +322,19 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
<FormControl isInvalid={!!form.formState.errors.icon}>
|
||||
<FormLabel>Icon</FormLabel>
|
||||
|
||||
<Flex gap={3} alignItems="center">
|
||||
<div className="flex gap-1 items-center">
|
||||
<ChainIcon size={24} ipfsSrc={form.watch("icon")} />
|
||||
<IconUpload
|
||||
onUpload={(uri) => {
|
||||
form.setValue("icon", uri, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
{editingChain?.status === "deprecated" && (
|
||||
<SimpleGrid columns={{ md: 2, base: 1 }} gap={4}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Active / Deprecated */}
|
||||
<FormControl>
|
||||
<FormLabel display="flex">Network status</FormLabel>
|
||||
@@ -360,13 +347,13 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
}}
|
||||
value={form.watch("status")}
|
||||
>
|
||||
<Stack direction="row" gap={4} mt={3}>
|
||||
<div className="flex mt-3 gap-4">
|
||||
<Radio value="active">Live</Radio>
|
||||
<Radio value="deprecated">Deprecated</Radio>
|
||||
</Stack>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RPC URL */}
|
||||
@@ -382,11 +369,7 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<Flex
|
||||
gap={4}
|
||||
direction={{ base: "column", md: "row" }}
|
||||
justifyContent={{ base: "center", md: "flex-end" }}
|
||||
>
|
||||
<div className="flex gap-4 flex-col md:flex-row justify-end">
|
||||
{/* Add / Update Button */}
|
||||
{overwritingChain && !disableSubmit ? (
|
||||
<ConfirmationPopover
|
||||
@@ -417,8 +400,8 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import type { StoredChain } from "contexts/configured-chains";
|
||||
import { useSetEditChain } from "hooks/networkConfigModal";
|
||||
import { ConfigureNetworks } from "./ConfigureNetworks";
|
||||
@@ -24,31 +18,26 @@ export const ConfigureNetworkModal: React.FC<AddNetworkModalProps> = (
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onModalClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
w="500px"
|
||||
mt={{ base: 8, md: 16 }}
|
||||
mb={{ base: 0, md: 16 }}
|
||||
maxW={{ base: "100vw", md: "calc(100vw - 40px)" }}
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
onModalClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="p-0 z-[10001] max-w-[480px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<ModalCloseButton />
|
||||
<ModalBody
|
||||
p={0}
|
||||
_dark={{ background: "backgroundBody" }}
|
||||
_light={{ background: "white" }}
|
||||
>
|
||||
<ConfigureNetworks
|
||||
onNetworkAdded={(chain) => {
|
||||
props.onNetworkAdded?.(chain);
|
||||
onModalClose();
|
||||
}}
|
||||
onNetworkConfigured={onModalClose}
|
||||
/>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<ConfigureNetworks
|
||||
onNetworkAdded={(chain) => {
|
||||
props.onNetworkAdded?.(chain);
|
||||
onModalClose();
|
||||
}}
|
||||
onNetworkConfigured={onModalClose}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Box, useToast } from "@chakra-ui/react";
|
||||
import type { StoredChain } from "contexts/configured-chains";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAddRecentlyUsedChainId } from "hooks/chains/recentlyUsedChains";
|
||||
import { useModifyChain } from "hooks/chains/useModifyChain";
|
||||
import { useEditChain } from "hooks/networkConfigModal";
|
||||
import { Heading } from "tw-components";
|
||||
import { toast } from "sonner";
|
||||
import { ConfigureNetworkForm } from "./ConfigureNetworkForm";
|
||||
|
||||
function useChainConfigTrack() {
|
||||
@@ -31,17 +30,6 @@ export const ConfigureNetworks: React.FC<ConfigureNetworksProps> = (props) => {
|
||||
const modifyChain = useModifyChain();
|
||||
const editChain = useEditChain();
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const successToast = (message: string) => {
|
||||
toast({
|
||||
title: message,
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (chain: StoredChain) => {
|
||||
modifyChain(chain);
|
||||
addRecentlyUsedChainId(chain.chainId);
|
||||
@@ -52,44 +40,29 @@ export const ConfigureNetworks: React.FC<ConfigureNetworksProps> = (props) => {
|
||||
}
|
||||
|
||||
trackChainConfig("add", chain);
|
||||
successToast("Network Added Successfully");
|
||||
toast.success("Network Added Successfully");
|
||||
} else {
|
||||
if (props.onNetworkConfigured) {
|
||||
props.onNetworkConfigured(chain);
|
||||
}
|
||||
|
||||
trackChainConfig("update", chain);
|
||||
successToast("Network Updated Successfully");
|
||||
toast.success("Network Updated Successfully");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={{ base: 4, md: 8 }}
|
||||
minH="600px"
|
||||
borderTop={{ md: "none", base: "1px solid" }}
|
||||
marginTop={{ base: 6, md: 0 }}
|
||||
borderColor="inputBg"
|
||||
>
|
||||
<Heading
|
||||
as={"h3"}
|
||||
size="label.xl"
|
||||
mb={8}
|
||||
display="flex"
|
||||
gap={2}
|
||||
alignItems="center"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h3 className="text-2xl font-semibold tracking-tight mb-5">
|
||||
{editChain ? "Edit Network" : "Add Custom Network"}
|
||||
</Heading>
|
||||
</h3>
|
||||
|
||||
{/* Modify the given chain */}
|
||||
{editChain && (
|
||||
<Box mt={9}>
|
||||
<ConfigureNetworkForm
|
||||
editingChain={editChain}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Box>
|
||||
<ConfigureNetworkForm
|
||||
editingChain={editChain}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom chain */}
|
||||
@@ -100,6 +73,6 @@ export const ConfigureNetworks: React.FC<ConfigureNetworksProps> = (props) => {
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FormControl, Input } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormLabel } from "tw-components";
|
||||
import type { NetworkConfigFormData } from "../ConfigureNetworkForm";
|
||||
|
||||
export const ChainIdInput: React.FC<{
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
form: UseFormReturn<NetworkConfigFormData, any>;
|
||||
form: UseFormReturn<NetworkConfigFormData>;
|
||||
disabled: boolean;
|
||||
}> = ({ form, disabled }) => {
|
||||
return (
|
||||
@@ -18,9 +18,6 @@ export const ChainIdInput: React.FC<{
|
||||
disabled={disabled}
|
||||
placeholder="e.g. 152"
|
||||
autoComplete="off"
|
||||
_placeholder={{
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// prevent typing e, +, -
|
||||
if (e.key === "e" || e.key === "+" || e.key === "-") {
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardStorageUpload } from "@3rdweb-sdk/react/hooks/useDashboardStorageUpload";
|
||||
import { Icon } from "@chakra-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { PINNED_FILES_QUERY_KEY_ROOT } from "components/storage/your-files";
|
||||
import { useErrorHandler } from "contexts/error-handler";
|
||||
import { FiUpload } from "react-icons/fi";
|
||||
import { Button } from "tw-components";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const IconUpload: React.FC<{ onUpload: (url: string) => void }> = ({
|
||||
onUpload,
|
||||
}) => {
|
||||
const errorHandler = useErrorHandler();
|
||||
const storageUpload = useDashboardStorageUpload();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleIconUpload = (file: File) => {
|
||||
// if file size is larger than 5000kB, show error
|
||||
if (file.size > 5000 * 1024) {
|
||||
errorHandler.onError("Icon image can not be larger than 5MB");
|
||||
toast.error("Icon image can not be larger than 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,21 +27,20 @@ export const IconUpload: React.FC<{ onUpload: (url: string) => void }> = ({
|
||||
queryClient.invalidateQueries([PINNED_FILES_QUERY_KEY_ROOT]);
|
||||
},
|
||||
onError(error) {
|
||||
errorHandler.onError(error, "Failed to upload file");
|
||||
console.error(error);
|
||||
toast.error("Failed to upload file");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileInput setValue={handleIconUpload} accept={{ "image/*": [] }}>
|
||||
<Button
|
||||
bg="inputBg"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
aria-label="Upload to IPFS"
|
||||
rightIcon={<Icon as={FiUpload} />}
|
||||
isLoading={storageUpload.isLoading}
|
||||
>
|
||||
<Button variant="ghost" className="gap-2 px-2" size="sm">
|
||||
{storageUpload.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<FiUpload className="size-4" />
|
||||
)}
|
||||
Upload Icon
|
||||
</Button>
|
||||
</FileInput>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { FormControl, Input } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
CodeBlock,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Text,
|
||||
} from "tw-components";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import type { NetworkConfigFormData } from "../ConfigureNetworkForm";
|
||||
import { TooltipBox } from "./TooltipBox";
|
||||
|
||||
export const NetworkIDInput: React.FC<{
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
form: UseFormReturn<NetworkConfigFormData, any>;
|
||||
form: UseFormReturn<NetworkConfigFormData>;
|
||||
disabled?: boolean;
|
||||
}> = ({ form, disabled }) => {
|
||||
const slug = form.watch("slug");
|
||||
@@ -25,23 +19,16 @@ export const NetworkIDInput: React.FC<{
|
||||
mt={6}
|
||||
isInvalid={form.formState.errors.slug?.type === "taken"}
|
||||
>
|
||||
<FormLabel display="flex">
|
||||
<FormLabel className="!flex items-center gap-1">
|
||||
Network ID
|
||||
<TooltipBox
|
||||
content={
|
||||
<>
|
||||
<Text color="heading" mb={4}>
|
||||
Network ID is used to identify the network in the URL{" "}
|
||||
</Text>
|
||||
<Heading fontSize="14px" mb={3}>
|
||||
Example
|
||||
</Heading>
|
||||
<CodeBlock
|
||||
fontSize="14px"
|
||||
code="thirdweb.com/<network-id>/<contract-address>"
|
||||
language="bash"
|
||||
canCopy={false}
|
||||
/>
|
||||
Network ID is used to identify the network in the URL
|
||||
<p className="font-semibold mt-2 mb-1">Example</p>
|
||||
<p className="text-link-foreground text-xs">
|
||||
{"thirdweb.com/<network-id>/<contract-address>"}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -50,9 +37,6 @@ export const NetworkIDInput: React.FC<{
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
placeholder="e.g. ethereum"
|
||||
_placeholder={{
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// only allow alphanumeric characters and dashes
|
||||
if (!/^[a-z0-9-]*$/i.test(e.key)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Alert, AlertIcon, FormControl, Input } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Alert, AlertIcon, FormControl } from "@chakra-ui/react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { IoWarning } from "react-icons/io5";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
@@ -28,15 +29,7 @@ export const RpcInput: React.FC<{
|
||||
isDisabled={form.watch("status") !== "active"}
|
||||
>
|
||||
<FormLabel>RPC URL</FormLabel>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="https://"
|
||||
type="url"
|
||||
_placeholder={{
|
||||
fontWeight: 500,
|
||||
}}
|
||||
{...reg}
|
||||
/>
|
||||
<Input autoComplete="off" placeholder="https://" type="url" {...reg} />
|
||||
|
||||
<FormErrorMessage fontSize="12px">Invalid RPC URL</FormErrorMessage>
|
||||
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
import { Flex, Icon, Tooltip } from "@chakra-ui/react";
|
||||
import { BsQuestionCircle } from "react-icons/bs";
|
||||
import { Card } from "tw-components";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
|
||||
export const TooltipBox: React.FC<{
|
||||
content: React.ReactNode;
|
||||
iconColor?: string;
|
||||
}> = ({ content, iconColor = "accent.600" }) => {
|
||||
}> = ({ content }) => {
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top-start"
|
||||
borderRadius="md"
|
||||
bg="transparent"
|
||||
boxShadow="none"
|
||||
p={4}
|
||||
minW={{ md: "450px" }}
|
||||
label={
|
||||
<Card py={2} px={4} bgColor="backgroundCardHighlight">
|
||||
{content}
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Flex alignItems="center" justifyContent="center">
|
||||
<Icon
|
||||
ml={2}
|
||||
mr={1}
|
||||
as={BsQuestionCircle}
|
||||
color={iconColor}
|
||||
boxSize={4}
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<ToolTipLabel label={<div>{content}</div>}>
|
||||
<CircleHelpIcon className="size-4 text-muted-foreground" />
|
||||
</ToolTipLabel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function Fieldset(props: {
|
||||
legend: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-2xl mb-3 font-semibold tracking-tight">
|
||||
{props.legend}
|
||||
</legend>
|
||||
|
||||
<div className="border rounded-lg border-border p-4 md:p-6">
|
||||
{props.children}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { FileInput } from "components/shared/FileInput";
|
||||
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import type { useContractPublishMetadataFromURI } from "../hooks";
|
||||
import { Fieldset } from "./common";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
|
||||
interface ContractMetadataFieldsetProps {
|
||||
@@ -15,12 +16,8 @@ export const ContractMetadataFieldset: React.FC<
|
||||
ContractMetadataFieldsetProps
|
||||
> = ({ form, metadata }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-2xl font-semibold tracking-tight mb-3">
|
||||
Contract Metadata
|
||||
</legend>
|
||||
|
||||
<div className="border border-border rounded-lg p-4 md:p-6 flex flex-col md:grid gap-6 md:grid-cols-[270px_1fr]">
|
||||
<Fieldset legend="Contract Metadata">
|
||||
<div className="flex flex-col md:grid gap-6 md:grid-cols-[270px_1fr]">
|
||||
<div>
|
||||
<FormControl
|
||||
isDisabled={!metadata.isSuccess}
|
||||
@@ -119,6 +116,6 @@ export const ContractMetadataFieldset: React.FC<
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
@@ -20,6 +21,8 @@ import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useSupportedChain } from "hooks/chains/configureChains";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { replaceTemplateValues } from "lib/deployment/template-values";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import { FormProvider, type UseFormReturn, useForm } from "react-hook-form";
|
||||
@@ -39,13 +42,18 @@ import {
|
||||
useFunctionParamsFromABI,
|
||||
useTransactionsForDeploy,
|
||||
} from "../hooks";
|
||||
import { Fieldset } from "./common";
|
||||
import { ContractMetadataFieldset } from "./contract-metadata-fieldset";
|
||||
import {
|
||||
ModularContractDefaultExtensionsFieldset,
|
||||
DeployStatusModal,
|
||||
useDeployStatusModal,
|
||||
} from "./deploy-context-modal";
|
||||
import {
|
||||
ModularContractDefaultModulesFieldset,
|
||||
showPrimarySaleFiedset,
|
||||
showRoyaltyFieldset,
|
||||
useModularContractsDefaultExtensionsInstallParams,
|
||||
} from "./modular-contract-default-extensions-fieldset";
|
||||
useModularContractsDefaultModulesInstallParams,
|
||||
} from "./modular-contract-default-modules-fieldset";
|
||||
import { Param } from "./param";
|
||||
import { PlatformFeeFieldset } from "./platform-fee-fieldset";
|
||||
import { PrimarySaleFieldset } from "./primary-sale-fieldset";
|
||||
@@ -69,7 +77,7 @@ export type CustomContractDeploymentFormData = {
|
||||
saltForCreate2: string;
|
||||
signerAsSalt: boolean;
|
||||
deployParams: Record<string, string>;
|
||||
modularContractDefaultExtensionsInstallParams: Record<string, string>[];
|
||||
modularContractDefaultModulesInstallParams: Record<string, string>[];
|
||||
contractMetadata?: {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -82,6 +90,21 @@ export type CustomContractDeploymentFormData = {
|
||||
export type CustomContractDeploymentForm =
|
||||
UseFormReturn<CustomContractDeploymentFormData>;
|
||||
|
||||
const rewardParamsSet = new Set([
|
||||
"_rewardToken",
|
||||
"_stakingToken",
|
||||
"_timeUnit",
|
||||
"_rewardsPerUnitTime",
|
||||
]);
|
||||
|
||||
const voteParamsSet = new Set([
|
||||
"_token",
|
||||
"_initialVotingDelay",
|
||||
"_initialVotingPeriod",
|
||||
"_initialProposalThreshold",
|
||||
"_initialVoteQuorumFraction",
|
||||
]);
|
||||
|
||||
const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
ipfsHash,
|
||||
version,
|
||||
@@ -137,11 +160,11 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
fullPublishMetadata.data?.deployType === "customFactory";
|
||||
|
||||
const isModular = fullPublishMetadata.data?.routerType === "modular";
|
||||
const defaultExtensions = fullPublishMetadata.data?.defaultExtensions;
|
||||
const defaultModules = fullPublishMetadata.data?.defaultModules;
|
||||
|
||||
const modularContractDefaultExtensionsInstallParams =
|
||||
useModularContractsDefaultExtensionsInstallParams({
|
||||
defaultExtensions,
|
||||
const modularContractDefaultModulesInstallParams =
|
||||
useModularContractsDefaultModulesInstallParams({
|
||||
defaultModules,
|
||||
isQueryEnabled: isModular,
|
||||
});
|
||||
|
||||
@@ -196,11 +219,11 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
signerAsSalt: true,
|
||||
deployParams: parsedDeployParams,
|
||||
recipients: [{ address: connectedWallet || "", sharesBps: 10000 }],
|
||||
// set default values for modular contract extensions with custom components
|
||||
modularContractDefaultExtensionsInstallParams:
|
||||
// set default values for modular contract modules with custom components
|
||||
modularContractDefaultModulesInstallParams:
|
||||
(activeAccount &&
|
||||
isTWPublisher &&
|
||||
modularContractDefaultExtensionsInstallParams.data?.map((ext) => {
|
||||
modularContractDefaultModulesInstallParams.data?.map((ext) => {
|
||||
const paramNames = ext.params.map((param) => param.name);
|
||||
const returnVal: Record<string, string> = {};
|
||||
|
||||
@@ -222,7 +245,7 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
parsedDeployParams,
|
||||
isAccountFactory,
|
||||
connectedWallet,
|
||||
modularContractDefaultExtensionsInstallParams.data,
|
||||
modularContractDefaultModulesInstallParams.data,
|
||||
isTWPublisher,
|
||||
activeAccount,
|
||||
],
|
||||
@@ -243,6 +266,7 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
"_royaltyBps" in formDeployParams &&
|
||||
"_royaltyRecipient" in formDeployParams;
|
||||
const hasPrimarySale = "_saleRecipient" in formDeployParams;
|
||||
const hasPrimarySaleRecipient = "_primarySaleRecipient" in formDeployParams;
|
||||
const hasPlatformFee =
|
||||
"_platformFeeBps" in formDeployParams &&
|
||||
"_platformFeeRecipient" in formDeployParams;
|
||||
@@ -256,6 +280,17 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
"_token" in formDeployParams;
|
||||
const hasTrustedForwarders = "_trustedForwarders" in formDeployParams;
|
||||
|
||||
const rewardDeployParams = Object.keys(formDeployParams).filter((paramKey) =>
|
||||
rewardParamsSet.has(paramKey),
|
||||
);
|
||||
|
||||
const voteDeployParams = Object.keys(formDeployParams).filter((paramKey) =>
|
||||
voteParamsSet.has(paramKey),
|
||||
);
|
||||
|
||||
const showRewardsSection = rewardDeployParams.length === rewardParamsSet.size;
|
||||
const showVoteSection = voteDeployParams.length === voteParamsSet.size;
|
||||
|
||||
const shouldHide = (paramKey: string) => {
|
||||
if (isAccountFactory) {
|
||||
return false;
|
||||
@@ -268,6 +303,9 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
(hasRoyalty &&
|
||||
(paramKey === "_royaltyBps" || paramKey === "_royaltyRecipient")) ||
|
||||
(hasPrimarySale && paramKey === "_saleRecipient") ||
|
||||
(hasPrimarySaleRecipient && paramKey === "_primarySaleRecipient") ||
|
||||
(showRewardsSection && rewardParamsSet.has(paramKey)) ||
|
||||
(showVoteSection && voteParamsSet.has(paramKey)) ||
|
||||
(hasPlatformFee &&
|
||||
(paramKey === "_platformFeeBps" ||
|
||||
paramKey === "_platformFeeRecipient")) ||
|
||||
@@ -286,18 +324,18 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
(extension) => extension.name === "ERC721SharedMetadata",
|
||||
);
|
||||
|
||||
const deploy = useCustomContractDeployMutation(
|
||||
const deployStatusModal = useDeployStatusModal();
|
||||
const deploy = useCustomContractDeployMutation({
|
||||
ipfsHash,
|
||||
version,
|
||||
isImplementationDeploy,
|
||||
{
|
||||
hasContractURI,
|
||||
hasRoyalty,
|
||||
isSplit,
|
||||
isVote,
|
||||
isErc721SharedMetadadata,
|
||||
},
|
||||
);
|
||||
forceDirectDeploy: isImplementationDeploy,
|
||||
hasContractURI,
|
||||
hasRoyalty,
|
||||
isSplit,
|
||||
isVote,
|
||||
isErc721SharedMetadadata,
|
||||
deployStatusModal,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { onSuccess, onError } = useTxNotifications(
|
||||
@@ -375,17 +413,17 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
|
||||
const deployParams = { ...formData.deployParams };
|
||||
|
||||
// if Modular contract has extensions
|
||||
if (isModular && modularContractDefaultExtensionsInstallParams.data) {
|
||||
const extensionInstallData: string[] =
|
||||
modularContractDefaultExtensionsInstallParams.data.map(
|
||||
// if Modular contract has modules
|
||||
if (isModular && modularContractDefaultModulesInstallParams.data) {
|
||||
const moduleInstallData: string[] =
|
||||
modularContractDefaultModulesInstallParams.data.map(
|
||||
(ext, extIndex) => {
|
||||
return encodeAbiParameters(
|
||||
// param name+type []
|
||||
ext.params.map((p) => ({ name: p.name, type: p.type })),
|
||||
// value []
|
||||
Object.values(
|
||||
formData.modularContractDefaultExtensionsInstallParams[
|
||||
formData.modularContractDefaultModulesInstallParams[
|
||||
extIndex
|
||||
] || {},
|
||||
),
|
||||
@@ -393,8 +431,7 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
},
|
||||
);
|
||||
|
||||
deployParams._extensionInstallData =
|
||||
JSON.stringify(extensionInstallData);
|
||||
deployParams._moduleInstallData = JSON.stringify(moduleInstallData);
|
||||
}
|
||||
|
||||
deploy.mutate(
|
||||
@@ -450,7 +487,7 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
networkInfo,
|
||||
`Network not found for chainId ${selectedChain}`,
|
||||
);
|
||||
router.replace(
|
||||
deployStatusModal.setViewContractLink(
|
||||
`/${networkInfo.slug}/${deployedContractAddress}`,
|
||||
);
|
||||
}
|
||||
@@ -479,81 +516,140 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(hasRoyalty || hasPrimarySale || hasPlatformFee) && (
|
||||
<fieldset>
|
||||
<legend className="text-2xl font-semibold tracking-tight mb-3">
|
||||
Fee Distribution
|
||||
</legend>
|
||||
<div className="border rounded-lg border-border p-4 md:p-6 flex flex-col gap-6">
|
||||
{/* Primary Sale */}
|
||||
{hasPrimarySale && (
|
||||
<PrimarySaleFieldset
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
"deployParams._saleRecipient",
|
||||
form.formState,
|
||||
).error
|
||||
}
|
||||
register={form.register("deployParams._saleRecipient")}
|
||||
errorMessage={
|
||||
form.getFieldState(
|
||||
"deployParams._saleRecipient",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Royalty */}
|
||||
{hasRoyalty && (
|
||||
<RoyaltyFieldset
|
||||
royaltyRecipient={{
|
||||
isInvalid: !!form.getFieldState(
|
||||
"deployParams._royaltyRecipient",
|
||||
form.formState,
|
||||
).error,
|
||||
register: form.register(
|
||||
"deployParams._royaltyRecipient",
|
||||
),
|
||||
errorMessage: form.getFieldState(
|
||||
"deployParams._royaltyRecipient",
|
||||
form.formState,
|
||||
).error?.message,
|
||||
}}
|
||||
royaltyBps={{
|
||||
value: form.watch("deployParams._royaltyBps"),
|
||||
isInvalid: !!form.getFieldState(
|
||||
"deployParams._royaltyBps",
|
||||
form.formState,
|
||||
).error,
|
||||
setValue: (v) => {
|
||||
form.setValue("deployParams._royaltyBps", v, {
|
||||
shouldTouch: true,
|
||||
});
|
||||
},
|
||||
errorMessage: form.getFieldState(
|
||||
"deployParams._royaltyBps",
|
||||
form.formState,
|
||||
).error?.message,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPlatformFee && <PlatformFeeFieldset form={form} />}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/* Primary Sale */}
|
||||
{hasPrimarySale && (
|
||||
<PrimarySaleFieldset
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
"deployParams._saleRecipient",
|
||||
form.formState,
|
||||
).error
|
||||
}
|
||||
register={form.register("deployParams._saleRecipient")}
|
||||
errorMessage={
|
||||
form.getFieldState(
|
||||
"deployParams._saleRecipient",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPrimarySaleRecipient && (
|
||||
<PrimarySaleFieldset
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
"deployParams._primarySaleRecipient",
|
||||
form.formState,
|
||||
).error
|
||||
}
|
||||
register={form.register("deployParams._primarySaleRecipient")}
|
||||
errorMessage={
|
||||
form.getFieldState(
|
||||
"deployParams._primarySaleRecipient",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Royalty */}
|
||||
{hasRoyalty && (
|
||||
<RoyaltyFieldset
|
||||
royaltyRecipient={{
|
||||
isInvalid: !!form.getFieldState(
|
||||
"deployParams._royaltyRecipient",
|
||||
form.formState,
|
||||
).error,
|
||||
register: form.register("deployParams._royaltyRecipient"),
|
||||
errorMessage: form.getFieldState(
|
||||
"deployParams._royaltyRecipient",
|
||||
form.formState,
|
||||
).error?.message,
|
||||
}}
|
||||
royaltyBps={{
|
||||
value: form.watch("deployParams._royaltyBps"),
|
||||
isInvalid: !!form.getFieldState(
|
||||
"deployParams._royaltyBps",
|
||||
form.formState,
|
||||
).error,
|
||||
setValue: (v) => {
|
||||
form.setValue("deployParams._royaltyBps", v, {
|
||||
shouldTouch: true,
|
||||
});
|
||||
},
|
||||
errorMessage: form.getFieldState(
|
||||
"deployParams._royaltyBps",
|
||||
form.formState,
|
||||
).error?.message,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPlatformFee && <PlatformFeeFieldset form={form} />}
|
||||
|
||||
{isSplit && <SplitFieldset form={form} />}
|
||||
|
||||
{hasTrustedForwarders && <TrustedForwardersFieldset form={form} />}
|
||||
|
||||
{/* for StakeERC721 */}
|
||||
{showRewardsSection && (
|
||||
<Fieldset legend="Reward Parameters">
|
||||
<div className="flex flex-col gap-6">
|
||||
{rewardDeployParams.map((paramKey) => {
|
||||
const deployParam = deployParams.find(
|
||||
(p) => p.name === paramKey,
|
||||
);
|
||||
|
||||
const contructorParams =
|
||||
fullPublishMetadata.data?.constructorParams || {};
|
||||
const extraMetadataParam = contructorParams[paramKey];
|
||||
|
||||
return (
|
||||
<Param
|
||||
key={paramKey}
|
||||
paramKey={paramKey}
|
||||
deployParam={deployParam}
|
||||
extraMetadataParam={extraMetadataParam}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Fieldset>
|
||||
)}
|
||||
|
||||
{/* for Vote */}
|
||||
{showVoteSection && (
|
||||
<Fieldset legend="Voting Parameters">
|
||||
<div className="flex flex-col gap-6">
|
||||
{voteDeployParams.map((paramKey) => {
|
||||
const deployParam = deployParams.find(
|
||||
(p) => p.name === paramKey,
|
||||
);
|
||||
|
||||
const contructorParams =
|
||||
fullPublishMetadata.data?.constructorParams || {};
|
||||
const extraMetadataParam = contructorParams[paramKey];
|
||||
|
||||
return (
|
||||
<Param
|
||||
key={paramKey}
|
||||
paramKey={paramKey}
|
||||
deployParam={deployParam}
|
||||
extraMetadataParam={extraMetadataParam}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Fieldset>
|
||||
)}
|
||||
|
||||
{Object.keys(formDeployParams)
|
||||
.filter((paramName) => {
|
||||
if (
|
||||
isModular &&
|
||||
(paramName === "_extensionInstallData" ||
|
||||
paramName === "_extensions")
|
||||
(paramName === "_moduleInstallData" ||
|
||||
paramName === "_modules")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -584,12 +680,10 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
|
||||
{isModular && (
|
||||
<>
|
||||
{modularContractDefaultExtensionsInstallParams.data ? (
|
||||
<ModularContractDefaultExtensionsFieldset
|
||||
{modularContractDefaultModulesInstallParams.data ? (
|
||||
<ModularContractDefaultModulesFieldset
|
||||
form={form}
|
||||
extensions={
|
||||
modularContractDefaultExtensionsInstallParams.data
|
||||
}
|
||||
modules={modularContractDefaultModulesInstallParams.data}
|
||||
isTWPublisher={isTWPublisher}
|
||||
/>
|
||||
) : (
|
||||
@@ -626,144 +720,160 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{fullPublishMetadata.data?.deployType === "standard" && (
|
||||
<>
|
||||
{/* Deterministic deploy */}
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
{...form.register("deployDeterministic")}
|
||||
checked={form.watch("deployDeterministic")}
|
||||
onCheckedChange={(c) =>
|
||||
form.setValue("deployDeterministic", !!c)
|
||||
}
|
||||
/>
|
||||
<ToolTipLabel label="Allows having the same contract address on multiple chains. You can control the address by specifying a salt for create2 deployment below">
|
||||
<div className="inline-flex gap-1.5 items-center">
|
||||
<span className="tex-sm">
|
||||
Deploy at a deterministic address
|
||||
</span>
|
||||
<FiHelpCircle className="size-4" />
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
</CheckboxWithLabel>
|
||||
<Fieldset legend="Deploy Options">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Chain */}
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
|
||||
{/* Optional Salt Input */}
|
||||
{isCreate2Deployment && (
|
||||
<FormControl>
|
||||
<Flex alignItems="center" my={1}>
|
||||
<FormLabel mb={0} flex="1" display="flex">
|
||||
<Flex alignItems="baseline" gap={1}>
|
||||
Optional Salt Input
|
||||
<Text size="label.sm">(saltForCreate2)</Text>
|
||||
</Flex>
|
||||
</FormLabel>
|
||||
<FormHelperText mt={0}>string</FormHelperText>
|
||||
</Flex>
|
||||
<SolidityInput
|
||||
defaultValue={""}
|
||||
solidityType={"string"}
|
||||
{...form.register("saltForCreate2")}
|
||||
/>
|
||||
<div className="h-2" />
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
Select a network to deploy this contract on. We recommend
|
||||
starting with a testnet.{" "}
|
||||
<TrackedLinkTW
|
||||
href="/chainlist"
|
||||
category="deploy"
|
||||
label="chainlist"
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
View all chains
|
||||
</TrackedLinkTW>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<NetworkSelectorButton
|
||||
isDisabled={
|
||||
isImplementationDeploy ||
|
||||
deploy.isLoading ||
|
||||
!compilerMetadata.isSuccess
|
||||
}
|
||||
onSwitchChain={(chain) => {
|
||||
onChainSelect(chain.chainId);
|
||||
}}
|
||||
networksEnabled={
|
||||
fullPublishMetadata.data?.name === "AccountFactory" ||
|
||||
fullPublishMetadata.data?.networksForDeployment
|
||||
?.allNetworks ||
|
||||
!fullPublishMetadata.data?.networksForDeployment
|
||||
? undefined
|
||||
: fullPublishMetadata.data?.networksForDeployment
|
||||
?.networksEnabled
|
||||
}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/chainlist" className="gap-2">
|
||||
View all chains <ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DeprecatedAlert chain={networkInfo} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
{fullPublishMetadata.data?.deployType === "standard" && (
|
||||
<>
|
||||
{/* Deterministic deploy */}
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
{...form.register("signerAsSalt")}
|
||||
checked={form.watch("signerAsSalt")}
|
||||
onCheckedChange={(c) => form.setValue("signerAsSalt", !!c)}
|
||||
{...form.register("deployDeterministic")}
|
||||
checked={form.watch("deployDeterministic")}
|
||||
onCheckedChange={(c) =>
|
||||
form.setValue("deployDeterministic", !!c)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
Include deployer wallet address in salt (recommended)
|
||||
</span>
|
||||
<ToolTipLabel label="Allows having the same contract address on multiple chains. You can control the address by specifying a salt for create2 deployment below">
|
||||
<div className="inline-flex gap-1.5 items-center">
|
||||
<span className="tex-sm">
|
||||
Deploy at a deterministic address
|
||||
</span>
|
||||
<FiHelpCircle className="size-4" />
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
</CheckboxWithLabel>
|
||||
</FormControl>
|
||||
|
||||
{/* Optional Salt Input */}
|
||||
{isCreate2Deployment && (
|
||||
<FormControl>
|
||||
<Flex alignItems="center" my={1}>
|
||||
<FormLabel mb={0} flex="1" display="flex">
|
||||
<Flex alignItems="baseline" gap={1}>
|
||||
Optional Salt Input
|
||||
<Text size="label.sm">(saltForCreate2)</Text>
|
||||
</Flex>
|
||||
</FormLabel>
|
||||
<FormHelperText mt={0}>string</FormHelperText>
|
||||
</Flex>
|
||||
<SolidityInput
|
||||
defaultValue={""}
|
||||
solidityType={"string"}
|
||||
{...form.register("saltForCreate2")}
|
||||
/>
|
||||
<div className="h-2" />
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
{...form.register("signerAsSalt")}
|
||||
checked={form.watch("signerAsSalt")}
|
||||
onCheckedChange={(c) =>
|
||||
form.setValue("signerAsSalt", !!c)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
Include deployer wallet address in salt (recommended)
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</FormControl>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chain */}
|
||||
<fieldset>
|
||||
<legend className="text-2xl font-semibold tracking-tight mb-2">
|
||||
Chain
|
||||
</legend>
|
||||
{/* Import Enable/Disable */}
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
{...form.register("addToDashboard")}
|
||||
checked={form.watch("addToDashboard")}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("addToDashboard", !!checked)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
Import so I can find it in the list of my contracts at{" "}
|
||||
<TrackedLinkTW
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/dashboard"
|
||||
target="_blank"
|
||||
category="custom-contract"
|
||||
label="visit-dashboard"
|
||||
>
|
||||
/dashboard
|
||||
</TrackedLinkTW>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
Select a network to deploy this contract on. We recommend starting
|
||||
with a testnet.{" "}
|
||||
<TrackedLinkTW
|
||||
href="/chainlist"
|
||||
category="deploy"
|
||||
label="chainlist"
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
View all chains
|
||||
</TrackedLinkTW>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<NetworkSelectorButton
|
||||
isDisabled={
|
||||
isImplementationDeploy ||
|
||||
deploy.isLoading ||
|
||||
!compilerMetadata.isSuccess
|
||||
}
|
||||
onSwitchChain={(chain) => {
|
||||
onChainSelect(chain.chainId);
|
||||
}}
|
||||
networksEnabled={
|
||||
fullPublishMetadata.data?.name === "AccountFactory" ||
|
||||
fullPublishMetadata.data?.networksForDeployment?.allNetworks ||
|
||||
!fullPublishMetadata.data?.networksForDeployment
|
||||
? undefined
|
||||
: fullPublishMetadata.data?.networksForDeployment
|
||||
?.networksEnabled
|
||||
}
|
||||
/>
|
||||
|
||||
<DeprecatedAlert chain={networkInfo} />
|
||||
{/* Depoy */}
|
||||
<div className="flex md:justify-end">
|
||||
<TransactionButton
|
||||
onChainSelect={onChainSelect}
|
||||
upsellTestnet
|
||||
flexShrink={0}
|
||||
type="submit"
|
||||
form="custom-contract-form"
|
||||
isLoading={deploy.isLoading}
|
||||
isDisabled={!compilerMetadata.isSuccess || !selectedChain}
|
||||
colorScheme="blue"
|
||||
transactionCount={transactionCount}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Deploy Now
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Import Enable/Disable */}
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
{...form.register("addToDashboard")}
|
||||
checked={form.watch("addToDashboard")}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("addToDashboard", !!checked)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
Import so I can find it in the list of my contracts at{" "}
|
||||
<TrackedLinkTW
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/dashboard"
|
||||
target="_blank"
|
||||
category="custom-contract"
|
||||
label="visit-dashboard"
|
||||
>
|
||||
/dashboard
|
||||
</TrackedLinkTW>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
|
||||
{/* Depoy */}
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
onChainSelect={onChainSelect}
|
||||
upsellTestnet
|
||||
flexShrink={0}
|
||||
type="submit"
|
||||
form="custom-contract-form"
|
||||
isLoading={deploy.isLoading}
|
||||
isDisabled={!compilerMetadata.isSuccess || !selectedChain}
|
||||
colorScheme="blue"
|
||||
transactionCount={transactionCount}
|
||||
>
|
||||
Deploy Now
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</Flex>
|
||||
|
||||
<DeployStatusModal deployStatusModal={deployStatusModal} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,192 +1,204 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Center,
|
||||
Flex,
|
||||
Icon,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
Spinner,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { FiActivity, FiCheck } from "react-icons/fi";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Card, Heading, Text } from "tw-components";
|
||||
import type { ComponentWithChildren } from "types/component-with-children";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleCheck, CircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface DeployModalStep {
|
||||
title: string;
|
||||
description: string | JSX.Element;
|
||||
action?:
|
||||
| {
|
||||
type: "link";
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
type: "button";
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
export type DeployModalStep = {
|
||||
type: "deploy" | "setNFTMetadata" | "import";
|
||||
signatureCount: number;
|
||||
};
|
||||
|
||||
interface DeployModalContext {
|
||||
nextStep: () => void;
|
||||
export type DeployStatusModal = {
|
||||
open: (steps: DeployModalStep[]) => void;
|
||||
close: () => void;
|
||||
_inProvider: true;
|
||||
}
|
||||
|
||||
const DeployModalContext = createContext<DeployModalContext>(
|
||||
{} as DeployModalContext,
|
||||
);
|
||||
|
||||
export const DeployModalProvider: ComponentWithChildren = ({ children }) => {
|
||||
const modalState = useDisclosure();
|
||||
nextStep: () => void;
|
||||
isModalOpen: boolean;
|
||||
steps: DeployModalStep[];
|
||||
activeStep: number;
|
||||
setIsModalOpen: (isOpen: boolean) => void;
|
||||
viewContractLink: string | undefined;
|
||||
setViewContractLink: (link: string) => void;
|
||||
};
|
||||
|
||||
export function useDeployStatusModal(): DeployStatusModal {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [steps, setSteps] = useState<DeployModalStep[]>([]);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
return (
|
||||
<DeployModalContext.Provider
|
||||
value={{
|
||||
open: (steps_) => {
|
||||
setSteps(steps_);
|
||||
modalState.onOpen();
|
||||
},
|
||||
close: () => {
|
||||
modalState.onClose();
|
||||
setSteps([]);
|
||||
setActiveStep(0);
|
||||
},
|
||||
nextStep: () => {
|
||||
setActiveStep((currStep) => {
|
||||
return currStep + 1;
|
||||
});
|
||||
},
|
||||
const [viewContractLink, setViewContractLink] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
_inProvider: true,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Modal
|
||||
closeOnEsc
|
||||
closeOnOverlayClick
|
||||
// can only be closed by the context
|
||||
onClose={() => undefined}
|
||||
isOpen={modalState.isOpen}
|
||||
isCentered
|
||||
const open = useCallback((steps_: DeployModalStep[]) => {
|
||||
setSteps(steps_);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsModalOpen(false);
|
||||
setSteps([]);
|
||||
setActiveStep(0);
|
||||
}, []);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setActiveStep((currStep) => {
|
||||
return currStep + 1;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
nextStep,
|
||||
isModalOpen,
|
||||
steps,
|
||||
activeStep,
|
||||
setIsModalOpen,
|
||||
viewContractLink,
|
||||
setViewContractLink,
|
||||
};
|
||||
}
|
||||
|
||||
export function DeployStatusModal(props: {
|
||||
deployStatusModal: DeployStatusModal;
|
||||
}) {
|
||||
const { steps, activeStep, isModalOpen, viewContractLink, setIsModalOpen } =
|
||||
props.deployStatusModal;
|
||||
|
||||
// DO not close modal on outside click
|
||||
return (
|
||||
<Dialog open={isModalOpen}>
|
||||
<DialogContent
|
||||
dialogCloseClassName="hidden"
|
||||
className="max-w-[480px] p-0 gap-0"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as={Card} borderRadius="xl">
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Heading size="subtitle.sm">Deploy Status</Heading>
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight">
|
||||
Deploy Status
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{steps.map((step, i) => {
|
||||
const isActive = i === activeStep;
|
||||
const hasCompleted = i < activeStep;
|
||||
return (
|
||||
<DeployModalStep
|
||||
key={step.title}
|
||||
{...step}
|
||||
<RenderDeployModalStep
|
||||
key={step.type}
|
||||
step={step}
|
||||
isActive={isActive}
|
||||
hasCompleted={hasCompleted}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</DeployModalContext.Provider>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
</div>
|
||||
|
||||
export function useDeployContextModal() {
|
||||
const { _inProvider, ...context } = useContext(DeployModalContext);
|
||||
invariant(
|
||||
_inProvider,
|
||||
"useDeployContextModal must be used within DeployModalProvider",
|
||||
{viewContractLink && (
|
||||
<div className="flex justify-between mt-2 border-border border-t p-6 gap-4">
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="primary" asChild>
|
||||
<Link href={viewContractLink}>View Contract</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
interface DeployModalStepProps extends DeployModalStep {
|
||||
type DeployModalStepProps = {
|
||||
isActive: boolean;
|
||||
hasCompleted: boolean;
|
||||
}
|
||||
|
||||
const DeployModalStep: React.FC<DeployModalStepProps> = ({
|
||||
title,
|
||||
description,
|
||||
isActive,
|
||||
hasCompleted,
|
||||
}) => {
|
||||
return (
|
||||
<Flex
|
||||
as={Card}
|
||||
flexDir="row"
|
||||
gap={4}
|
||||
opacity={isActive ? 1 : hasCompleted ? 0.8 : 0.3}
|
||||
align="center"
|
||||
>
|
||||
{isActive ? (
|
||||
<Center boxSize={5} flexShrink={0}>
|
||||
<Spinner size="sm" />
|
||||
</Center>
|
||||
) : hasCompleted ? (
|
||||
<Icon as={FiCheck} color="green.500" boxSize={5} flexShrink={0} />
|
||||
) : (
|
||||
<Icon as={FiActivity} boxSize={5} flexShrink={0} />
|
||||
)}
|
||||
<Flex flexDir="column">
|
||||
<Heading size="label.lg" mb={3}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text size="body.md">{description}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
step: DeployModalStep;
|
||||
};
|
||||
|
||||
export function getStepDeploy(
|
||||
transactionsNumber: number,
|
||||
requiresSignature = true,
|
||||
): DeployModalStep {
|
||||
return {
|
||||
title: "Deploying contract",
|
||||
description: requiresSignature
|
||||
? `Your wallet will prompt you to sign ${
|
||||
transactionsNumber === 1 ? "the" : transactionsNumber || 1
|
||||
} transaction${transactionsNumber > 1 ? "s" : ""}.`
|
||||
: "This may take a few seconds.",
|
||||
};
|
||||
function RenderDeployModalStep(props: DeployModalStepProps) {
|
||||
const { isActive, hasCompleted } = props;
|
||||
const { title, description } = getStepInfo(props.step);
|
||||
return (
|
||||
<div className="rounded-lg border border-border">
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4 p-4 items-center",
|
||||
!isActive && !hasCompleted && "opacity-30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center shrink-0">
|
||||
{isActive ? (
|
||||
<Spinner className="size-6 text-link-foreground" />
|
||||
) : hasCompleted ? (
|
||||
<CircleCheck className="size-6 text-green-500" />
|
||||
) : (
|
||||
<CircleIcon className="size-6 text-secondary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-semibold mb-1 font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function getStepSetNFTMetadata(
|
||||
requiresSignature = true,
|
||||
): DeployModalStep {
|
||||
return {
|
||||
title: "Setting NFT metadata",
|
||||
description: (
|
||||
<>
|
||||
{requiresSignature
|
||||
? "Your wallet will prompt you to sign the transaction. "
|
||||
: "This may take a few seconds."}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
export function getStepAddToRegistry(
|
||||
requiresSignature = true,
|
||||
): DeployModalStep {
|
||||
return {
|
||||
title: "Adding to dashboard",
|
||||
description: (
|
||||
<>
|
||||
{requiresSignature
|
||||
? "Your wallet will prompt you to sign the transaction. "
|
||||
: ""}
|
||||
<i>
|
||||
This step is <b>gasless</b>.
|
||||
</i>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
type TitleAndDesc = {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
};
|
||||
|
||||
function getStepInfo(step: DeployModalStep): TitleAndDesc {
|
||||
switch (step.type) {
|
||||
case "deploy": {
|
||||
return {
|
||||
title: "Deploying contract",
|
||||
description:
|
||||
step.signatureCount > 0
|
||||
? `Your wallet will prompt you to sign ${
|
||||
step.signatureCount === 1 ? "the" : step.signatureCount || 1
|
||||
} transaction${step.signatureCount > 1 ? "s" : ""}.`
|
||||
: "This may take a few seconds",
|
||||
};
|
||||
}
|
||||
|
||||
case "setNFTMetadata": {
|
||||
return {
|
||||
title: "Setting NFT metadata",
|
||||
description: (
|
||||
<>
|
||||
{step.signatureCount > 0
|
||||
? "Your wallet will prompt you to sign the transaction. "
|
||||
: "This may take a few seconds."}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
case "import": {
|
||||
return {
|
||||
title: "Adding to dashboard",
|
||||
description: (
|
||||
<>
|
||||
{step.signatureCount > 0
|
||||
? "Your wallet will prompt you to sign the transaction. "
|
||||
: ""}
|
||||
<i>
|
||||
This step is <b>gasless</b>.
|
||||
</i>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { getExtensionInstalledParams } from "contract-ui/tabs/manage/components/getExtensionInstalledParams";
|
||||
import { getModuleInstalledParams } from "contract-ui/tabs/manage/components/getModuleInstalledParams";
|
||||
import invariant from "tiny-invariant";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
import { PrimarySaleFieldset } from "./primary-sale-fieldset";
|
||||
import { RoyaltyFieldset } from "./royalty-fieldset";
|
||||
|
||||
type ExtensionMeta = {
|
||||
extensionName: string;
|
||||
extensionVersion: string;
|
||||
type ModuleMeta = {
|
||||
moduleName: string;
|
||||
moduleVersion: string;
|
||||
publisherAddress: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the install params for all given extensions
|
||||
* Get the install params for all given modules
|
||||
*/
|
||||
export function useModularContractsDefaultExtensionsInstallParams(props: {
|
||||
defaultExtensions?: ExtensionMeta[];
|
||||
export function useModularContractsDefaultModulesInstallParams(props: {
|
||||
defaultModules?: ModuleMeta[];
|
||||
isQueryEnabled: boolean;
|
||||
}) {
|
||||
const { defaultExtensions, isQueryEnabled } = props;
|
||||
const { defaultModules, isQueryEnabled } = props;
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"useModularContractsDefaultExtensionsInstallParams",
|
||||
defaultExtensions,
|
||||
"useModularContractsDefaultModulesInstallParams",
|
||||
defaultModules,
|
||||
],
|
||||
queryFn: async () => {
|
||||
invariant(defaultExtensions, "defaultExtensions must be defined");
|
||||
return Promise.all(defaultExtensions.map(getExtensionInstalledParams));
|
||||
invariant(defaultModules, "defaultModules must be defined");
|
||||
return Promise.all(defaultModules.map(getModuleInstalledParams));
|
||||
},
|
||||
enabled: !!(isQueryEnabled && defaultExtensions),
|
||||
enabled: !!(isQueryEnabled && defaultModules),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export type UseModularContractsDefaultExtensionsInstallParams = ReturnType<
|
||||
typeof useModularContractsDefaultExtensionsInstallParams
|
||||
export type UseModularContractsDefaultModulesInstallParams = ReturnType<
|
||||
typeof useModularContractsDefaultModulesInstallParams
|
||||
>;
|
||||
|
||||
type Extensions = NonNullable<
|
||||
UseModularContractsDefaultExtensionsInstallParams["data"]
|
||||
type Modules = NonNullable<
|
||||
UseModularContractsDefaultModulesInstallParams["data"]
|
||||
>;
|
||||
|
||||
type ExtensionWithIndex = Extensions[number] & { extensionIndex: number };
|
||||
type ModuleWithIndex = Modules[number] & { moduleIndex: number };
|
||||
|
||||
export function ModularContractDefaultExtensionsFieldset(props: {
|
||||
extensions: Extensions;
|
||||
export function ModularContractDefaultModulesFieldset(props: {
|
||||
modules: Modules;
|
||||
form: CustomContractDeploymentForm;
|
||||
isTWPublisher: boolean;
|
||||
}) {
|
||||
// save the index of the extension before filtering out
|
||||
const extensionsWithIndex: ExtensionWithIndex[] = props.extensions
|
||||
// save the index of the module before filtering out
|
||||
const modulesWithIndex: ModuleWithIndex[] = props.modules
|
||||
.map((v, i) => ({
|
||||
...v,
|
||||
extensionIndex: i,
|
||||
moduleIndex: i,
|
||||
}))
|
||||
.filter((v) => v.params.length > 0);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{extensionsWithIndex.map((ext) => {
|
||||
{modulesWithIndex.map((ext) => {
|
||||
return (
|
||||
<RenderExtension
|
||||
key={ext.extensionName}
|
||||
extension={ext}
|
||||
<RenderModule
|
||||
key={ext.moduleName}
|
||||
module={ext}
|
||||
isTWPublisher={props.isTWPublisher}
|
||||
form={props.form}
|
||||
/>
|
||||
@@ -77,34 +77,26 @@ export function ModularContractDefaultExtensionsFieldset(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function RenderExtension(props: {
|
||||
extension: ExtensionWithIndex;
|
||||
function RenderModule(props: {
|
||||
module: ModuleWithIndex;
|
||||
form: CustomContractDeploymentForm;
|
||||
isTWPublisher: boolean;
|
||||
}) {
|
||||
const { extension, form } = props;
|
||||
const { module, form } = props;
|
||||
|
||||
// only consider mapping if published by thirdweb, else show the generic form
|
||||
if (props.isTWPublisher) {
|
||||
const paramNames = extension.params.map((param) => param.name);
|
||||
const paramNames = module.params.map((param) => param.name);
|
||||
|
||||
if (showRoyaltyFieldset(paramNames)) {
|
||||
return (
|
||||
<RenderRoyaltyFieldset
|
||||
extension={extension}
|
||||
form={form}
|
||||
isTWPublisher
|
||||
/>
|
||||
<RenderRoyaltyFieldset module={module} form={form} isTWPublisher />
|
||||
);
|
||||
}
|
||||
|
||||
if (showPrimarySaleFiedset(paramNames)) {
|
||||
return (
|
||||
<RenderPrimarySaleFieldset
|
||||
extension={extension}
|
||||
form={form}
|
||||
isTWPublisher
|
||||
/>
|
||||
<RenderPrimarySaleFieldset module={module} form={form} isTWPublisher />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -112,12 +104,12 @@ function RenderExtension(props: {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg mb-2 text-secondary-foreground font-medium">
|
||||
{extension.extensionName}
|
||||
{module.moduleName}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{extension.params.map((param) => {
|
||||
{module.params.map((param) => {
|
||||
const formFieldKey =
|
||||
`modularContractDefaultExtensionsInstallParams.${extension.extensionIndex}.${param.name}` as const;
|
||||
`modularContractDefaultModulesInstallParams.${module.moduleIndex}.${param.name}` as const;
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
@@ -149,14 +141,14 @@ function RenderExtension(props: {
|
||||
}
|
||||
|
||||
function RenderPrimarySaleFieldset(prosp: {
|
||||
extension: ExtensionWithIndex;
|
||||
module: ModuleWithIndex;
|
||||
form: CustomContractDeploymentForm;
|
||||
isTWPublisher: boolean;
|
||||
}) {
|
||||
const { extension, form } = prosp;
|
||||
const { module, form } = prosp;
|
||||
|
||||
const primarySaleRecipientPath =
|
||||
`modularContractDefaultExtensionsInstallParams.${extension.extensionIndex}.primarySaleRecipient` as const;
|
||||
`modularContractDefaultModulesInstallParams.${module.moduleIndex}.primarySaleRecipient` as const;
|
||||
|
||||
return (
|
||||
<PrimarySaleFieldset
|
||||
@@ -173,17 +165,17 @@ function RenderPrimarySaleFieldset(prosp: {
|
||||
}
|
||||
|
||||
function RenderRoyaltyFieldset(props: {
|
||||
extension: ExtensionWithIndex;
|
||||
module: ModuleWithIndex;
|
||||
form: CustomContractDeploymentForm;
|
||||
isTWPublisher: boolean;
|
||||
}) {
|
||||
const { extension: ext, form } = props;
|
||||
const { module: ext, form } = props;
|
||||
|
||||
const royaltyRecipientPath =
|
||||
`modularContractDefaultExtensionsInstallParams.${ext.extensionIndex}.royaltyRecipient` as const;
|
||||
`modularContractDefaultModulesInstallParams.${ext.moduleIndex}.royaltyRecipient` as const;
|
||||
|
||||
const royaltyBpsPath =
|
||||
`modularContractDefaultExtensionsInstallParams.${ext.extensionIndex}.royaltyBps` as const;
|
||||
`modularContractDefaultModulesInstallParams.${ext.moduleIndex}.royaltyBps` as const;
|
||||
|
||||
return (
|
||||
<RoyaltyFieldset
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { BasisPointsInput } from "components/inputs/BasisPointsInput";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { Fieldset } from "./common";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
|
||||
interface PlatformFeeFieldsetProps {
|
||||
@@ -12,17 +13,7 @@ export const PlatformFeeFieldset: React.FC<PlatformFeeFieldsetProps> = ({
|
||||
form,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg mb-1 font-semibold">Platform fees</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
For contract with primary sales, get additional fees for all primary
|
||||
sales that happen on this contract. (This is useful if you are deploying
|
||||
this contract for a 3rd party and want to take fees for your service).
|
||||
If this contract is a marketplace, get a percentage of all the secondary
|
||||
sales that happen on your contract.
|
||||
</p>
|
||||
|
||||
<Fieldset legend="Platform fees">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<FormControl
|
||||
isRequired
|
||||
@@ -47,6 +38,13 @@ export const PlatformFeeFieldset: React.FC<PlatformFeeFieldsetProps> = ({
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
For contract with primary sales, get additional fees for all primary
|
||||
sales that happen on this contract. (This is useful if you are
|
||||
deploying this contract for a 3rd party and want to take fees for
|
||||
your service). If this contract is a marketplace, get a percentage
|
||||
of all the secondary sales that happen on your contract.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isRequired
|
||||
@@ -74,6 +72,6 @@ export const PlatformFeeFieldset: React.FC<PlatformFeeFieldsetProps> = ({
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { Fieldset } from "./common";
|
||||
|
||||
interface PrimarySaleFieldsetProps {
|
||||
isInvalid: boolean;
|
||||
@@ -13,14 +14,7 @@ export const PrimarySaleFieldset: React.FC<PrimarySaleFieldsetProps> = (
|
||||
props,
|
||||
) => {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg mb-1 font-semibold">Primary Sales</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
The wallet address that should receive the revenue from initial sales of
|
||||
the assets.
|
||||
</p>
|
||||
|
||||
<Fieldset legend=" Primary Sales">
|
||||
<FormControl isRequired isInvalid={props.isInvalid}>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
@@ -28,8 +22,14 @@ export const PrimarySaleFieldset: React.FC<PrimarySaleFieldsetProps> = (
|
||||
variant="filled"
|
||||
{...props.register}
|
||||
/>
|
||||
|
||||
<FormErrorMessage>{props.errorMessage}</FormErrorMessage>
|
||||
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
The wallet address that should receive the revenue from initial sales
|
||||
of the assets.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@ import { FormControl } from "@chakra-ui/react";
|
||||
import { BasisPointsInput } from "components/inputs/BasisPointsInput";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { Fieldset } from "./common";
|
||||
|
||||
export function RoyaltyFieldset(props: {
|
||||
royaltyRecipient: {
|
||||
@@ -19,13 +20,8 @@ export function RoyaltyFieldset(props: {
|
||||
}) {
|
||||
const bpsNumValue = Number.parseInt(props.royaltyBps.value);
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg mb-1 font-semibold">Royalties</h3>
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
The wallet address that should receive the revenue from royalties earned
|
||||
from secondary sales of the assets.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 md:gap-4 md:flex-row">
|
||||
<Fieldset legend="Royalties">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormControl isRequired isInvalid={props.royaltyRecipient.isInvalid}>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
@@ -36,6 +32,10 @@ export function RoyaltyFieldset(props: {
|
||||
<FormErrorMessage>
|
||||
{props.royaltyRecipient.errorMessage}
|
||||
</FormErrorMessage>
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
The wallet address that should receive the revenue from royalties
|
||||
earned from secondary sales of the assets.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isRequired
|
||||
@@ -52,6 +52,6 @@ export function RoyaltyFieldset(props: {
|
||||
<FormErrorMessage>{props.royaltyBps.errorMessage}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
Box,
|
||||
Code,
|
||||
Flex,
|
||||
FormControl,
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import { IoMdAdd } from "@react-icons/all-files/io/IoMdAdd";
|
||||
import { IoMdRemove } from "@react-icons/all-files/io/IoMdRemove";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { BasisPointsInput } from "components/inputs/BasisPointsInput";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { InfoIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { Button, FormErrorMessage, Heading, Text } from "tw-components";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { Alert, AlertTitle } from "../../../@/components/ui/alert";
|
||||
import { Fieldset } from "./common";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
|
||||
interface SplitFieldsetProps {
|
||||
@@ -38,23 +31,12 @@ export const SplitFieldset: React.FC<SplitFieldsetProps> = ({ form }) => {
|
||||
?.reduce((a: number, b: Recipient) => a + b.sharesBps, 0) || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex px={0} as="section" direction="column" gap={4}>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Heading size="label.lg">Split Settings</Heading>
|
||||
<Text size="body.md" fontStyle="italic">
|
||||
Define the recipients and share percentages for this Split contract.
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap={2}>
|
||||
<Fieldset legend="Split Settings">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<Flex
|
||||
key={field.id}
|
||||
gap={2}
|
||||
direction={{ base: "column", md: "row" }}
|
||||
>
|
||||
<div key={field.id} className="flex gap-3 items-end">
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
@@ -63,6 +45,7 @@ export const SplitFieldset: React.FC<SplitFieldsetProps> = ({ form }) => {
|
||||
).error
|
||||
}
|
||||
>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
@@ -79,8 +62,9 @@ export const SplitFieldset: React.FC<SplitFieldsetProps> = ({ form }) => {
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
w="35%"
|
||||
className="max-w-[160px]"
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
`recipients.${index}.sharesBps`,
|
||||
@@ -88,6 +72,7 @@ export const SplitFieldset: React.FC<SplitFieldsetProps> = ({ form }) => {
|
||||
).error
|
||||
}
|
||||
>
|
||||
<FormLabel> Percentage </FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={form.watch(`recipients.${index}.sharesBps`)}
|
||||
@@ -108,57 +93,45 @@ export const SplitFieldset: React.FC<SplitFieldsetProps> = ({ form }) => {
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
borderRadius="md"
|
||||
isDisabled={
|
||||
fields.length === 1 || form.formState.isSubmitting
|
||||
}
|
||||
colorScheme="red"
|
||||
icon={<IoMdRemove />}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={fields.length === 1 || form.formState.isSubmitting}
|
||||
aria-label="remove row"
|
||||
onClick={() => remove(index)}
|
||||
/>
|
||||
</Flex>
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{/* then render high level controls */}
|
||||
<Flex align="center" gap={2}>
|
||||
<Box w="50%">
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<IoMdAdd />}
|
||||
onClick={() => append({ address: "", sharesBps: 0 })}
|
||||
>
|
||||
Add Recipient
|
||||
</Button>
|
||||
</Box>
|
||||
<Flex w="50%" direction="column">
|
||||
<Heading as="label" textTransform="uppercase" size="label.sm">
|
||||
Total Shares
|
||||
</Heading>
|
||||
<Code p={0} bg="transparent">
|
||||
{Math.round(totalShares / 100)}%
|
||||
</Code>
|
||||
</Flex>
|
||||
<Box flexShrink={0} boxSize={8} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
{totalShares < 10000 ? (
|
||||
<Alert status="warning" borderRadius="lg">
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Total shares need to add up to 100%. Total shares currently add up
|
||||
to {totalShares / 100}%.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : totalShares > 10000 ? (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
<AlertDescription>Total shares cannot go over 100%.</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
{/* div added to avoid full-width button */}
|
||||
<div>
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="outline"
|
||||
onClick={() => append({ address: "", sharesBps: 0 })}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Recipient
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{totalShares !== 10000 && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="h-auto flex items-center gap-2"
|
||||
>
|
||||
<InfoIcon className="size-5 !text-destructive-text shrink-0 !static" />
|
||||
<AlertTitle className="!m-0 !p-0 !text-destructive-text">
|
||||
Total shares need to add up to 100%, Total shares currently add up
|
||||
to {totalShares / 100}%
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { Flex, FormControl, InputGroup } from "@chakra-ui/react";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { FormErrorMessage } from "tw-components";
|
||||
import { useState } from "react";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { DynamicHeight } from "../../../@/components/ui/DynamicHeight";
|
||||
import { Switch } from "../../../@/components/ui/switch";
|
||||
import { useDefaultForwarders } from "../hooks";
|
||||
import { Fieldset } from "./common";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
|
||||
interface TrustedForwardersFieldsetProps {
|
||||
@@ -14,68 +18,86 @@ export const TrustedForwardersFieldset: React.FC<
|
||||
TrustedForwardersFieldsetProps
|
||||
> = ({ form }) => {
|
||||
const defaultForwarders = useDefaultForwarders();
|
||||
const [showFull, setShowFull] = useState(false);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-2xl font-semibold tracking-tight mb-2">
|
||||
Trusted Forwarders
|
||||
</legend>
|
||||
|
||||
<p className="text-muted-foreground text-sm mb-1">
|
||||
Trusted forwarder addresses to enable ERC-2771 transactions (i.e.
|
||||
gasless).
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
You can provide your own forwarder, or click the button below to use
|
||||
default forwarders provided by thirdweb. Leave empty if not needed.
|
||||
</p>
|
||||
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
"deployParams._trustedForwarders",
|
||||
form.formState,
|
||||
).error
|
||||
}
|
||||
>
|
||||
<InputGroup size="md">
|
||||
<Flex flexDir="column" w="full">
|
||||
<SolidityInput
|
||||
value={form.watch("deployParams._trustedForwarders")}
|
||||
solidityType="address[]"
|
||||
{...form.register("deployParams._trustedForwarders")}
|
||||
/>
|
||||
</Flex>
|
||||
</InputGroup>
|
||||
|
||||
<ToolTipLabel label="Click to apply default trusted forwarders">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
// size="sm"
|
||||
onClick={() =>
|
||||
form.setValue(
|
||||
"deployParams._trustedForwarders",
|
||||
JSON.stringify(defaultForwarders.data),
|
||||
)
|
||||
}
|
||||
>
|
||||
Get Default
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
<Fieldset legend="Gasless">
|
||||
<DynamicHeight>
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
"deployParams._trustedForwarders",
|
||||
form.formState,
|
||||
).error?.message
|
||||
).error
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</fieldset>
|
||||
>
|
||||
<div className="flex gap-6 justify-between items-center">
|
||||
{/* left */}
|
||||
<div>
|
||||
<FormLabel>Trusted Forwarders</FormLabel>
|
||||
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm mb-1 block">
|
||||
Trusted forwarder addresses to enable ERC-2771 transactions
|
||||
(i.e. gasless).
|
||||
</span>
|
||||
|
||||
<span className="text-muted-foreground text-sm block">
|
||||
You can provide your own forwarder, or click the button below
|
||||
to use default forwarders provided by thirdweb. Leave empty if
|
||||
not needed.
|
||||
</span>
|
||||
</FormHelperText>
|
||||
</div>
|
||||
{/* Right */}
|
||||
<Switch checked={showFull} onCheckedChange={setShowFull} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
!showFull
|
||||
? "hidden"
|
||||
: "block pt-3 fade-in-0 animate-in duration-400"
|
||||
}
|
||||
>
|
||||
<InputGroup size="md">
|
||||
<Flex flexDir="column" w="full">
|
||||
<SolidityInput
|
||||
value={form.watch("deployParams._trustedForwarders")}
|
||||
solidityType="address[]"
|
||||
{...form.register("deployParams._trustedForwarders")}
|
||||
/>
|
||||
</Flex>
|
||||
</InputGroup>
|
||||
|
||||
<ToolTipLabel label="Click to apply default trusted forwarders">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
form.setValue(
|
||||
"deployParams._trustedForwarders",
|
||||
JSON.stringify(defaultForwarders.data),
|
||||
)
|
||||
}
|
||||
>
|
||||
Get Default
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
"deployParams._trustedForwarders",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</div>
|
||||
</FormControl>
|
||||
</DynamicHeight>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||