Compare commits
29
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 |
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,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>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -333,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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+3
-2
@@ -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") => {
|
||||
|
||||
+3
@@ -51,3 +51,6 @@ export default async function Layout({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// because cookies() is used
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { SortBy, TimeRange } from "../../../../lib/search";
|
||||
|
||||
export type SortingHeaderProps = {
|
||||
@@ -13,7 +14,7 @@ export type SortingHeaderProps = {
|
||||
};
|
||||
|
||||
export function SortingHeader(props: SortingHeaderProps) {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const path = usePathname();
|
||||
const enableSorting = !!props.searchParams;
|
||||
const { timeRange, page, sortBy } = props.searchParams || {};
|
||||
|
||||
+3
-2
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,7 +25,7 @@ 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 {
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
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";
|
||||
@@ -41,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,
|
||||
@@ -51,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";
|
||||
@@ -485,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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -750,6 +750,20 @@ export function useCustomContractDeployMutation(options: {
|
||||
message,
|
||||
);
|
||||
}
|
||||
case "eth_sendTransaction": {
|
||||
const tx = params?.[0];
|
||||
if (!tx) {
|
||||
throw new Error("No transaction provided");
|
||||
}
|
||||
return signer.sendTransaction(tx);
|
||||
}
|
||||
case "personal_sign": {
|
||||
const data = params?.[0];
|
||||
if (!data) {
|
||||
throw new Error("Nothing to sign");
|
||||
}
|
||||
return signer.signMessage(data);
|
||||
}
|
||||
default: {
|
||||
return (signer as providers.JsonRpcSigner)?.provider?.send(
|
||||
method,
|
||||
|
||||
+62
-61
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Box,
|
||||
type BoxProps,
|
||||
@@ -35,8 +34,7 @@ export const MarkdownRenderer: React.FC<
|
||||
{...restProps}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
h1: (props: any) => (
|
||||
h1: (props) => (
|
||||
<Heading
|
||||
as="h2"
|
||||
size="title.lg"
|
||||
@@ -44,11 +42,11 @@ export const MarkdownRenderer: React.FC<
|
||||
borderBottomColor="borderColor"
|
||||
{...commonHeadingProps}
|
||||
mb={4}
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
h2: (props: any) => (
|
||||
|
||||
h2: (props) => (
|
||||
<Heading
|
||||
as="h3"
|
||||
size="title.md"
|
||||
@@ -57,51 +55,51 @@ export const MarkdownRenderer: React.FC<
|
||||
{...commonHeadingProps}
|
||||
mt={8}
|
||||
mb={4}
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
h3: (props: any) => (
|
||||
|
||||
h3: (props) => (
|
||||
<Heading
|
||||
as="h4"
|
||||
size="title.sm"
|
||||
{...commonHeadingProps}
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
mt={4}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
h4: (props: any) => (
|
||||
|
||||
h4: (props) => (
|
||||
<Heading
|
||||
as="h5"
|
||||
size="subtitle.md"
|
||||
{...commonHeadingProps}
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
mt={4}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
h5: (props: any) => (
|
||||
|
||||
h5: (props) => (
|
||||
<Heading
|
||||
as="h6"
|
||||
size="subtitle.sm"
|
||||
{...commonHeadingProps}
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
mt={4}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
h6: (props: any) => (
|
||||
|
||||
h6: (props) => (
|
||||
<Heading
|
||||
as="p"
|
||||
size="label.md"
|
||||
{...commonHeadingProps}
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
mt={4}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
a: (props: any) => (
|
||||
|
||||
a: (props) => (
|
||||
<Link
|
||||
_dark={{
|
||||
color: "blue.400",
|
||||
@@ -116,13 +114,12 @@ export const MarkdownRenderer: React.FC<
|
||||
},
|
||||
}}
|
||||
isExternal
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
mt={4}
|
||||
/>
|
||||
),
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
code: ({ inline, ...props }: any) => {
|
||||
code: ({ ...props }) => {
|
||||
if (props?.className) {
|
||||
const language = props.className.replace("language-", "");
|
||||
return (
|
||||
@@ -130,7 +127,7 @@ export const MarkdownRenderer: React.FC<
|
||||
code={onlyText(props.children).trim()}
|
||||
language={language}
|
||||
mb={4}
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -145,16 +142,21 @@ export const MarkdownRenderer: React.FC<
|
||||
_dark={{ bg: "whiteAlpha.100" }}
|
||||
borderRadius="md"
|
||||
fontFamily="mono"
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
p: (props: any) => (
|
||||
<Text size="body.md" mb={4} {...props} lineHeight={1.5} />
|
||||
|
||||
p: (props) => (
|
||||
<Text
|
||||
size="body.md"
|
||||
mb={4}
|
||||
{...cleanedProps(props)}
|
||||
lineHeight={1.5}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
table: (props: any) => (
|
||||
|
||||
table: (props) => (
|
||||
<Box
|
||||
maxW="100%"
|
||||
overflowX="auto"
|
||||
@@ -164,53 +166,44 @@ export const MarkdownRenderer: React.FC<
|
||||
mb={4}
|
||||
borderTopRadius="lg"
|
||||
>
|
||||
<Table {...props} />
|
||||
<Table {...cleanedProps(props)} />
|
||||
</Box>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
th: ({ children: c, ...props }: any) => (
|
||||
<Th
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
{...(props as unknown as any)}
|
||||
textAlign="left!important"
|
||||
border="none"
|
||||
>
|
||||
<Text as="label" size="label.sm" color="faded">
|
||||
|
||||
th: ({ children: c, ...props }) => (
|
||||
<Th {...cleanedProps(props)} textAlign="left" border="none">
|
||||
<Text as="span" size="label.sm" color="faded">
|
||||
{c}
|
||||
</Text>
|
||||
</Th>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
td: (props: any) => (
|
||||
|
||||
td: (props) => (
|
||||
<Td
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
{...(props as unknown as any)}
|
||||
{...cleanedProps(props)}
|
||||
borderColor="borderColor"
|
||||
textAlign="left!important"
|
||||
textAlign="left"
|
||||
borderBottomWidth={"inherit"}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
thead: (props: any) => <Thead {...props} />,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
tbody: (props: any) => <Tbody {...props} />,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
tr: (props: any) => (
|
||||
thead: (props) => <Thead {...cleanedProps(props)} />,
|
||||
tbody: (props) => <Tbody {...cleanedProps(props)} />,
|
||||
tr: (props) => (
|
||||
<Tr
|
||||
{...props}
|
||||
{...cleanedProps(props)}
|
||||
transition="all 0.1s"
|
||||
borderBottomWidth={1}
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
/>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
ul: ({ ordered, ...props }: any) => <UnorderedList {...props} mb={4} />,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
ol: ({ ordered, ...props }: any) => <OrderedList {...props} mb={4} />,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
li: ({ children: c, ordered, ...props }: any) => (
|
||||
<ListItem {...props}>
|
||||
<Text>{c}</Text>
|
||||
ul: (props) => {
|
||||
return <UnorderedList {...cleanedProps(props)} mb={4} />;
|
||||
},
|
||||
ol: (props) => <OrderedList {...cleanedProps(props)} mb={4} />,
|
||||
li: ({ children: c, ...props }) => (
|
||||
<ListItem {...cleanedProps(props)}>
|
||||
{/* hydration error - can not render p > p, so use span instead - remove extra span when we move to tailwind */}
|
||||
<Text as="span">{c}</Text>
|
||||
</ListItem>
|
||||
),
|
||||
}}
|
||||
@@ -219,3 +212,11 @@ export const MarkdownRenderer: React.FC<
|
||||
</ChakraReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
function cleanedProps<T extends object & { node?: unknown }>(
|
||||
props: T,
|
||||
): Omit<T, "node"> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { node, ...rest } = props;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useRouter } from "next/router";
|
||||
import type { EngineInstance } from "../../@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { PageId } from "../../page-id";
|
||||
import type { ThirdwebNextPage } from "../../utils/types";
|
||||
import { AppLayout } from "../app-layouts/app";
|
||||
import {
|
||||
type ActivePage,
|
||||
EnginePageLayout,
|
||||
PageLoading,
|
||||
} from "./EnginePageLayout";
|
||||
|
||||
export function createEnginePage(
|
||||
activePage: ActivePage,
|
||||
PageContent: React.FC<{ instance: EngineInstance }>,
|
||||
): ThirdwebNextPage {
|
||||
const Page: ThirdwebNextPage = () => {
|
||||
const router = useRouter();
|
||||
const engineId = router.query.engineId;
|
||||
|
||||
if (typeof engineId !== "string") {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EnginePageLayout
|
||||
activePage={activePage}
|
||||
engineId={engineId}
|
||||
content={PageContent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Page.pageId = PageId.EngineManage;
|
||||
|
||||
Page.getLayout = (page, props) => (
|
||||
<AppLayout {...props} hasSidebar={true}>
|
||||
{page}
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
return Page;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ArrowLeftIcon, CircleAlertIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { CopyTextButton } from "../../@/components/ui/CopyTextButton";
|
||||
import { Button } from "../../@/components/ui/button";
|
||||
import { Separator } from "../../@/components/ui/separator";
|
||||
import { useDashboardRouter } from "../../@/lib/DashboardRouter";
|
||||
import {
|
||||
type EngineInstance,
|
||||
useEngineInstances,
|
||||
} from "../../@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { SidebarNav } from "../../core-ui/sidebar/nav";
|
||||
import type { Route } from "../../core-ui/sidebar/types";
|
||||
import { useTrack } from "../../hooks/analytics/useTrack";
|
||||
import { EngineVersionBadge } from "./badges/version";
|
||||
import { useHasEnginePermission } from "./useHasEnginePermission";
|
||||
|
||||
export type ActivePage =
|
||||
| "overview"
|
||||
| "explorer"
|
||||
| "relayers"
|
||||
| "contract-subscriptions"
|
||||
| "admins"
|
||||
| "access-tokens"
|
||||
| "webhooks"
|
||||
| "configuration"
|
||||
| "metrics";
|
||||
|
||||
const NEXT_PUBLIC_DEMO_ENGINE_URL = process.env.NEXT_PUBLIC_DEMO_ENGINE_URL;
|
||||
|
||||
export function EnginePageLayout(props: {
|
||||
engineId: string;
|
||||
activePage: ActivePage;
|
||||
content: React.FC<{ instance: EngineInstance }>;
|
||||
}) {
|
||||
const { data } = useAccount();
|
||||
|
||||
if (props.engineId === "sandbox" && data) {
|
||||
invariant(
|
||||
NEXT_PUBLIC_DEMO_ENGINE_URL,
|
||||
"missing NEXT_PUBLIC_DEMO_ENGINE_URL",
|
||||
);
|
||||
|
||||
const sandboxEngine: EngineInstance = {
|
||||
id: "sandbox",
|
||||
url: NEXT_PUBLIC_DEMO_ENGINE_URL,
|
||||
name: "Demo Engine",
|
||||
status: "active",
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
accountId: data?.id,
|
||||
};
|
||||
|
||||
return (
|
||||
<RenderInstance
|
||||
instance={sandboxEngine}
|
||||
activePage={props.activePage}
|
||||
content={props.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <QueryAndRenderInstance {...props} />;
|
||||
}
|
||||
|
||||
function QueryAndRenderInstance(props: {
|
||||
activePage: ActivePage;
|
||||
engineId: string;
|
||||
content: React.FC<{ instance: EngineInstance }>;
|
||||
}) {
|
||||
const instancesQuery = useEngineInstances();
|
||||
const instance = instancesQuery.data?.find((x) => x.id === props.engineId);
|
||||
|
||||
if (instancesQuery.isLoading) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
if (!instance) {
|
||||
return <EngineErrorPage>Engine Instance Not Found</EngineErrorPage>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EnsurePermissionAndRenderInstance
|
||||
instance={instance}
|
||||
activePage={props.activePage}
|
||||
content={props.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EnsurePermissionAndRenderInstance(props: {
|
||||
activePage: ActivePage;
|
||||
content: React.FC<{ instance: EngineInstance }>;
|
||||
instance: EngineInstance;
|
||||
}) {
|
||||
const permissionQuery = useHasEnginePermission({
|
||||
instanceUrl: props.instance.url,
|
||||
});
|
||||
|
||||
if (permissionQuery.isLoading) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
if (permissionQuery.error instanceof Error) {
|
||||
if (permissionQuery.error.message.includes("Failed to fetch")) {
|
||||
return (
|
||||
<EngineErrorPage>
|
||||
<p>Unable to connect to Engine</p>
|
||||
|
||||
<p>Ensure that your Engine is publicly accessible</p>
|
||||
</EngineErrorPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EngineErrorPage>
|
||||
<p>There was an unexpected error reaching your Engine instance</p>
|
||||
<p>Try again or contact us if this issue persists.</p>
|
||||
</EngineErrorPage>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
permissionQuery.data &&
|
||||
permissionQuery.data.hasPermission === false &&
|
||||
permissionQuery.data.reason === "Unauthorized"
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
You are not an admin for this Engine instance. Contact the owner to add
|
||||
your wallet as an admin
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RenderInstance
|
||||
instance={props.instance}
|
||||
activePage={props.activePage}
|
||||
content={props.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderInstance(props: {
|
||||
instance: EngineInstance;
|
||||
activePage: ActivePage;
|
||||
content: React.FC<{ instance: EngineInstance }>;
|
||||
}) {
|
||||
const { instance, activePage } = props;
|
||||
const router = useDashboardRouter();
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(id: ActivePage) => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "navigate-tab",
|
||||
label: id,
|
||||
url: instance.url,
|
||||
});
|
||||
if (id === "overview") {
|
||||
router.push(`/dashboard/engine/${instance.id}`);
|
||||
} else {
|
||||
router.push(`/dashboard/engine/${instance.id}/${id}`);
|
||||
}
|
||||
},
|
||||
[instance.url, instance.id, trackEvent, router],
|
||||
);
|
||||
|
||||
const links = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}`,
|
||||
title: "Overview",
|
||||
name: "overview",
|
||||
onClick: () => handleClick("overview"),
|
||||
},
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}/explorer`,
|
||||
title: "Explorer",
|
||||
name: "explorer",
|
||||
onClick: () => handleClick("explorer"),
|
||||
},
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}/relayers`,
|
||||
title: "Relayers",
|
||||
name: "relayers",
|
||||
onClick: () => handleClick("relayers"),
|
||||
},
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}/contract-subscriptions`,
|
||||
title: "Contract Subscriptions",
|
||||
name: "contract-subscriptions",
|
||||
onClick: () => handleClick("contract-subscriptions"),
|
||||
},
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}/admins`,
|
||||
title: "Admins",
|
||||
name: "admins",
|
||||
onClick: () => handleClick("admins"),
|
||||
},
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}/access-tokens`,
|
||||
title: "Access Tokens",
|
||||
name: "access-tokens",
|
||||
onClick: () => handleClick("access-tokens"),
|
||||
},
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}/webhooks`,
|
||||
title: "Webhooks",
|
||||
name: "webhooks",
|
||||
onClick: () => handleClick("webhooks"),
|
||||
},
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}/configuration`,
|
||||
title: "Configuration",
|
||||
name: "configuration",
|
||||
onClick: () => handleClick("configuration"),
|
||||
},
|
||||
{
|
||||
path: `/dashboard/engine/${instance.id}/metrics`,
|
||||
title: "Metrics",
|
||||
name: "metrics",
|
||||
onClick: () => handleClick("metrics"),
|
||||
},
|
||||
] satisfies Route[],
|
||||
[handleClick, instance.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarNav links={links} activePage={activePage} title="Engine" />
|
||||
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 -translate-x-2 flex items-center gap-2 text-muted-foreground hover:text-foreground h-auto"
|
||||
>
|
||||
<Link
|
||||
href="/dashboard/engine"
|
||||
aria-label="Go Back"
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" /> Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-4 lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-5xl font-semibold tracking-tighter">
|
||||
{instance.name}
|
||||
</h1>
|
||||
|
||||
<div className="h-1" />
|
||||
|
||||
<div className="flex gap-3 items-center">
|
||||
{!instance.name.startsWith("https://") && (
|
||||
<CopyTextButton
|
||||
copyIconPosition="right"
|
||||
textToShow={instance.url}
|
||||
textToCopy={instance.url}
|
||||
tooltip="Copy Engine URL"
|
||||
variant="ghost"
|
||||
className="px-2 py-1 -translate-x-2 h-auto text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EngineVersionBadge instance={instance} />
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
<Separator />
|
||||
<div className="h-10 " />
|
||||
|
||||
<props.content instance={instance} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineErrorPage(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/engine"
|
||||
className="gap-2 flex items-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
Back
|
||||
</Link>
|
||||
|
||||
<div className="border rounded-lg border-border mt-5">
|
||||
<MessageContainer>
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<CircleAlertIcon className="size-16 text-destructive-text" />
|
||||
<p className="text-secondary-foreground text-center">
|
||||
{props.children}
|
||||
</p>
|
||||
</div>
|
||||
</MessageContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageContainer(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-[300px] lg:h-[400px] flex items-center justify-center">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageLoading() {
|
||||
return (
|
||||
<MessageContainer>
|
||||
<Spinner className="size-10" />
|
||||
</MessageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SidebarNav } from "../../core-ui/sidebar/nav";
|
||||
|
||||
export function EngineSidebar() {
|
||||
return (
|
||||
<SidebarNav
|
||||
title="Engine"
|
||||
links={[
|
||||
{
|
||||
name: "overview",
|
||||
path: "/dashboard/engine",
|
||||
title: "Overview",
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
path: "/dashboard/engine/create",
|
||||
title: "Create",
|
||||
},
|
||||
{
|
||||
name: "import",
|
||||
path: "/dashboard/engine/import",
|
||||
title: "Import",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type EngineInstance,
|
||||
useEngineLatestVersion,
|
||||
useEngineSystemHealth,
|
||||
useEngineUpdateVersion,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { CircleArrowDownIcon, CloudDownloadIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Tooltip,
|
||||
type UseDisclosureReturn,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { FaArrowCircleUp } from "react-icons/fa";
|
||||
import { Button, Text, TrackedLink } from "tw-components";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const EngineVersionBadge = ({
|
||||
instance,
|
||||
@@ -30,7 +28,7 @@ export const EngineVersionBadge = ({
|
||||
}) => {
|
||||
const healthQuery = useEngineSystemHealth(instance.url);
|
||||
const latestVersionQuery = useEngineLatestVersion();
|
||||
const disclosure = useDisclosure();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const current = healthQuery.data?.engineVersion ?? "...";
|
||||
const latest = latestVersionQuery.data ?? "...";
|
||||
@@ -38,112 +36,141 @@ export const EngineVersionBadge = ({
|
||||
|
||||
if (!isStale) {
|
||||
return (
|
||||
<Tag size="sm">
|
||||
<TagLabel fontSize="small">{current}</TagLabel>
|
||||
</Tag>
|
||||
<ToolTipLabel label="Latest Version">
|
||||
<Button variant="outline" asChild className="hover:bg-transparent">
|
||||
<div>{current}</div>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Update to the latest version">
|
||||
<Button variant="unstyled">
|
||||
<Tag
|
||||
onClick={disclosure.onOpen}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
>
|
||||
<TagLeftIcon as={FaArrowCircleUp} boxSize={3} />
|
||||
<TagLabel fontSize="small">{current}</TagLabel>
|
||||
</Tag>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<ToolTipLabel
|
||||
label="New version is available"
|
||||
leftIcon={
|
||||
<CircleArrowDownIcon className="size-4 text-link-foreground" />
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative"
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
{current}
|
||||
|
||||
{disclosure.isOpen && (
|
||||
<UpdateVersionModal
|
||||
disclosure={disclosure}
|
||||
latest={latest ?? ""}
|
||||
instance={instance}
|
||||
/>
|
||||
)}
|
||||
{/* Notification Dot */}
|
||||
<span className="absolute -top-1 -right-1">
|
||||
<PulseDot />
|
||||
</span>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
<UpdateVersionModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
latest={latest ?? ""}
|
||||
instance={instance}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UpdateVersionModal = ({
|
||||
disclosure,
|
||||
latest,
|
||||
instance,
|
||||
}: {
|
||||
disclosure: UseDisclosureReturn;
|
||||
const UpdateVersionModal = (props: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
latest: string;
|
||||
instance: EngineInstance;
|
||||
}) => {
|
||||
const { mutate } = useEngineUpdateVersion();
|
||||
const { onSuccess, onError } = useTxNotifications(
|
||||
"Submitted a request to update your Engine instance. Please allow 1-2 business days for this process.",
|
||||
"Unexpected error updating your Engine instance.",
|
||||
);
|
||||
const { open, onOpenChange, latest, instance } = props;
|
||||
const updateEngine = useEngineUpdateVersion();
|
||||
|
||||
if (!instance.cloudDeployedAt) {
|
||||
// For self-hosted, show a prompt to the Github release page.
|
||||
return (
|
||||
<Modal isOpen={disclosure.isOpen} onClose={disclosure.onClose}>
|
||||
<ModalContent>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalHeader>Update your self-hosted Engine to {latest}</ModalHeader>
|
||||
<ModalBody>
|
||||
<Text>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] max-w-[400px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight pr-4 mb-6">
|
||||
Update your self-hosted Engine to {latest}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
View the changelog in the{" "}
|
||||
<TrackedLink
|
||||
<TrackedLinkTW
|
||||
href="https://github.com/thirdweb-dev/engine/releases"
|
||||
category="engine"
|
||||
label="clicked-engine-releases"
|
||||
isExternal
|
||||
color="blue.500"
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Engine Github repository
|
||||
</TrackedLink>
|
||||
.
|
||||
</Text>
|
||||
</ModalBody>
|
||||
<ModalFooter as={Flex} gap={3}>
|
||||
<Button type="button" onClick={disclosure.onClose} variant="ghost">
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</TrackedLinkTW>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
const onClick = async () => {
|
||||
try {
|
||||
mutate({ engineId: instance.id });
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
const promise = updateEngine.mutateAsync({ engineId: instance.id });
|
||||
toast.promise(promise, {
|
||||
success:
|
||||
"Submitted a request to update your Engine instance. Please allow 1-2 business days for this process.",
|
||||
error: "Unexpected error updating your Engine instance.",
|
||||
});
|
||||
await promise;
|
||||
} finally {
|
||||
onOpenChange(false);
|
||||
}
|
||||
disclosure.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={disclosure.isOpen} onClose={disclosure.onClose}>
|
||||
<ModalContent>
|
||||
<ModalCloseButton />
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] max-w-[400px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Engine to {latest}?</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ModalHeader>Update Engine to {latest}?</ModalHeader>
|
||||
<ModalFooter as={Flex} gap={3}>
|
||||
<Button type="button" onClick={disclosure.onClose} variant="ghost">
|
||||
<DialogFooter className="mt-5">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button type="submit" onClick={onClick} colorScheme="blue">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={onClick}
|
||||
variant="primary"
|
||||
className="gap-2"
|
||||
>
|
||||
{updateEngine.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<CloudDownloadIcon className="size-4" />
|
||||
)}
|
||||
Update
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function PulseDot() {
|
||||
return (
|
||||
<span className="relative flex size-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2 bg-primary" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { EngineTier } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import {
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
SimpleGrid,
|
||||
type UseDisclosureReturn,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChakraNextImage } from "components/Image";
|
||||
import { OnboardingModal } from "components/onboarding/Modal";
|
||||
import { THIRDWEB_API_HOST } from "constants/urls";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useSingleQueryParam } from "hooks/useQueryParam";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Button, Heading, Text } from "tw-components";
|
||||
import { LazyOnboardingBilling } from "../onboarding/LazyOnboardingBilling";
|
||||
import { EngineTierCard, MONTHLY_PRICE_USD } from "./tier-card";
|
||||
|
||||
interface CreateEngineInstanceButtonProps {
|
||||
ctaText: string;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export const CreateEngineInstanceButton = ({
|
||||
ctaText,
|
||||
refetch,
|
||||
}: CreateEngineInstanceButtonProps) => {
|
||||
const showModalOnLoad = useSingleQueryParam("requestCloudHosted");
|
||||
const disclosure = useDisclosure({
|
||||
defaultIsOpen: showModalOnLoad !== undefined,
|
||||
});
|
||||
const paymentDisclosure = useDisclosure();
|
||||
const trackEvent = useTrack();
|
||||
const toast = useToast();
|
||||
const accountQuery = useAccount();
|
||||
const [engineTier, setEngineTier] = useState<EngineTier>("STARTER");
|
||||
|
||||
const addCloudHostedEngine = async (tier?: EngineTier) => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-cloud-hosted",
|
||||
tier: tier ?? engineTier,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/add-cloud-hosted`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tier: tier ?? engineTier,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
toast({
|
||||
status: "warning",
|
||||
description:
|
||||
"There is a pending Engine deployment. Please contact [email protected] if this takes longer than 2 hours.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const error =
|
||||
json.error?.message ??
|
||||
"Unexpected error. Please try again or visit https://thirdweb.com/support.";
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast({
|
||||
status: "success",
|
||||
description: "Thank you! Your Engine deployment will begin shortly.",
|
||||
});
|
||||
|
||||
// Refresh the engine list to show a pending cloud-hosted Engine instance.
|
||||
refetch();
|
||||
} catch (e) {
|
||||
toast({
|
||||
status: "error",
|
||||
description: `${e}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "add-engine-instance",
|
||||
});
|
||||
disclosure.onOpen();
|
||||
}}
|
||||
colorScheme="blue"
|
||||
px={6}
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
|
||||
{disclosure.isOpen && (
|
||||
<CreateCloudHostedEngineModal
|
||||
disclosure={disclosure}
|
||||
onClickAddToPlan={async (tier: EngineTier) => {
|
||||
setEngineTier(tier);
|
||||
disclosure.onClose();
|
||||
|
||||
if (accountQuery.data?.status === AccountStatus.ValidPayment) {
|
||||
await addCloudHostedEngine(tier);
|
||||
} else {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-add-payment-method",
|
||||
});
|
||||
// Switch the Cloud-hosted Engine modal with the Add Payment modal.
|
||||
paymentDisclosure.onOpen();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OnboardingModal
|
||||
isOpen={paymentDisclosure.isOpen}
|
||||
onClose={paymentDisclosure.onClose}
|
||||
>
|
||||
<LazyOnboardingBilling
|
||||
onSave={async () => {
|
||||
await addCloudHostedEngine();
|
||||
paymentDisclosure.onClose();
|
||||
}}
|
||||
onCancel={paymentDisclosure.onClose}
|
||||
/>
|
||||
</OnboardingModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateCloudHostedEngineModal = ({
|
||||
disclosure,
|
||||
onClickAddToPlan,
|
||||
}: {
|
||||
disclosure: UseDisclosureReturn;
|
||||
onClickAddToPlan: (tier: EngineTier) => void;
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
const router = useRouter();
|
||||
const [modalState, setModalState] = useState<
|
||||
"selectTier" | "confirmStandard" | "confirmPremium"
|
||||
>("selectTier");
|
||||
|
||||
if (modalState === "confirmStandard" || modalState === "confirmPremium") {
|
||||
const tier: EngineTier =
|
||||
modalState === "confirmStandard" ? "STARTER" : "PREMIUM";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={disclosure.isOpen}
|
||||
onClose={disclosure.onClose}
|
||||
isCentered
|
||||
size="lg"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent p={4} pt={8}>
|
||||
<ModalCloseButton />
|
||||
<ModalBody as={Flex} flexDir="column" gap={6}>
|
||||
<ChakraNextImage
|
||||
alt="Engine hero image"
|
||||
src={require("../../../public/assets/engine/empty-state-header.png")}
|
||||
w="fit-content"
|
||||
/>
|
||||
<Heading size="title.sm">
|
||||
Are you sure you want to deploy a{" "}
|
||||
{tier === "STARTER" ? "Standard" : "Premium"} Engine?
|
||||
</Heading>
|
||||
<Text>
|
||||
You will be charged ${MONTHLY_PRICE_USD[tier]} per month for the
|
||||
subscription.
|
||||
</Text>
|
||||
</ModalBody>
|
||||
<ModalFooter as={Flex} alignItems="flex-end" gap={3}>
|
||||
<Button onClick={() => setModalState("selectTier")} variant="ghost">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => onClickAddToPlan(tier)} colorScheme="blue">
|
||||
Deploy now
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={disclosure.isOpen}
|
||||
onClose={disclosure.onClose}
|
||||
isCentered
|
||||
size="6xl"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent p={4} pb={8}>
|
||||
<ModalCloseButton />
|
||||
<ModalHeader>
|
||||
<Heading size="title.sm">Choose an Engine deployment</Heading>
|
||||
</ModalHeader>
|
||||
<ModalBody as={Flex} flexDir="column" gap={4}>
|
||||
<Text>
|
||||
Host Engine on thirdweb with no setup or maintenance required.
|
||||
</Text>
|
||||
<SimpleGrid columns={{ base: 1, lg: 3 }} gap={6}>
|
||||
<EngineTierCard
|
||||
tier="STARTER"
|
||||
onClick={() => setModalState("confirmStandard")}
|
||||
/>
|
||||
|
||||
<EngineTierCard
|
||||
tier="PREMIUM"
|
||||
isPrimaryCta
|
||||
onClick={() => setModalState("confirmPremium")}
|
||||
/>
|
||||
|
||||
<EngineTierCard
|
||||
tier="ENTERPRISE"
|
||||
previousTier="Premium Engine"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-cloud-hosted",
|
||||
tier: "ENTERPRISE",
|
||||
});
|
||||
router.push("/contact-us");
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,13 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type EditEngineInstanceInput,
|
||||
type EngineInstance,
|
||||
@@ -7,52 +17,38 @@ import {
|
||||
useEngineRemoveCloudHosted,
|
||||
useEngineRemoveFromDashboard,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Flex,
|
||||
FormControl,
|
||||
HStack,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
type UseDisclosureReturn,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { FormControl, Radio, RadioGroup, Stack } from "@chakra-ui/react";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { TWTable } from "components/shared/TWTable";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useState,
|
||||
} from "react";
|
||||
CircleAlertIcon,
|
||||
SendIcon,
|
||||
Trash2Icon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { BiPencil } from "react-icons/bi";
|
||||
import { FiArrowRight, FiTrash } from "react-icons/fi";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Badge, Button, FormLabel, Heading, Text } from "tw-components";
|
||||
import { FiTrash } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { FormLabel } from "tw-components";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "../../@/components/ui/alert";
|
||||
import { Button } from "../../@/components/ui/button";
|
||||
import { Input } from "../../@/components/ui/input";
|
||||
import { Textarea } from "../../@/components/ui/textarea";
|
||||
|
||||
interface EngineInstancesTableProps {
|
||||
instances: EngineInstance[];
|
||||
isLoading: boolean;
|
||||
isFetched: boolean;
|
||||
refetch: ReturnType<typeof useEngineInstances>["refetch"];
|
||||
setConnectedInstance: Dispatch<SetStateAction<EngineInstance | undefined>>;
|
||||
}
|
||||
|
||||
export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
|
||||
@@ -60,86 +56,15 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
|
||||
isLoading,
|
||||
isFetched,
|
||||
refetch,
|
||||
setConnectedInstance,
|
||||
}) => {
|
||||
const editDisclosure = useDisclosure();
|
||||
const removeDisclosure = useDisclosure();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false);
|
||||
const trackEvent = useTrack();
|
||||
const token = useLoggedInUser().user?.jwt ?? null;
|
||||
const address = useActiveAccount()?.address;
|
||||
const toast = useToast();
|
||||
|
||||
const [instanceToUpdate, setInstanceToUpdate] = useState<
|
||||
EngineInstance | undefined
|
||||
>();
|
||||
|
||||
const { mutate: connectToInstance } = useMutation({
|
||||
mutationFn: async (instance: EngineInstance) => {
|
||||
// Make an authed request.
|
||||
// If it fails to fetch, the server is unreachable.
|
||||
// If it returns a 401, the user is not a valid admin.
|
||||
const res = await fetch(`${instance.url}auth/permissions/get-all`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
return instance;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
throw new Error(`Unexpected status code ${res.status}`);
|
||||
},
|
||||
onSuccess: (instance) => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "connect",
|
||||
label: "success",
|
||||
});
|
||||
setConnectedInstance(instance);
|
||||
},
|
||||
onError: (error, instance) => {
|
||||
const e = error as Error;
|
||||
if (e?.message === "Failed to fetch") {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "connect",
|
||||
label: "unreachable",
|
||||
});
|
||||
toast({
|
||||
status: "error",
|
||||
description: `Unable to connect to ${instance.url}. Ensure that your Engine is publicly accessible.`,
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
} else if (e?.message.indexOf("Unauthorized") > -1) {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "connect",
|
||||
label: "unauthorized",
|
||||
});
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Unauthorized",
|
||||
description: `You are not an admin for this Engine instance. Contact the owner to add your wallet as an admin: ${address}`,
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
status: "error",
|
||||
description:
|
||||
"There was an unexpected error reaching your Engine instance. Try again or contact us if this issue persists.",
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const columnHelper = createColumnHelper<EngineInstance>();
|
||||
const columns = [
|
||||
columnHelper.accessor("id", {
|
||||
@@ -150,83 +75,62 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
|
||||
let badge: ReactNode | undefined;
|
||||
if (status === "requested") {
|
||||
badge = (
|
||||
<Tooltip label="Deployment will begin shortly.">
|
||||
<Badge
|
||||
borderRadius="full"
|
||||
size="label.sm"
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1.5}
|
||||
colorScheme="yellow"
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<ToolTipLabel label="Deployment will begin shortly.">
|
||||
<div>
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Spinner className="size-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
} else if (status === "deploying") {
|
||||
badge = (
|
||||
<Tooltip label="This step may take up to 30 minutes.">
|
||||
<Badge
|
||||
borderRadius="full"
|
||||
size="label.sm"
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1.5}
|
||||
colorScheme="green"
|
||||
>
|
||||
Deploying
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<ToolTipLabel label="This step may take up to 30 minutes.">
|
||||
<div>
|
||||
<Badge variant="default" className="gap-1.5">
|
||||
<Spinner className="size-3" />
|
||||
Deploying
|
||||
</Badge>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
} else if (status === "paymentFailed") {
|
||||
badge = (
|
||||
<Tooltip label="There was an error charging your payment method. Please contact [email protected].">
|
||||
<Badge
|
||||
borderRadius="full"
|
||||
size="label.sm"
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1.5}
|
||||
colorScheme="red"
|
||||
>
|
||||
Payment Failed
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<ToolTipLabel
|
||||
label={
|
||||
"There was an error charging your payment method. Please contact [email protected]."
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Badge variant="destructive" className="gap-1.5">
|
||||
<CircleAlertIcon className="size-3" />
|
||||
Payment Failed
|
||||
</Badge>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack py={2}>
|
||||
<div className="py-3">
|
||||
{badge ? (
|
||||
<HStack spacing={4}>
|
||||
<Text fontWeight="600" size="body.lg">
|
||||
{name}
|
||||
</Text>
|
||||
<div className="flex items-center gap-4 py-2">
|
||||
<p className="font-semibold text-lg"> {name} </p>
|
||||
{badge}
|
||||
</HStack>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const instance = instances.find((i) => i.id === id);
|
||||
if (instance) {
|
||||
connectToInstance(instance);
|
||||
}
|
||||
}}
|
||||
variant="link"
|
||||
colorScheme="blue"
|
||||
w="fit-content"
|
||||
rightIcon={<FiArrowRight />}
|
||||
justifyContent="flex-start"
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Link
|
||||
href={`/dashboard/engine/${id}`}
|
||||
className="text-foreground flex text-lg font-semibold items-center before:absolute before:inset-0 before:bg-transparent"
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
<Text size="body.sm" color="gray.700">
|
||||
{url}
|
||||
</Text>
|
||||
</>
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-sm">{url}</p>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
@@ -251,7 +155,7 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
|
||||
label: "open-modal",
|
||||
});
|
||||
setInstanceToUpdate(instance);
|
||||
editDisclosure.onOpen();
|
||||
setIsEditModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -264,24 +168,28 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
|
||||
label: "open-modal",
|
||||
});
|
||||
setInstanceToUpdate(instance);
|
||||
removeDisclosure.onOpen();
|
||||
setIsRemoveModalOpen(true);
|
||||
},
|
||||
isDestructive: true,
|
||||
},
|
||||
]}
|
||||
bodyRowClassName="hover:bg-secondary relative"
|
||||
/>
|
||||
|
||||
{editDisclosure.isOpen && instanceToUpdate && (
|
||||
{instanceToUpdate && (
|
||||
<EditModal
|
||||
instance={instanceToUpdate}
|
||||
disclosure={editDisclosure}
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={setIsEditModalOpen}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
{removeDisclosure.isOpen && instanceToUpdate && (
|
||||
|
||||
{instanceToUpdate && (
|
||||
<RemoveModal
|
||||
instance={instanceToUpdate}
|
||||
disclosure={removeDisclosure}
|
||||
onOpenChange={setIsRemoveModalOpen}
|
||||
open={isRemoveModalOpen}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
@@ -289,18 +197,14 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const EditModal = ({
|
||||
instance,
|
||||
disclosure,
|
||||
refetch,
|
||||
}: {
|
||||
disclosure: UseDisclosureReturn;
|
||||
const EditModal = (props: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
instance: EngineInstance;
|
||||
refetch: () => void;
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
const { mutate: editInstance } = useEngineEditInstance();
|
||||
const { onClose } = disclosure;
|
||||
const editInstance = useEngineEditInstance();
|
||||
const { onOpenChange, instance, open, refetch } = props;
|
||||
|
||||
const form = useForm<EditEngineInstanceInput>({
|
||||
defaultValues: {
|
||||
@@ -308,242 +212,298 @@ const EditModal = ({
|
||||
name: instance.name,
|
||||
url: instance.url,
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} isCentered size="lg">
|
||||
<ModalOverlay />
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
<DialogHeader className="mb-3">
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight">
|
||||
Edit Engine Instance
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ModalContent>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
editInstance(data, {
|
||||
editInstance.mutate(data, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
status: "success",
|
||||
description: "Successfully updated this Engine.",
|
||||
});
|
||||
toast.success("Engine updated successfully");
|
||||
refetch();
|
||||
onClose();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
status: "error",
|
||||
description: "Error updating this Engine.",
|
||||
});
|
||||
toast.error("Failed to update Engine");
|
||||
},
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<ModalHeader>Edit Engine Instance</ModalHeader>
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter a descriptive label"
|
||||
autoFocus
|
||||
{...form.register("name", {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="Enter your Engine URL"
|
||||
{...form.register("url", {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<ModalBody>
|
||||
<Stack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter a descriptive label"
|
||||
autoFocus
|
||||
{...form.register("name")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="Enter your Engine URL"
|
||||
{...form.register("url")}
|
||||
/>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter as={Flex} gap={3}>
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
<DialogFooter className="mt-10 gap-2">
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" colorScheme="blue">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="gap-2"
|
||||
disabled={!form.formState.isDirty}
|
||||
>
|
||||
{editInstance.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<SendIcon className="size-4" />
|
||||
)}
|
||||
Update
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const RemoveModal = ({
|
||||
instance,
|
||||
disclosure,
|
||||
refetch,
|
||||
}: {
|
||||
const RemoveModal = (props: {
|
||||
instance: EngineInstance;
|
||||
disclosure: UseDisclosureReturn;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
refetch: () => void;
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
const { mutate: removeFromDashboard } = useEngineRemoveFromDashboard();
|
||||
const { mutate: removeCloudHosted } = useEngineRemoveCloudHosted();
|
||||
const { onClose } = disclosure;
|
||||
const { instance, open, onOpenChange, refetch } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
{instance.status === "paymentFailed" ||
|
||||
(instance.status === "active" && !instance.cloudDeployedAt) ? (
|
||||
<RemoveFromDashboardModalContent
|
||||
refetch={refetch}
|
||||
instance={instance}
|
||||
close={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<CancelSubscriptionModalContent
|
||||
refetch={refetch}
|
||||
instance={instance}
|
||||
close={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function RemoveFromDashboardModalContent(props: {
|
||||
refetch: () => void;
|
||||
instance: EngineInstance;
|
||||
close: () => void;
|
||||
}) {
|
||||
const { refetch, instance, close } = props;
|
||||
const removeFromDashboard = useEngineRemoveFromDashboard();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight mb-3">
|
||||
Remove Engine Instance
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
<span className="block mb-2">
|
||||
Are you sure you want to remove{" "}
|
||||
<em className="not-italic font-semibold">"{instance.name}"</em> from
|
||||
your dashboard?
|
||||
</span>
|
||||
<span className="block">
|
||||
This action does not modify your Engine infrastructure. You can
|
||||
re-add it at any time.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-8 gap-1">
|
||||
<Button onClick={close} variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
removeFromDashboard.mutate(instance.id, {
|
||||
onSuccess: () => {
|
||||
toast.success("Removed an Engine instance from your dashboard");
|
||||
refetch();
|
||||
close();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
"Error removing an Engine instance from your dashboard",
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
>
|
||||
{removeFromDashboard.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Trash2Icon className="size-4" />
|
||||
)}
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelSubscriptionModalContent(props: {
|
||||
refetch: () => void;
|
||||
instance: EngineInstance;
|
||||
close: () => void;
|
||||
}) {
|
||||
const { refetch, instance, close } = props;
|
||||
const removeCloudHosted = useEngineRemoveCloudHosted();
|
||||
|
||||
const form = useForm<RemoveCloudHostedInput>({
|
||||
defaultValues: {
|
||||
instanceId: instance.id,
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} isCentered size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
{instance.status === "paymentFailed" ||
|
||||
(instance.status === "active" && !instance.cloudDeployedAt) ? (
|
||||
<>
|
||||
<ModalHeader>Remove Engine Instance</ModalHeader>
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight mb-1">
|
||||
Cancel Engine Subscription
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Complete this form to request to cancel your Engine subscription. This
|
||||
may take up to 2 business days.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ModalBody as={Flex} flexDir="column" gap={2}>
|
||||
<Text>
|
||||
Are you sure you want to remove{" "}
|
||||
<strong>{instance?.name}</strong> from your dashboard?
|
||||
</Text>
|
||||
<Text>
|
||||
This action does not modify your Engine infrastructure. You can
|
||||
re-add it at any time.
|
||||
</Text>
|
||||
</ModalBody>
|
||||
<div className="h-3" />
|
||||
|
||||
<ModalFooter as={Flex} gap={3}>
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
removeFromDashboard(instance.id, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
status: "success",
|
||||
description:
|
||||
"Removed an Engine instance from your dashboard.",
|
||||
});
|
||||
refetch();
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
status: "error",
|
||||
description:
|
||||
"Error removing an Engine instance from your dashboard.",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
colorScheme="red"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
removeCloudHosted(data, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
status: "success",
|
||||
description:
|
||||
"Submitted a request to cancel your Engine subscription. This may take up to 2 business days.",
|
||||
});
|
||||
refetch();
|
||||
onClose();
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon className="size-5 !text-destructive-text" />
|
||||
<AlertTitle>This action is irreversible!</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will no longer be able to access this Engine's local backend
|
||||
wallets. <strong>Any remaining mainnet funds will be lost.</strong>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
removeCloudHosted.mutate(data, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
"Submitted a request to cancel your Engine subscription. This may take up to 2 business days.",
|
||||
{
|
||||
dismissible: true,
|
||||
duration: 10000,
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
status: "error",
|
||||
description:
|
||||
"Error requesting to cancel your Engine subscription.",
|
||||
});
|
||||
},
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<ModalHeader>Cancel Engine Subscription</ModalHeader>
|
||||
);
|
||||
|
||||
<ModalBody as={Stack} gap={4}>
|
||||
<Text>
|
||||
Complete this form to request to cancel your Engine
|
||||
subscription. This may take up to 2 business days.
|
||||
</Text>
|
||||
|
||||
{/* Form */}
|
||||
<FormControl>
|
||||
<FormLabel>
|
||||
Please share any feedback to help us improve
|
||||
</FormLabel>
|
||||
<RadioGroup>
|
||||
<Stack>
|
||||
<Radio
|
||||
value="USING_SELF_HOSTED"
|
||||
{...form.register("reason", { required: true })}
|
||||
>
|
||||
<Text>Migrating to self-hosted</Text>
|
||||
</Radio>
|
||||
<Radio
|
||||
value="TOO_EXPENSIVE"
|
||||
{...form.register("reason", { required: true })}
|
||||
>
|
||||
<Text>Too expensive</Text>
|
||||
</Radio>
|
||||
<Radio
|
||||
value="MISSING_FEATURES"
|
||||
{...form.register("reason", { required: true })}
|
||||
>
|
||||
<Text>Missing features</Text>
|
||||
</Radio>
|
||||
<Radio
|
||||
value="OTHER"
|
||||
{...form.register("reason", { required: true })}
|
||||
>
|
||||
<Text>Other</Text>
|
||||
</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
<Textarea
|
||||
placeholder="Provide additional feedback"
|
||||
mt={2}
|
||||
{...form.register("feedback")}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Alert status="warning" variant="left-accent">
|
||||
<Flex direction="column" gap={2}>
|
||||
<Heading as={AlertTitle} size="label.md">
|
||||
This action is irreversible!
|
||||
</Heading>
|
||||
<Text as={AlertDescription} size="body.md">
|
||||
You will no longer be able to access this Engine's
|
||||
local backend wallets.{" "}
|
||||
<strong>Any remaining mainnet funds will be lost.</strong>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter as={Flex} gap={3}>
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
isDisabled={!form.formState.isValid}
|
||||
>
|
||||
Request to cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
refetch();
|
||||
close();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
"Error requesting to cancel your Engine subscription",
|
||||
);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
>
|
||||
{/* Form */}
|
||||
<FormControl isRequired>
|
||||
<FormLabel className="!text-base">
|
||||
Please share any feedback to help us improve
|
||||
</FormLabel>
|
||||
<RadioGroup>
|
||||
<Stack>
|
||||
<Radio
|
||||
value="USING_SELF_HOSTED"
|
||||
{...form.register("reason", { required: true })}
|
||||
>
|
||||
<span className="text-sm"> Migrating to self-hosted </span>
|
||||
</Radio>
|
||||
<Radio
|
||||
value="TOO_EXPENSIVE"
|
||||
{...form.register("reason", { required: true })}
|
||||
>
|
||||
<span className="text-sm"> Too expensive </span>
|
||||
</Radio>
|
||||
<Radio
|
||||
value="MISSING_FEATURES"
|
||||
{...form.register("reason", { required: true })}
|
||||
>
|
||||
<span className="text-sm"> Missing features </span>
|
||||
</Radio>
|
||||
<Radio
|
||||
value="OTHER"
|
||||
{...form.register("reason", { required: true })}
|
||||
>
|
||||
<span className="text-sm"> Other </span>
|
||||
</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<Textarea
|
||||
className="mt-3"
|
||||
placeholder="Provide additional feedback"
|
||||
{...form.register("feedback")}
|
||||
/>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button onClick={close} variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={!form.formState.isValid}
|
||||
className="gap-2"
|
||||
>
|
||||
{removeCloudHosted.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<SendIcon className="size-4" />
|
||||
)}
|
||||
Request to cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,164 +1,188 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
type EngineInstance,
|
||||
useEngineInstances,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import {
|
||||
Center,
|
||||
Divider,
|
||||
Flex,
|
||||
Icon,
|
||||
ListItem,
|
||||
Stack,
|
||||
UnorderedList,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChakraNextImage } from "components/Image";
|
||||
import { useEngineInstances } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { VscBook } from "react-icons/vsc";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Button, Card, Heading, Link, Text } from "tw-components";
|
||||
import { CreateEngineInstanceButton } from "./create-engine-instance";
|
||||
import {
|
||||
CloudDownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { UnorderedList } from "../../@/components/ui/List/List";
|
||||
import { Button } from "../../@/components/ui/button";
|
||||
import { EngineInstancesTable } from "./engine-instances-table";
|
||||
import { ImportEngineInstanceButton } from "./import-engine-instance";
|
||||
|
||||
interface EngineInstancesListProps {
|
||||
setConnectedInstance: Dispatch<SetStateAction<EngineInstance | undefined>>;
|
||||
}
|
||||
|
||||
export const EngineInstancesList = ({
|
||||
setConnectedInstance,
|
||||
}: EngineInstancesListProps) => {
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_DEMO_ENGINE_URL,
|
||||
"missing NEXT_PUBLIC_DEMO_ENGINE_URL",
|
||||
);
|
||||
const NEXT_PUBLIC_DEMO_ENGINE_URL = process.env.NEXT_PUBLIC_DEMO_ENGINE_URL;
|
||||
|
||||
const { data } = useAccount();
|
||||
export const EngineInstancesList = () => {
|
||||
const instancesQuery = useEngineInstances();
|
||||
const instances = instancesQuery.data ?? [];
|
||||
const trackEvent = useTrack();
|
||||
|
||||
if (instancesQuery.isLoading) {
|
||||
return (
|
||||
<div className="grid place-items-center">
|
||||
<div className="flex justify-center items-center h-[300px]">
|
||||
<Spinner className="size-14" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack spacing={8}>
|
||||
{instances.length === 0 ? (
|
||||
<Center>
|
||||
<Stack spacing={8}>
|
||||
<ChakraNextImage
|
||||
alt="Engine hero image"
|
||||
src={require("../../../public/assets/engine/empty-state-header.png")}
|
||||
maxW={650}
|
||||
/>
|
||||
<Heading size="title.lg" as="h1">
|
||||
Your scalable web3 backend server
|
||||
</Heading>
|
||||
|
||||
<UnorderedList color="gray.600">
|
||||
<ListItem>
|
||||
Read, write, and deploy contracts at production scale
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
Reliably parallelize and retry transactions with gas & nonce
|
||||
management
|
||||
</ListItem>
|
||||
<ListItem>Securely manage backend wallets</ListItem>
|
||||
<ListItem>
|
||||
Built-in support for account abstraction, relayers, and more
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
if (instances.length === 0) {
|
||||
return (
|
||||
<div className="max-w-[600px] mx-auto">
|
||||
<Image
|
||||
alt="Engine hero image"
|
||||
src={require("../../../public/assets/engine/empty-state-header.png")}
|
||||
/>
|
||||
|
||||
<Flex gap={4}>
|
||||
<CreateEngineInstanceButton
|
||||
ctaText="Get Started"
|
||||
refetch={instancesQuery.refetch}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!data) return;
|
||||
<div className="h-6" />
|
||||
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "try-demo",
|
||||
label: "clicked-try-demo",
|
||||
});
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tighter">
|
||||
Your scalable web3 backend server
|
||||
</h1>
|
||||
|
||||
// Need to hardcode the demo engine instance details
|
||||
// but using the signed-in user account id
|
||||
setConnectedInstance({
|
||||
id: "sandbox",
|
||||
url: NEXT_PUBLIC_DEMO_ENGINE_URL,
|
||||
name: "Demo Engine",
|
||||
status: "active",
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
accountId: data.id,
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
px={6}
|
||||
>
|
||||
🚀 Try Demo
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Divider my="2" />
|
||||
<Text>Already have an Engine Instance?</Text>
|
||||
<ImportEngineInstanceButton refetch={instancesQuery.refetch} />
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between md:items-center">
|
||||
<h1 className="text-4xl font-bold">Engine</h1>
|
||||
<div className="flex flex-row gap-2">
|
||||
<ImportEngineInstanceButton refetch={instancesQuery.refetch} />
|
||||
<CreateEngineInstanceButton
|
||||
ctaText="Create Engine Instance"
|
||||
refetch={instancesQuery.refetch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3" />
|
||||
|
||||
<Stack spacing={4}>
|
||||
<EngineInstancesTable
|
||||
instances={instancesQuery.data ?? ([] as EngineInstance[])}
|
||||
isFetched={instancesQuery.isFetched}
|
||||
isLoading={instancesQuery.isLoading}
|
||||
refetch={instancesQuery.refetch}
|
||||
setConnectedInstance={setConnectedInstance}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<UnorderedList>
|
||||
<li>Read, write, and deploy contracts at production scale</li>
|
||||
<li>
|
||||
Reliably parallelize and retry transactions with gas & nonce
|
||||
management
|
||||
</li>
|
||||
<li>Securely manage backend wallets</li>
|
||||
<li>Built-in support for account abstraction, relayers, and more</li>
|
||||
</UnorderedList>
|
||||
|
||||
<Divider />
|
||||
<div className="h-6" />
|
||||
|
||||
<Card p={8}>
|
||||
<Stack>
|
||||
<Flex gap={2} align="center">
|
||||
<Icon as={VscBook} />
|
||||
<Heading size="title.xs">Learn more about Engine</Heading>
|
||||
</Flex>
|
||||
<Text>Dive into features and integration guides.</Text>
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/engine"
|
||||
isExternal
|
||||
color="blue.500"
|
||||
fontSize="small"
|
||||
<div className="flex gap-3">
|
||||
<CreateEngineLink label="Get Started" />
|
||||
|
||||
<Button
|
||||
asChild
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "try-demo",
|
||||
label: "clicked-try-demo",
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
View Docs →
|
||||
</Link>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
<Link href={"/dashboard/engine/sandbox"} className="gap-2">
|
||||
Try Demo
|
||||
<RocketIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-10" />
|
||||
|
||||
<div className="flex items-center gap-4 justify-between rounded-lg border border-border p-6 bg-secondary">
|
||||
<p className="text-lg font-semibold tracking-tight">
|
||||
Already have an Engine Instance?
|
||||
</p>
|
||||
|
||||
<ImportEngineLink label="Import" />
|
||||
</div>
|
||||
|
||||
<div className="h-20" />
|
||||
<LearnMoreCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between md:items-center">
|
||||
<h1 className="text-4xl font-bold">Engine</h1>
|
||||
<div className="flex flex-row gap-2">
|
||||
<ImportEngineLink label="Import" />
|
||||
<CreateEngineLink label="Create Engine Instance" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<EngineInstancesTable
|
||||
instances={instances}
|
||||
isFetched={instancesQuery.isFetched}
|
||||
isLoading={instancesQuery.isLoading}
|
||||
refetch={instancesQuery.refetch}
|
||||
/>
|
||||
|
||||
<div className="h-10" />
|
||||
<LearnMoreCard />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function LearnMoreCard() {
|
||||
return (
|
||||
<div className="p-4 border border-border rounded-lg relative hover:bg-secondary">
|
||||
<h3 className="font-semibold tracking-tight mb-1">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/engine"
|
||||
target="_blank"
|
||||
className="before:content before:absolute before:inset-0"
|
||||
>
|
||||
Learn more about Engine
|
||||
</Link>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Dive into features and integration guides.
|
||||
</p>
|
||||
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground absolute right-4 top-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateEngineLink(props: {
|
||||
label: string;
|
||||
}) {
|
||||
const trackEvent = useTrack();
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "add-engine-instance",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link href={"/dashboard/engine/create"} className="gap-2">
|
||||
<PlusIcon className="size-4" />
|
||||
{props.label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportEngineLink(props: {
|
||||
label: string;
|
||||
}) {
|
||||
const trackEvent = useTrack();
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "import",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link href={"/dashboard/engine/import"} className="gap-2">
|
||||
<CloudDownloadIcon className="size-4" />
|
||||
{props.label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { Flex, Stack } from "@chakra-ui/react";
|
||||
import { SidebarNav } from "core-ui/sidebar/nav";
|
||||
import type { Route } from "core-ui/sidebar/types";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { FiArrowLeft } from "react-icons/fi";
|
||||
import { Button, Heading, Text } from "tw-components";
|
||||
import { EngineVersionBadge } from "./badges/version";
|
||||
import { EngineConfiguration } from "./configuration/engine-configuration";
|
||||
import { EngineWebhooks } from "./configuration/engine-webhooks";
|
||||
import { EngineContractSubscriptions } from "./contract-subscription/engine-contract-subscription";
|
||||
import { EngineExplorer } from "./explorer/engine-explorer";
|
||||
import { EngineOverview } from "./overview/engine-overview";
|
||||
import { EngineAccessTokens } from "./permissions/engine-access-tokens";
|
||||
import { EngineAdmins } from "./permissions/engine-admins";
|
||||
import { EngineRelayer } from "./relayer/engine-relayer";
|
||||
import { EngineSystemMetrics } from "./system-metrics";
|
||||
|
||||
interface EngineNavigationProps {
|
||||
instance: EngineInstance;
|
||||
setConnectedInstance: Dispatch<SetStateAction<EngineInstance | undefined>>;
|
||||
}
|
||||
|
||||
export const EngineNavigation: React.FC<EngineNavigationProps> = ({
|
||||
instance,
|
||||
setConnectedInstance,
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const onClickBack = () => {
|
||||
setConnectedInstance(undefined);
|
||||
};
|
||||
|
||||
const [activePage, setActivePage] = useState<string>("overview");
|
||||
|
||||
const handleClick = useCallback(
|
||||
(id: string) => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "navigate-tab",
|
||||
label: id,
|
||||
url: instance.url,
|
||||
});
|
||||
setActivePage(id);
|
||||
},
|
||||
[instance.url, trackEvent],
|
||||
);
|
||||
|
||||
const links = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
path: "/dashboard/engine/overview",
|
||||
title: "Overview",
|
||||
name: "overview",
|
||||
onClick: () => handleClick("overview"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard/engine/explorer",
|
||||
title: "Explorer",
|
||||
name: "explorer",
|
||||
onClick: () => handleClick("explorer"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard/engine/relayers",
|
||||
title: "Relayers",
|
||||
name: "relayers",
|
||||
onClick: () => handleClick("relayers"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard/engine/contract-subscriptions",
|
||||
title: "Contract Subscriptions",
|
||||
name: "contract-subscriptions",
|
||||
onClick: () => handleClick("contract-subscriptions"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard/engine/admins",
|
||||
title: "Admins",
|
||||
name: "admins",
|
||||
onClick: () => handleClick("admins"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard/engine/access-tokens",
|
||||
title: "Access Tokens",
|
||||
name: "access-tokens",
|
||||
onClick: () => handleClick("access-tokens"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard/engine/webhooks",
|
||||
title: "Webhooks",
|
||||
name: "webhooks",
|
||||
onClick: () => handleClick("webhooks"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard/engine/configuration",
|
||||
title: "Configuration",
|
||||
name: "configuration",
|
||||
onClick: () => handleClick("configuration"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard/engine/metrics",
|
||||
title: "Metrics",
|
||||
name: "metrics",
|
||||
onClick: () => handleClick("metrics"),
|
||||
},
|
||||
] satisfies Route[],
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
const activeComponent = useMemo(() => {
|
||||
const tabs = {
|
||||
overview: <EngineOverview instanceUrl={instance.url} />,
|
||||
explorer: <EngineExplorer instanceUrl={instance.url} />,
|
||||
relayers: <EngineRelayer instanceUrl={instance.url} />,
|
||||
"contract-subscriptions": (
|
||||
<EngineContractSubscriptions instanceUrl={instance.url} />
|
||||
),
|
||||
admins: <EngineAdmins instanceUrl={instance.url} />,
|
||||
"access-tokens": <EngineAccessTokens instanceUrl={instance.url} />,
|
||||
webhooks: <EngineWebhooks instanceUrl={instance.url} />,
|
||||
configuration: <EngineConfiguration instance={instance} />,
|
||||
metrics: <EngineSystemMetrics instance={instance} />,
|
||||
};
|
||||
return tabs[activePage as keyof typeof tabs];
|
||||
}, [instance, activePage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarNav links={links} activePage={activePage} title="Engine" />
|
||||
<Stack spacing={4}>
|
||||
<Button
|
||||
onClick={onClickBack}
|
||||
variant="link"
|
||||
leftIcon={<FiArrowLeft />}
|
||||
w="fit-content"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Stack>
|
||||
<Heading size="title.lg" as="h1" isTruncated>
|
||||
{instance.name}
|
||||
</Heading>
|
||||
|
||||
<Flex gap={3} alignItems="center">
|
||||
{!instance.name.startsWith("https://") && (
|
||||
<Text color="gray.600">{instance.url}</Text>
|
||||
)}
|
||||
<EngineVersionBadge instance={instance} />
|
||||
</Flex>
|
||||
</Stack>
|
||||
|
||||
{activeComponent}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,186 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Stack,
|
||||
type UseDisclosureReturn,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { THIRDWEB_API_HOST } from "constants/urls";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useSingleQueryParam } from "hooks/useQueryParam";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button, FormLabel, Text, TrackedLink } from "tw-components";
|
||||
|
||||
interface ImportEngineInstanceButtonProps {
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export const ImportEngineInstanceButton = ({
|
||||
refetch,
|
||||
}: ImportEngineInstanceButtonProps) => {
|
||||
const hasImportUrl = !!useSingleQueryParam("importUrl");
|
||||
|
||||
const disclosure = useDisclosure({
|
||||
defaultIsOpen: !!hasImportUrl,
|
||||
});
|
||||
const trackEvent = useTrack();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "import",
|
||||
label: "open-modal",
|
||||
});
|
||||
disclosure.onOpen();
|
||||
}}
|
||||
variant="outline"
|
||||
px={6}
|
||||
width="fit-content"
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
|
||||
{disclosure.isOpen && (
|
||||
<ModalImportEngine
|
||||
disclosure={disclosure}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImportEngineInput {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const ModalImportEngine = ({
|
||||
disclosure,
|
||||
onSuccess,
|
||||
}: {
|
||||
disclosure: UseDisclosureReturn;
|
||||
onSuccess: () => void;
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
const defaultUrl = useSingleQueryParam("importUrl");
|
||||
|
||||
const form = useForm<ImportEngineInput>({
|
||||
defaultValues: {
|
||||
name: "My Engine Instance",
|
||||
url: defaultUrl ? decodeURIComponent(defaultUrl) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ImportEngineInput) => {
|
||||
try {
|
||||
// Instance URLs should end with a /.
|
||||
const url = data.url.endsWith("/") ? data.url : `${data.url}/`;
|
||||
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/engine`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
url,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}`);
|
||||
}
|
||||
|
||||
disclosure.onClose();
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
toast({
|
||||
status: "error",
|
||||
description:
|
||||
"Error importing Engine. Please check if the details are correct.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={disclosure.isOpen}
|
||||
onClose={disclosure.onClose}
|
||||
isCentered
|
||||
size="lg"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalCloseButton />
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Import Engine Instance</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Stack spacing={4}>
|
||||
<Text>
|
||||
Import an Engine instance hosted on your infrastructure.
|
||||
<br />
|
||||
<TrackedLink
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/get-started"
|
||||
isExternal
|
||||
category="engine"
|
||||
label="clicked-self-host-instructions"
|
||||
color="blue.500"
|
||||
>
|
||||
Get help setting up Engine for free.
|
||||
</TrackedLink>
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter a descriptive label"
|
||||
autoFocus
|
||||
{...form.register("name")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="Enter your Engine URL"
|
||||
{...form.register("url")}
|
||||
/>
|
||||
</FormControl>
|
||||
<Alert status="warning" borderRadius="m">
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Do not import a URL you do not recognize.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter as={Flex} gap={3}>
|
||||
<Button onClick={disclosure.onClose} variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" colorScheme="primary">
|
||||
Import
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useState } from "react";
|
||||
import { FiArrowLeft, FiArrowRight, FiInfo } from "react-icons/fi";
|
||||
import { toTokens } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
Badge,
|
||||
@@ -311,6 +312,8 @@ const TransactionDetailsDrawer = ({
|
||||
|
||||
const chain: ChainMetadata | undefined =
|
||||
chainIdToChainRecord[Number.parseInt(transaction.chainId)];
|
||||
const decimals = chain.nativeCurrency.decimals || 18;
|
||||
const symbol = chain.nativeCurrency.symbol || "ETH";
|
||||
const explorer = chain?.explorers?.[0];
|
||||
|
||||
const status = statusDetails[transaction.status as EngineStatus];
|
||||
@@ -320,12 +323,15 @@ const TransactionDetailsDrawer = ({
|
||||
: transaction.functionName ?? null;
|
||||
|
||||
let txFeeDisplay = "N/A";
|
||||
if (transaction.gasLimit && transaction.gasPrice) {
|
||||
const txFee =
|
||||
(Number.parseFloat(transaction.gasLimit) *
|
||||
Number.parseFloat(transaction.gasPrice)) /
|
||||
10 ** (chain?.nativeCurrency.decimals || 18);
|
||||
txFeeDisplay = `${txFee} ${chain?.nativeCurrency.symbol || "ETH"}`;
|
||||
if (transaction.gasLimit) {
|
||||
const gasPrice =
|
||||
transaction.effectiveGasPrice ||
|
||||
transaction.gasPrice ||
|
||||
transaction.maxFeePerGas;
|
||||
if (gasPrice) {
|
||||
const txFeeWei = BigInt(transaction.gasLimit) * BigInt(gasPrice);
|
||||
txFeeDisplay = `${toTokens(txFeeWei, decimals)} ${symbol}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -397,7 +403,9 @@ const TransactionDetailsDrawer = ({
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormLabel>
|
||||
{transaction.accountAddress ? "Signer Address" : "From Address"}
|
||||
</FormLabel>
|
||||
<LinkButton
|
||||
variant="ghost"
|
||||
isExternal
|
||||
@@ -412,8 +420,31 @@ const TransactionDetailsDrawer = ({
|
||||
</LinkButton>
|
||||
</FormControl>
|
||||
|
||||
{transaction.accountAddress && (
|
||||
<FormControl>
|
||||
<FormLabel>Account Address</FormLabel>
|
||||
<LinkButton
|
||||
variant="ghost"
|
||||
isExternal
|
||||
size="xs"
|
||||
href={
|
||||
explorer
|
||||
? `${explorer.url}/address/${transaction.accountAddress}`
|
||||
: "#"
|
||||
}
|
||||
>
|
||||
<Text fontFamily="mono">{transaction.accountAddress}</Text>
|
||||
</LinkButton>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>To Address</FormLabel>
|
||||
{/* The "to" address is usually a contract except for native token transfers. */}
|
||||
<FormLabel>
|
||||
{functionCalled === "transfer"
|
||||
? "Recipient Address"
|
||||
: "Contract Address"}
|
||||
</FormLabel>
|
||||
<LinkButton
|
||||
variant="ghost"
|
||||
isExternal
|
||||
@@ -456,6 +487,24 @@ const TransactionDetailsDrawer = ({
|
||||
|
||||
{/* On-chain details */}
|
||||
|
||||
<FormControl>
|
||||
<Flex>
|
||||
<FormLabel>Value</FormLabel>
|
||||
<Tooltip
|
||||
label={`The amount of ${symbol} sent to the "To" .`}
|
||||
shouldWrapChildren
|
||||
>
|
||||
<FiInfo />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Text>
|
||||
{transaction.value
|
||||
? toTokens(BigInt(transaction.value), decimals)
|
||||
: 0}{" "}
|
||||
{symbol}
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
{transaction.transactionHash && (
|
||||
<>
|
||||
<FormControl>
|
||||
@@ -528,6 +577,25 @@ const TransactionDetailsDrawer = ({
|
||||
<Text>{Number(transaction.gasPrice).toLocaleString()}</Text>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{transaction.blockNumber && (
|
||||
<FormControl>
|
||||
<FormLabel>Block</FormLabel>
|
||||
<LinkButton
|
||||
variant="ghost"
|
||||
isExternal
|
||||
size="xs"
|
||||
href={
|
||||
explorer
|
||||
? `${explorer.url}/block/${transaction.blockNumber}`
|
||||
: "#"
|
||||
}
|
||||
maxW="100%"
|
||||
>
|
||||
<Text>{transaction.blockNumber}</Text>
|
||||
</LinkButton>
|
||||
</FormControl>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export const EngineSystemMetrics: React.FC<EngineStatusProps> = ({
|
||||
<Text>
|
||||
Upgrade to a{" "}
|
||||
<TrackedLink
|
||||
href="/dashboard/engine?requestCloudHosted"
|
||||
href="/dashboard/engine/create"
|
||||
isExternal
|
||||
color="blue.500"
|
||||
category="engine"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLoggedInUser } from "../../@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
|
||||
// If it fails to fetch, the server is unreachable.
|
||||
// If it returns a 401, the user is not a valid admin.
|
||||
|
||||
export function useHasEnginePermission(props: {
|
||||
instanceUrl: string;
|
||||
}) {
|
||||
const instanceUrl = props.instanceUrl;
|
||||
const token = useLoggedInUser().user?.jwt ?? null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["auth/permissions/get-all", instanceUrl, token],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${instanceUrl}auth/permissions/get-all`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return {
|
||||
hasPermission: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return {
|
||||
hasPermission: false,
|
||||
reason: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unexpected status code");
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export const CustomChainRenderer = ({
|
||||
{!disableChainConfig && storedChain && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-auto p-2 leading-4 opacity-0 group-hover:opacity-100 hover:bg-transparent transition-opacity"
|
||||
className="ml-auto p-2 leading-4 md:opacity-0 group-hover:opacity-100 hover:bg-transparent transition-opacity"
|
||||
aria-label="Configure Network"
|
||||
onClick={() => {
|
||||
setEditChain(storedChain);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ButtonGroup,
|
||||
Center,
|
||||
Divider,
|
||||
Flex,
|
||||
Icon,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
Spinner,
|
||||
Table,
|
||||
Tbody,
|
||||
@@ -29,7 +33,7 @@ import { type SetStateAction, useMemo, useState } from "react";
|
||||
import { FaEllipsisVertical } from "react-icons/fa6";
|
||||
import { FiArrowRight } from "react-icons/fi";
|
||||
import type { IconType } from "react-icons/lib";
|
||||
import { Button, MenuItem, TableContainer, Text } from "tw-components";
|
||||
import { TableContainer, Text } from "tw-components";
|
||||
|
||||
type CtaMenuItem<TRowData> = {
|
||||
icon?: IconType;
|
||||
@@ -54,6 +58,7 @@ type TWTableProps<TRowData> = {
|
||||
showLess?: boolean;
|
||||
};
|
||||
title: string;
|
||||
bodyRowClassName?: string;
|
||||
};
|
||||
|
||||
export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
|
||||
@@ -148,13 +153,13 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
|
||||
</Th>
|
||||
))}
|
||||
{(tableProps.onRowClick || tableProps.onMenuClick) && (
|
||||
<Th border="none" />
|
||||
<Th border="none" className="w-0" />
|
||||
)}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
|
||||
<Tbody>
|
||||
<Tbody className="!bg-background">
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<Tr
|
||||
@@ -176,6 +181,7 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
className={tableProps.bodyRowClassName}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
@@ -207,30 +213,37 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
|
||||
borderBottomWidth="inherit"
|
||||
borderBottomColor="accent.100"
|
||||
>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
variant="outline"
|
||||
icon={<Icon as={FaEllipsisVertical} boxSize={4} />}
|
||||
aria-label="Actions"
|
||||
/>
|
||||
<MenuList>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Actions"
|
||||
className="relative z-10 p-2.5 !h-auto"
|
||||
>
|
||||
<FaEllipsisVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{tableProps.onMenuClick.map(
|
||||
({ icon, text, onClick, isDestructive }) => {
|
||||
return (
|
||||
<MenuItem
|
||||
<DropdownMenuItem
|
||||
key={text}
|
||||
onClick={() => onClick(row.original)}
|
||||
icon={icon && <Icon as={icon} boxSize={4} />}
|
||||
color={isDestructive ? "red.500" : undefined}
|
||||
className={cn(
|
||||
"gap-3 px-3 py-3 min-w-[170px] cursor-pointer",
|
||||
isDestructive &&
|
||||
"!text-destructive-text hover:!bg-destructive",
|
||||
)}
|
||||
>
|
||||
{icon && <Icon as={icon} boxSize={4} />}
|
||||
{text}
|
||||
</MenuItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
) : null}
|
||||
</Tr>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* publicly available wallet for local nodes
|
||||
* extracted from SDK v4, used to request localhost testnet funds
|
||||
*/
|
||||
export const LOCAL_NODE_PKEY =
|
||||
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
|
||||
@@ -49,3 +49,19 @@ const AddressSchema = z.custom<string>(
|
||||
export const AddressOrEnsSchema = z.union([AddressSchema, EnsSchema], {
|
||||
invalid_type_error: "Provided value was not a valid address or ENS name",
|
||||
});
|
||||
|
||||
const FileSchema = z.instanceof(File) as z.ZodType<InstanceType<typeof File>>;
|
||||
|
||||
const FileOrStringSchema = z.union([FileSchema, z.string()]);
|
||||
|
||||
export const CommonContractSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
image: FileOrStringSchema.optional(),
|
||||
external_link: z.string().optional(),
|
||||
app_uri: z.string().optional(),
|
||||
social_urls: z.record(z.string()).optional(),
|
||||
defaultAdmin: AddressOrEnsSchema.optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEns } from "components/contract-components/hooks";
|
||||
import { ContractOverviewPage } from "contract-ui/tabs/overview/page";
|
||||
import type { EnhancedRoute } from "contract-ui/types/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useAnalyticsSupportedForChain } from "../../data/analytics/hooks";
|
||||
|
||||
const LazyContractExplorerPage = dynamic(() =>
|
||||
import("../tabs/explorer/page").then(
|
||||
@@ -100,6 +101,10 @@ export function useContractRouteConfig(
|
||||
const contractQuery = useContract(ensQuery.data?.address);
|
||||
const contractTypeQuery = contractType.useQuery(contractAddress);
|
||||
|
||||
const analyticsSupported = useAnalyticsSupportedForChain(
|
||||
contractQuery.contract?.chainId,
|
||||
);
|
||||
|
||||
const claimconditionExtensionDetection = extensionDetectedState({
|
||||
contractQuery,
|
||||
feature: [
|
||||
@@ -158,6 +163,11 @@ export function useContractRouteConfig(
|
||||
component: LazyContractAnalyticsPage,
|
||||
isDefault: true,
|
||||
isBeta: true,
|
||||
isEnabled: analyticsSupported.isLoading
|
||||
? "loading"
|
||||
: analyticsSupported.data
|
||||
? "enabled"
|
||||
: "disabled",
|
||||
},
|
||||
{
|
||||
title: "NFTs",
|
||||
|
||||
@@ -9,23 +9,15 @@ import {
|
||||
Flex,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
} from "@chakra-ui/react";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import type {
|
||||
AreaChartProps,
|
||||
GenericDataType,
|
||||
} from "components/analytics/area-chart";
|
||||
import { AutoBarChart } from "components/analytics/auto-bar-chart";
|
||||
import { BarChart } from "components/analytics/bar-chart";
|
||||
import { ChartContainer } from "components/analytics/chart-container";
|
||||
import {
|
||||
type AnalyticsQueryParams,
|
||||
type TotalQueryResult,
|
||||
useAnalyticsSupportedChains,
|
||||
useAnalyticsSupportedForChain,
|
||||
useEventsAnalytics,
|
||||
useFunctionsAnalytics,
|
||||
useLogsAnalytics,
|
||||
@@ -36,7 +28,8 @@ import {
|
||||
useUniqueWalletsAnalytics,
|
||||
} from "data/analytics/hooks";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { Card, Heading, Text } from "tw-components";
|
||||
import { Card, Heading } from "tw-components";
|
||||
import { ThirdwebBarChart } from "../../../@/components/blocks/charts/bar-chart";
|
||||
import { useIsomorphicLayoutEffect } from "../../../@/lib/useIsomorphicLayoutEffect";
|
||||
|
||||
interface ContractAnalyticsPageProps {
|
||||
@@ -61,11 +54,32 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
|
||||
|
||||
const evmContractInfo = useEVMContractInfo();
|
||||
|
||||
if (!contractAddress) {
|
||||
const analyticsSupported = useAnalyticsSupportedForChain(
|
||||
evmContractInfo?.chain?.chainId,
|
||||
);
|
||||
|
||||
if (
|
||||
!contractAddress ||
|
||||
!evmContractInfo?.chain?.chainId ||
|
||||
analyticsSupported.isLoading
|
||||
) {
|
||||
// TODO build a skeleton for this
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!analyticsSupported.data) {
|
||||
return (
|
||||
<Alert status="warning" borderRadius="md" mb={4}>
|
||||
<AlertIcon />
|
||||
<AlertTitle>Analytics is not supported for this chain.</AlertTitle>
|
||||
<AlertDescription>
|
||||
Analytics support is rolling out to additional chains. Please check
|
||||
back later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={6}>
|
||||
{contractAddress && evmContractInfo?.chain && (
|
||||
@@ -111,124 +125,40 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
|
||||
</Flex>
|
||||
</Flex>
|
||||
<SimpleGrid columns={{ base: 1, md: 1 }} gap={4}>
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Unique Wallets
|
||||
</Heading>
|
||||
<Text>
|
||||
The number of unique wallet addresses that have sent a
|
||||
transaction to this contract.
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "wallets", label: "Unique Wallets" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useUniqueWalletsAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Total Transactions
|
||||
</Heading>
|
||||
<Text>
|
||||
The number of transactions that have been sent to this
|
||||
contract.
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "count", label: "Transactions" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useTransactionAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<UniqueWalletsChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Total Events
|
||||
</Heading>
|
||||
<Text>
|
||||
The number of on-chain events that have been emitted from this
|
||||
contract.
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "count", label: "Events" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useLogsAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Function Breakdown
|
||||
</Heading>
|
||||
<Text>
|
||||
The breakdown of calls to each write function from
|
||||
transactions.
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={"auto"}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useFunctionsAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Event Breakdown
|
||||
</Heading>
|
||||
<Text>The breakdown of events emitted by this contract.</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={"auto"}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useEventsAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<TotalTransactionsChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
|
||||
<TotalEventsChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
|
||||
<FunctionBreakdownChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
|
||||
<EventBreakdownChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
@@ -236,87 +166,127 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface AnalyticsChartProps<
|
||||
TAnalytics extends GenericDataType = GenericDataType,
|
||||
> {
|
||||
chainId: number;
|
||||
type ChartProps = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
index: string;
|
||||
categories: AreaChartProps<TAnalytics, "time">["categories"] | "auto";
|
||||
useAnalytics: (params: AnalyticsQueryParams) => UseQueryResult<TAnalytics[]>;
|
||||
showXAxis?: boolean;
|
||||
showYAxis?: boolean;
|
||||
}
|
||||
|
||||
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
|
||||
chainId,
|
||||
contractAddress,
|
||||
startDate,
|
||||
endDate,
|
||||
index,
|
||||
categories,
|
||||
useAnalytics,
|
||||
showXAxis,
|
||||
showYAxis,
|
||||
}) => {
|
||||
const supportedChainsQuery = useAnalyticsSupportedChains();
|
||||
|
||||
const analyticsQuery = useAnalytics({
|
||||
contractAddress,
|
||||
chainId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!analyticsQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return analyticsQuery.data;
|
||||
}, [analyticsQuery.data]);
|
||||
|
||||
if (data.length <= 1) {
|
||||
const supportedChains = supportedChainsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<Alert status="info" borderRadius="md" mb={4}>
|
||||
<AlertIcon />
|
||||
{supportedChains.includes(chainId) ? (
|
||||
<AlertDescription>No recent activity.</AlertDescription>
|
||||
) : (
|
||||
<AlertDescription>
|
||||
Analytics for this chain not currently supported.
|
||||
</AlertDescription>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (categories === "auto") {
|
||||
return (
|
||||
<AutoBarChart
|
||||
data={data}
|
||||
index={{ id: index }}
|
||||
showXAxis
|
||||
showYAxis
|
||||
stacked
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
function UniqueWalletsChart(props: ChartProps) {
|
||||
const { data } = useUniqueWalletsAnalytics(props);
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
data={data}
|
||||
index={{ id: index }}
|
||||
categories={categories}
|
||||
showXAxis={showXAxis !== undefined ? showXAxis : true}
|
||||
showYAxis={showYAxis !== undefined ? showYAxis : true}
|
||||
<ThirdwebBarChart
|
||||
title="Unique Wallets"
|
||||
description="The number of unique wallet addresses that have sent a transaction to this contract."
|
||||
data={data || []}
|
||||
config={{
|
||||
wallets: {
|
||||
label: "Unique Wallets",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function TotalTransactionsChart(props: ChartProps) {
|
||||
const { data } = useTransactionAnalytics({
|
||||
chainId: props.chainId,
|
||||
contractAddress: props.contractAddress,
|
||||
endDate: props.endDate,
|
||||
startDate: props.startDate,
|
||||
});
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Total Transactions"
|
||||
description="The number of transactions that have been sent to this contract."
|
||||
data={data || []}
|
||||
config={{
|
||||
count: {
|
||||
label: "Transactions",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalEventsChart(props: ChartProps) {
|
||||
const { data } = useLogsAnalytics(props);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Total Events"
|
||||
description="The number of on-chain events that have been emitted from this contract."
|
||||
data={data || []}
|
||||
config={{
|
||||
count: {
|
||||
label: "Events",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FunctionBreakdownChart(props: ChartProps) {
|
||||
const { data } = useFunctionsAnalytics(props);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Function Breakdown"
|
||||
description="The breakdown of calls to each write function from transactions."
|
||||
data={data || []}
|
||||
config={Object.keys(data?.[0] || {}).reduce(
|
||||
(acc, key) => {
|
||||
if (key === "time") {
|
||||
return acc;
|
||||
}
|
||||
acc[key] = {
|
||||
label: key,
|
||||
color: `hsl(var(--chart-${(Object.keys(acc).length % 15) + 1}))`,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { label: string; color: string }>,
|
||||
)}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
showLegend
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EventBreakdownChart(props: ChartProps) {
|
||||
const { data } = useEventsAnalytics(props);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Event Breakdown"
|
||||
description="The breakdown of events emitted by this contract."
|
||||
data={data || []}
|
||||
config={Object.keys(data?.[0] || {}).reduce(
|
||||
(acc, key) => {
|
||||
if (key === "time") {
|
||||
return acc;
|
||||
}
|
||||
acc[key] = {
|
||||
label: key,
|
||||
color: `hsl(var(--chart-${(Object.keys(acc).length % 15) + 1}))`,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { label: string; color: string }>,
|
||||
)}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
showLegend
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnalyticsStatProps {
|
||||
label: string;
|
||||
|
||||
+2
-2
@@ -615,7 +615,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
// TODO: Automatically start editing the new phase after adding it
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<div className="flex items-center gap-1">
|
||||
{ClaimConditionTypeData[type].name}
|
||||
<TooltipBox
|
||||
content={
|
||||
@@ -627,7 +627,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
+10
-9
@@ -1,3 +1,4 @@
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { thirdwebClient } from "@/constants/client";
|
||||
import { AdminOnly } from "@3rdweb-sdk/react/components/roles/admin-only";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
@@ -6,12 +7,11 @@ import {
|
||||
useResetClaimConditions,
|
||||
} from "@thirdweb-dev/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { TooltipBox } from "components/configure-networks/Form/TooltipBox";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { useV5DashboardChain } from "lib/v5-adapter";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { getContract } from "thirdweb";
|
||||
import { Text } from "tw-components";
|
||||
|
||||
interface ResetClaimEligibilityProps {
|
||||
isErc20: boolean;
|
||||
@@ -88,18 +88,19 @@ export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
|
||||
size="sm"
|
||||
>
|
||||
Reset Eligibility{" "}
|
||||
<TooltipBox
|
||||
iconColor="secondary.500"
|
||||
content={
|
||||
<Text>
|
||||
This contract's claim eligibility stores who has already
|
||||
<ToolTipLabel
|
||||
label={
|
||||
<>
|
||||
This {`contract's`} claim eligibility stores who has already
|
||||
claimed {isErc20 ? "tokens" : "NFTs"} from this contract and
|
||||
carries across claim phases. Resetting claim eligibility will
|
||||
reset this state permanently, and wallets that have already
|
||||
claimed to their limit will be able to claim again.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<CircleHelpIcon className="size-4 text-muted-foreground ml-2" />
|
||||
</ToolTipLabel>
|
||||
</TransactionButton>
|
||||
</AdminOnly>
|
||||
);
|
||||
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
useClipboard,
|
||||
} from "@chakra-ui/react";
|
||||
import { IoMdCheckmark } from "@react-icons/all-files/io/IoMdCheckmark";
|
||||
import type { DropContract } from "@thirdweb-dev/react";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useSupportedChainsRecord } from "hooks/chains/configureChains";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { FiCopy } from "react-icons/fi";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
Button,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import type { StoredChain } from "../../../../contexts/configured-chains";
|
||||
|
||||
interface EmbedSetupProps {
|
||||
contract: DropContract;
|
||||
contract: ThirdwebContract;
|
||||
ercOrMarketplace: string;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ const isValidUrl = (url: string | undefined) => {
|
||||
};
|
||||
|
||||
const buildIframeSrc = (
|
||||
contract?: DropContract,
|
||||
contract: ThirdwebContract,
|
||||
ercOrMarketplace?: string,
|
||||
options?: IframeSrcOptions,
|
||||
): string => {
|
||||
@@ -114,7 +114,7 @@ const buildIframeSrc = (
|
||||
`https://embed.ipfscdn.io/ipfs/${contractEmbedHash}/${contractPath}`,
|
||||
);
|
||||
|
||||
url.searchParams.append("contract", contract.getAddress());
|
||||
url.searchParams.append("contract", contract.address);
|
||||
url.searchParams.append("chain", chain);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
|
||||
@@ -565,7 +565,7 @@ export const EmbedSetup: React.FC<EmbedSetupProps> = ({
|
||||
category: "embed",
|
||||
action: "click",
|
||||
label: "copy-code",
|
||||
address: contract?.getAddress(),
|
||||
address: contract.address,
|
||||
chainId,
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { getErcs, useContract, useContractType } from "@thirdweb-dev/react";
|
||||
import { detectFeatures } from "components/contract-components/utils";
|
||||
import { thirdwebClient } from "lib/thirdweb-client";
|
||||
import { useV5DashboardChain } from "lib/v5-adapter";
|
||||
import { getContract } from "thirdweb";
|
||||
import { EmbedSetup } from "./components/embed-setup";
|
||||
|
||||
interface ContractEmbedPageProps {
|
||||
@@ -33,18 +36,27 @@ export const ContractEmbedPage: React.FC<ContractEmbedPageProps> = ({
|
||||
? "erc721"
|
||||
: null;
|
||||
|
||||
const chain = useV5DashboardChain(contractQuery.contract?.chainId);
|
||||
|
||||
if (contractQuery.isLoading) {
|
||||
// TODO build a skeleton for this
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!contractQuery.contract || !chain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contract = getContract({
|
||||
address: contractQuery.contract.getAddress(),
|
||||
chain,
|
||||
client: thirdwebClient,
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={6}>
|
||||
{contractQuery?.contract && ercOrMarketplace && (
|
||||
<EmbedSetup
|
||||
contract={contractQuery.contract}
|
||||
ercOrMarketplace={ercOrMarketplace}
|
||||
/>
|
||||
<EmbedSetup contract={contract} ercOrMarketplace={ercOrMarketplace} />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { Flex, GridItem, Icon, SimpleGrid, Tooltip } from "@chakra-ui/react";
|
||||
import { ChartContainer } from "components/analytics/chart-container";
|
||||
import { AnalyticsChart } from "contract-ui/tabs/analytics/page";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTabHref } from "contract-ui/utils";
|
||||
import {
|
||||
useAnalyticsSupportedForChain,
|
||||
useLogsAnalytics,
|
||||
useTransactionAnalytics,
|
||||
useUniqueWalletsAnalytics,
|
||||
} from "data/analytics/hooks";
|
||||
import { useState } from "react";
|
||||
import { FiInfo } from "react-icons/fi";
|
||||
import {
|
||||
Card,
|
||||
Heading,
|
||||
TrackedLink,
|
||||
type TrackedLinkProps,
|
||||
} from "tw-components";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface AnalyticsOverviewProps {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
trackingCategory: TrackedLinkProps["category"];
|
||||
trackingCategory: string;
|
||||
}
|
||||
|
||||
export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
|
||||
@@ -27,10 +23,13 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
|
||||
contractAddress,
|
||||
trackingCategory,
|
||||
}) => {
|
||||
const analyticsSupported = useAnalyticsSupportedForChain(chainId);
|
||||
|
||||
const trackEvent = useTrack();
|
||||
const [startDate] = useState(
|
||||
(() => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 11);
|
||||
date.setDate(date.getDate() - 14);
|
||||
return date;
|
||||
})(),
|
||||
);
|
||||
@@ -38,152 +37,94 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
|
||||
|
||||
const analyticsHref = useTabHref("analytics");
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={{ base: 3, md: 6 }}>
|
||||
<Flex align="center" justify="space-between" w="full">
|
||||
<Heading size="title.sm">Analytics</Heading>
|
||||
<TrackedLink
|
||||
category={trackingCategory}
|
||||
label="view_all_nfts"
|
||||
color="blue.400"
|
||||
_light={{
|
||||
color: "blue.600",
|
||||
}}
|
||||
gap={4}
|
||||
href={analyticsHref}
|
||||
>
|
||||
View all ->
|
||||
</TrackedLink>
|
||||
</Flex>
|
||||
if (!analyticsSupported.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
<SimpleGrid gap={{ base: 3, md: 6 }} columns={{ base: 1, md: 3 }}>
|
||||
<GridItem>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
gap={4}
|
||||
as={Card}
|
||||
bg="backgroundHighlight"
|
||||
h="full"
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Heading size="label.md">Unique Wallets</Heading>
|
||||
<Tooltip
|
||||
p={0}
|
||||
label={
|
||||
<Flex p={2} fontSize="small" color="white">
|
||||
The number of unique wallet addresses that have sent a
|
||||
transaction to this contract.
|
||||
</Flex>
|
||||
}
|
||||
bgColor="backgroundCardHighlight"
|
||||
borderRadius="lg"
|
||||
placement="right"
|
||||
shouldWrapChildren
|
||||
>
|
||||
<Icon as={FiInfo} boxSize={4} color="gray.700" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<ChartContainer className="w-full" ratio={1.7}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "wallets", label: "Unique Wallets" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useUniqueWalletsAnalytics}
|
||||
showYAxis={false}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
gap={4}
|
||||
as={Card}
|
||||
bg="backgroundHighlight"
|
||||
h="full"
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Heading size="label.md">Total Transactions</Heading>
|
||||
<Tooltip
|
||||
p={0}
|
||||
label={
|
||||
<Flex p={2} fontSize="small" color="white">
|
||||
The number of transactions that have been sent to this
|
||||
contract.
|
||||
</Flex>
|
||||
}
|
||||
bgColor="backgroundCardHighlight"
|
||||
borderRadius="lg"
|
||||
placement="right"
|
||||
shouldWrapChildren
|
||||
>
|
||||
<Icon as={FiInfo} boxSize={4} color="gray.700" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<ChartContainer className="w-full" ratio={1.7}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "count", label: "Transactions" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useTransactionAnalytics}
|
||||
showYAxis={false}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
gap={4}
|
||||
as={Card}
|
||||
bg="backgroundHighlight"
|
||||
h="full"
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Heading size="label.md">Total Events</Heading>
|
||||
<Tooltip
|
||||
p={0}
|
||||
label={
|
||||
<Flex p={2} fontSize="small" color="white">
|
||||
The number of on-chain events that have been emitted from
|
||||
this contract.
|
||||
</Flex>
|
||||
}
|
||||
bgColor="backgroundCardHighlight"
|
||||
borderRadius="lg"
|
||||
placement="right"
|
||||
shouldWrapChildren
|
||||
>
|
||||
<Icon as={FiInfo} boxSize={4} color="gray.700" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<ChartContainer className="w-full" ratio={1.7}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "count", label: "Events" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useLogsAnalytics}
|
||||
showYAxis={false}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
</Flex>
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
asChild
|
||||
className="absolute top-4 right-6 flex flex-row gap-1 items-center"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: trackingCategory,
|
||||
action: "click",
|
||||
label: "view_all_analytics",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link href={analyticsHref}>
|
||||
<span>View All</span>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<OverviewAnalytics
|
||||
chainId={chainId}
|
||||
contractAddress={contractAddress}
|
||||
endDate={endDate}
|
||||
startDate={startDate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ChartProps = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
|
||||
function OverviewAnalytics(props: ChartProps) {
|
||||
const wallets = useUniqueWalletsAnalytics(props);
|
||||
const transactions = useTransactionAnalytics(props);
|
||||
const events = useLogsAnalytics(props);
|
||||
|
||||
const mergedData = useMemo(() => {
|
||||
const time = (wallets.data || transactions.data || events.data || []).map(
|
||||
(wallet) => wallet.time,
|
||||
);
|
||||
|
||||
return time.map((time) => {
|
||||
const wallet = wallets.data?.find((wallet) => wallet.time === time);
|
||||
const transaction = transactions.data?.find(
|
||||
(transaction) => transaction.time === time,
|
||||
);
|
||||
const event = events.data?.find((event) => event.time === time);
|
||||
|
||||
return {
|
||||
time,
|
||||
wallets: wallet?.wallets || 0,
|
||||
transactions: transaction?.count || 0,
|
||||
events: event?.count || 0,
|
||||
};
|
||||
});
|
||||
}, [wallets.data, transactions.data, events.data]);
|
||||
|
||||
return (
|
||||
<ThirdwebAreaChart
|
||||
title="Analytics"
|
||||
config={{
|
||||
wallets: {
|
||||
label: "Unique Wallets",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
transactions: {
|
||||
label: "Total Transactions",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
events: {
|
||||
label: "Total Events",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
}}
|
||||
data={mergedData}
|
||||
showLegend
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,26 +9,23 @@ import {
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useContractMetadata } from "@thirdweb-dev/react";
|
||||
import {
|
||||
CommonContractSchema,
|
||||
type ValidContractInstance,
|
||||
} from "@thirdweb-dev/sdk/evm";
|
||||
import type { ExtensionDetectedState } from "components/buttons/ExtensionDetectButton";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { CommonContractSchema } from "constants/schemas";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useInvalidatev4Contract } from "hooks/invalidate-v4-contract";
|
||||
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { thirdwebClient } from "lib/thirdweb-client";
|
||||
import { useV5DashboardChain } from "lib/v5-adapter";
|
||||
import { useMemo } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { FiPlus, FiTrash } from "react-icons/fi";
|
||||
import { getContract } from "thirdweb";
|
||||
import { setContractMetadata } from "thirdweb/extensions/common";
|
||||
import { useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import {
|
||||
getContractMetadata,
|
||||
setContractMetadata,
|
||||
} from "thirdweb/extensions/common";
|
||||
import { useReadContract, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -63,27 +60,41 @@ function extractDomain(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export const SettingsMetadata = <
|
||||
TContract extends ValidContractInstance | undefined,
|
||||
>({
|
||||
const SocialUrlSchema = z.record(z.string(), z.string());
|
||||
|
||||
export const SettingsMetadata = ({
|
||||
contract,
|
||||
detectedState,
|
||||
}: {
|
||||
contract: TContract;
|
||||
contract: ThirdwebContract;
|
||||
detectedState: ExtensionDetectedState;
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
const metadata = useContractMetadata(contract);
|
||||
const metadata = useReadContract(getContractMetadata, { contract });
|
||||
const sendTransaction = useSendAndConfirmTransaction();
|
||||
const invalidateContract = useInvalidatev4Contract();
|
||||
|
||||
const transformedQueryData = useMemo(() => {
|
||||
let socialUrls: z.infer<typeof SocialUrlSchema> = {
|
||||
twitter: "",
|
||||
discord: "",
|
||||
};
|
||||
if (metadata.data?.social_urls) {
|
||||
try {
|
||||
const parsed = SocialUrlSchema.parse(metadata.data.social_urls);
|
||||
socialUrls = parsed;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...metadata.data,
|
||||
name: metadata.data?.name || "",
|
||||
dashboard_social_urls: Object.entries(
|
||||
metadata.data?.social_urls || { twitter: "", discord: "" },
|
||||
).map(([key, value]) => ({ key, value })),
|
||||
image: metadata.data?.image || "",
|
||||
dashboard_social_urls: Object.entries(socialUrls).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
};
|
||||
}, [metadata.data]);
|
||||
|
||||
@@ -111,20 +122,10 @@ export const SettingsMetadata = <
|
||||
name: "dashboard_social_urls",
|
||||
});
|
||||
|
||||
const chain = useV5DashboardChain(contract?.chainId);
|
||||
const contractV5 =
|
||||
contract && chain
|
||||
? getContract({
|
||||
address: contract.getAddress(),
|
||||
chain: chain,
|
||||
client: thirdwebClient,
|
||||
})
|
||||
: null;
|
||||
|
||||
const { onSuccess, onError } = useTxNotifications(
|
||||
"Successfully updated metadata",
|
||||
"Error updating metadata",
|
||||
contractV5,
|
||||
contract,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -153,9 +154,6 @@ export const SettingsMetadata = <
|
||||
},
|
||||
{},
|
||||
);
|
||||
if (!contract || !contractV5) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
category: "settings",
|
||||
@@ -164,7 +162,7 @@ export const SettingsMetadata = <
|
||||
});
|
||||
|
||||
const tx = setContractMetadata({
|
||||
contract: contractV5,
|
||||
contract,
|
||||
...data,
|
||||
social_urls: socialUrlsObj,
|
||||
});
|
||||
@@ -189,8 +187,8 @@ export const SettingsMetadata = <
|
||||
},
|
||||
onSettled: () => {
|
||||
return invalidateContract({
|
||||
contractAddress: contract.getAddress(),
|
||||
chainId: contract.chainId,
|
||||
contractAddress: contract.address,
|
||||
chainId: contract.chain.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -323,24 +321,22 @@ export const SettingsMetadata = <
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{contractV5 && (
|
||||
<AdminOnly contract={contractV5}>
|
||||
<TransactionButton
|
||||
colorScheme="primary"
|
||||
transactionCount={1}
|
||||
isDisabled={metadata.isLoading || !formState.isDirty}
|
||||
type="submit"
|
||||
isLoading={sendTransaction.isPending}
|
||||
loadingText="Saving..."
|
||||
size="md"
|
||||
borderRadius="xl"
|
||||
borderTopLeftRadius="0"
|
||||
borderTopRightRadius="0"
|
||||
>
|
||||
Update Metadata
|
||||
</TransactionButton>
|
||||
</AdminOnly>
|
||||
)}
|
||||
<AdminOnly contract={contract}>
|
||||
<TransactionButton
|
||||
colorScheme="primary"
|
||||
transactionCount={1}
|
||||
isDisabled={metadata.isLoading || !formState.isDirty}
|
||||
type="submit"
|
||||
isLoading={sendTransaction.isPending}
|
||||
loadingText="Saving..."
|
||||
size="md"
|
||||
borderRadius="xl"
|
||||
borderTopLeftRadius="0"
|
||||
borderTopRightRadius="0"
|
||||
>
|
||||
Update Metadata
|
||||
</TransactionButton>
|
||||
</AdminOnly>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -55,12 +55,14 @@ export const ContractSettingsPage: React.FC<ContractSettingsPageProps> = ({
|
||||
<Flex direction="column" gap={4}>
|
||||
<Flex gap={8} w="100%">
|
||||
<SimpleGrid columns={1} w="100%" gap={8}>
|
||||
<GridItem order={detectedMetadata === "enabled" ? 0 : 100}>
|
||||
<SettingsMetadata
|
||||
contract={contractQuery.contract}
|
||||
detectedState={detectedMetadata}
|
||||
/>
|
||||
</GridItem>
|
||||
{contract && (
|
||||
<GridItem order={detectedMetadata === "enabled" ? 0 : 100}>
|
||||
<SettingsMetadata
|
||||
contract={contract}
|
||||
detectedState={detectedMetadata}
|
||||
/>
|
||||
</GridItem>
|
||||
)}
|
||||
{contract && (
|
||||
<GridItem order={detectedPrimarySale === "enabled" ? 2 : 101}>
|
||||
<SettingsPrimarySale
|
||||
|
||||
@@ -18,11 +18,11 @@ import { useContract } from "@thirdweb-dev/react";
|
||||
import type { Abi } from "@thirdweb-dev/sdk";
|
||||
import { SourcesPanel } from "components/contract-components/shared/sources-panel";
|
||||
import { useContractSources } from "contract-ui/hooks/useContractSources";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FiCheckCircle, FiXCircle } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Heading } from "tw-components";
|
||||
import { useDashboardRouter } from "../../../@/lib/DashboardRouter";
|
||||
|
||||
interface ContractSourcesPageProps {
|
||||
contractAddress?: string;
|
||||
@@ -257,7 +257,7 @@ function RefreshContractMetadataButton(props: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const contractCacheMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -65,6 +65,7 @@ export const ContractSidebar: React.FC<ContractSidebarProps> = ({
|
||||
href: `/${r.path.replace("overview", "")}`,
|
||||
isBeta: r.isBeta,
|
||||
isDeprecated: r.isDeprecated,
|
||||
extensionDetectedState: r.isEnabled,
|
||||
onClick: () => {
|
||||
openState.onClose();
|
||||
},
|
||||
@@ -131,25 +132,25 @@ const NavLinkSection: React.FC<NavLinkSectionProps> = ({
|
||||
</Text>
|
||||
</Flex>
|
||||
{filteredLinks.map((link) => (
|
||||
<Flex gap={2} key={link.href}>
|
||||
<DetailNavLink key={link.href} {...link}>
|
||||
<DetailNavLink key={link.href} {...link}>
|
||||
<Flex gap={2} as="span">
|
||||
{link.title}
|
||||
</DetailNavLink>
|
||||
{link.isBeta && (
|
||||
<Box>
|
||||
<Badge colorScheme="green" variant="subtle">
|
||||
Beta
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
{link.isDeprecated && (
|
||||
<Box>
|
||||
<Badge colorScheme="orange" variant="subtle">
|
||||
Deprecated
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
{link.isBeta && (
|
||||
<Box>
|
||||
<Badge colorScheme="green" variant="subtle">
|
||||
Beta
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
{link.isDeprecated && (
|
||||
<Box>
|
||||
<Badge colorScheme="orange" variant="subtle">
|
||||
Deprecated
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</DetailNavLink>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const THIRDWEB_ANALYTICS_API_HOSTNAME = "https://chainsaw.thirdweb.com";
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { THIRDWEB_ANALYTICS_API_HOSTNAME } from "./constants";
|
||||
|
||||
export type AnalyticsQueryParams = {
|
||||
chainId: number;
|
||||
@@ -17,7 +16,7 @@ async function makeQuery(
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("&")}`;
|
||||
return fetch(`${THIRDWEB_ANALYTICS_API_HOSTNAME}${path}${queryString}`, {
|
||||
return fetch(`/api/server-proxy/chainsaw/${path}${queryString}`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
@@ -374,17 +373,24 @@ export function useTotalWalletsAnalytics(params: AnalyticsQueryParams) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnalyticsSupportedChains() {
|
||||
return useQuery(
|
||||
["analytics-supported-chains"] as const,
|
||||
async (): Promise<number[]> => {
|
||||
const res = await makeQuery("/api/v1/supported-chains", {});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}`);
|
||||
}
|
||||
export function useAnalyticsSupportedForChain(chainId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["analytics-supported-chains", chainId] as const,
|
||||
queryFn: async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await makeQuery(`/service/chains/${chainId}`, {});
|
||||
if (!res.ok) {
|
||||
// assume not supported if we get a non-200 response
|
||||
return false;
|
||||
}
|
||||
|
||||
const { results } = await res.json();
|
||||
return results;
|
||||
const { data } = await res.json();
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error("Error checking if analytics is supported for chain", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
enabled: !!chainId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ export enum PageId {
|
||||
|
||||
// thirdweb.com/dashboard/engine
|
||||
EngineManage = "engine-manage",
|
||||
EngineCreate = "engine-create",
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// settings pages
|
||||
|
||||
@@ -3,14 +3,7 @@ import {
|
||||
useEVMContractInfo,
|
||||
useSetEVMContractInfo,
|
||||
} from "@3rdweb-sdk/react";
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Box,
|
||||
Container,
|
||||
Flex,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { Box, Container, Flex } from "@chakra-ui/react";
|
||||
import {
|
||||
type DehydratedState,
|
||||
QueryClient,
|
||||
@@ -20,7 +13,6 @@ import { AppLayout } from "components/app-layouts/app";
|
||||
import { ConfigureNetworks } from "components/configure-networks/ConfigureNetworks";
|
||||
import { ensQuery } from "components/contract-components/hooks";
|
||||
import { ContractMetadata } from "components/custom-contract/contract-header/contract-metadata";
|
||||
import { HomepageSection } from "components/product-pages/homepage/HomepageSection";
|
||||
import { THIRDWEB_DOMAIN } from "constants/urls";
|
||||
import { SupportedChainsReadyContext } from "contexts/configured-chains";
|
||||
import { PrimaryDashboardButton } from "contract-ui/components/primary-dashboard-button";
|
||||
@@ -47,6 +39,8 @@ import { isERC1155 } from "thirdweb/extensions/erc1155";
|
||||
import { fetchChain } from "utils/fetchChain";
|
||||
import type { ThirdwebNextPage } from "utils/types";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
import { Spinner } from "../../@/components/ui/Spinner/Spinner";
|
||||
import { Alert } from "../../@/components/ui/alert";
|
||||
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
|
||||
import { DeprecatedAlert } from "../../components/shared/DeprecatedAlert";
|
||||
import { mapV4ChainToV5Chain } from "../../contexts/map-chains";
|
||||
@@ -187,35 +181,25 @@ const ContractPage: ThirdwebNextPage = () => {
|
||||
|
||||
if (!contractInfo) {
|
||||
return (
|
||||
<Flex h="100%" justifyContent="center" alignItems="center">
|
||||
<Spinner size="xl" />
|
||||
</Flex>
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chainNotFound) {
|
||||
return (
|
||||
<HomepageSection maxW="container.md" mx="auto">
|
||||
<Box mb={8} mt={8}>
|
||||
<Alert borderRadius="md" background="backgroundHighlight">
|
||||
<AlertIcon />
|
||||
You tried to connecting to {isSlugNumber ? "Chain" : "Network"} ID{" "}
|
||||
{`"`}
|
||||
{chainSlug}
|
||||
{`"`} but it is not configured yet. Please configure it and try
|
||||
again.
|
||||
</Alert>
|
||||
</Box>
|
||||
<div className="mx-auto max-w-[500px]">
|
||||
<Alert variant="warning">
|
||||
You tried to connecting to {isSlugNumber ? "Chain" : "Network"} ID{" "}
|
||||
{`"`}
|
||||
{chainSlug}
|
||||
{`"`} but it is not configured yet. Please configure it and try again
|
||||
</Alert>
|
||||
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderRadius="md"
|
||||
borderColor="backgroundHighlight"
|
||||
overflow="hidden"
|
||||
_light={{
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<div className="h-10" />
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<ConfigureNetworks
|
||||
prefillSlug={isSlugNumber ? undefined : chainSlug}
|
||||
prefillChainId={isSlugNumber ? chainSlug : undefined}
|
||||
@@ -228,16 +212,16 @@ const ContractPage: ThirdwebNextPage = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</HomepageSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSupportedChainsReady) {
|
||||
return (
|
||||
<Flex h="100%" justifyContent="center" alignItems="center">
|
||||
<Spinner size="xl" />
|
||||
</Flex>
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -369,9 +353,9 @@ ContractPage.getLayout = (page, props: EVMContractProps) => {
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<Flex h="100%" justifyContent="center" alignItems="center">
|
||||
<Spinner size="xl" />
|
||||
</Flex>
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { generateBreakpointTypographyCssVars } from "tw-components/utils/typogra
|
||||
import type { ThirdwebNextPage } from "utils/types";
|
||||
import chakraTheme from "../theme";
|
||||
import "@/styles/globals.css";
|
||||
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
|
||||
|
||||
const inter = interConstructor({
|
||||
subsets: ["latin"],
|
||||
@@ -269,6 +270,8 @@ const ConsoleApp = memo(function ConsoleApp({
|
||||
transitionTimingFunction="ease"
|
||||
/>
|
||||
|
||||
<DashboardRouterTopProgressBar />
|
||||
|
||||
<TailwindTheme>
|
||||
<ChakraProvider theme={chakraThemeWithFonts}>
|
||||
<AnnouncementBanner />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const config = {
|
||||
runtime: "edge",
|
||||
};
|
||||
const handler = async (req: NextRequest) => {
|
||||
if (!process.env.CHAINSAW_API_KEY) {
|
||||
return new Response("CHAINSAW_API_KEY not set", { status: 401 });
|
||||
}
|
||||
|
||||
// get the full path from the request including query params
|
||||
let pathname = req.nextUrl.pathname;
|
||||
|
||||
// remove the /api/server-proxy prefix
|
||||
pathname = pathname.replace(/^\/api\/server-proxy\/chainsaw/, "");
|
||||
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
searchParams.delete("paths");
|
||||
|
||||
// create a new URL object for the chainsaw service
|
||||
// TODO: this should really point at dev vs prod based on the environment - but for now prod only will have to suffice
|
||||
const CHAINSAW_URL = new URL("https://chainsaw.thirdweb.com");
|
||||
CHAINSAW_URL.pathname = pathname;
|
||||
searchParams.forEach((value, key) => {
|
||||
CHAINSAW_URL.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
return fetch(CHAINSAW_URL, {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
// pass the shared secret
|
||||
"x-service-api-key": process.env.CHAINSAW_API_KEY,
|
||||
},
|
||||
body: req.body,
|
||||
});
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineAccessTokens } from "components/engine/permissions/engine-access-tokens";
|
||||
|
||||
export default createEnginePage("access-tokens", ({ instance }) => (
|
||||
<EngineAccessTokens instanceUrl={instance.url} />
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineAdmins } from "components/engine/permissions/engine-admins";
|
||||
|
||||
export default createEnginePage("admins", ({ instance }) => (
|
||||
<EngineAdmins instanceUrl={instance.url} />
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineConfiguration } from "components/engine/configuration/engine-configuration";
|
||||
|
||||
export default createEnginePage("configuration", ({ instance }) => (
|
||||
<EngineConfiguration instance={instance} />
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineContractSubscriptions } from "components/engine/contract-subscription/engine-contract-subscription";
|
||||
|
||||
export default createEnginePage("contract-subscriptions", ({ instance }) => (
|
||||
<EngineContractSubscriptions instanceUrl={instance.url} />
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineExplorer } from "components/engine/explorer/engine-explorer";
|
||||
|
||||
export default createEnginePage("explorer", ({ instance }) => (
|
||||
<EngineExplorer instanceUrl={instance.url} />
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineOverview } from "components/engine/overview/engine-overview";
|
||||
|
||||
export default createEnginePage("overview", ({ instance }) => (
|
||||
<EngineOverview instanceUrl={instance.url} />
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineSystemMetrics } from "components/engine/system-metrics";
|
||||
|
||||
export default createEnginePage("metrics", ({ instance }) => (
|
||||
<EngineSystemMetrics instance={instance} />
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineRelayer } from "components/engine/relayer/engine-relayer";
|
||||
|
||||
export default createEnginePage("relayers", ({ instance }) => (
|
||||
<EngineRelayer instanceUrl={instance.url} />
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEnginePage } from "components/engine/EnginePage";
|
||||
import { EngineWebhooks } from "components/engine/configuration/engine-webhooks";
|
||||
|
||||
export default createEnginePage("webhooks", ({ instance }) => (
|
||||
<EngineWebhooks instanceUrl={instance.url} />
|
||||
));
|
||||
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { RocketIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../@/components/ui/button";
|
||||
import { useDashboardRouter } from "../../../@/lib/DashboardRouter";
|
||||
import { AppLayout } from "../../../components/app-layouts/app";
|
||||
import { EngineSidebar } from "../../../components/engine/EngineSidebar";
|
||||
import {
|
||||
EngineTierCard,
|
||||
MONTHLY_PRICE_USD,
|
||||
} from "../../../components/engine/tier-card";
|
||||
import { LazyOnboardingBilling } from "../../../components/onboarding/LazyOnboardingBilling";
|
||||
import { OnboardingModal } from "../../../components/onboarding/Modal";
|
||||
import { THIRDWEB_API_HOST } from "../../../constants/urls";
|
||||
import { useTrack } from "../../../hooks/analytics/useTrack";
|
||||
import { PageId } from "../../../page-id";
|
||||
import type { ThirdwebNextPage } from "../../../utils/types";
|
||||
|
||||
const Page: ThirdwebNextPage = () => {
|
||||
const trackEvent = useTrack();
|
||||
const router = useDashboardRouter();
|
||||
const [selectedTier, setSelectedTier] = useState<"STARTER" | "PREMIUM">();
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const accountQuery = useAccount();
|
||||
const [isBillingModalOpen, setIsBillingModalOpen] = useState(false);
|
||||
|
||||
async function createEngineInstance(tier: "STARTER" | "PREMIUM") {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-cloud-hosted",
|
||||
tier: tier,
|
||||
});
|
||||
|
||||
try {
|
||||
toast.info("Starting Engine deployment");
|
||||
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/add-cloud-hosted`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tier: tier,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
toast.warning(
|
||||
"There is a pending Engine deployment. Please contact [email protected] if this takes longer than 2 hours.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const error =
|
||||
json.error?.message ??
|
||||
"Unexpected error. Please try again or visit https://thirdweb.com/support.";
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
"Thank you! Your Engine deployment will begin shortly. You can view the progress in Overview page.",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedTier && (
|
||||
<ConfirmEngineTierDialog
|
||||
tier={selectedTier}
|
||||
onOpenChange={setIsConfirmationDialogOpen}
|
||||
open={isConfirmationDialogOpen}
|
||||
onConfirm={async () => {
|
||||
setIsConfirmationDialogOpen(false);
|
||||
|
||||
// If Payment is already setup, deploy the Engine
|
||||
if (accountQuery.data?.status === AccountStatus.ValidPayment) {
|
||||
await createEngineInstance(selectedTier);
|
||||
} else {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-add-payment-method",
|
||||
});
|
||||
setIsBillingModalOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OnboardingModal
|
||||
isOpen={isBillingModalOpen}
|
||||
onClose={() => setIsBillingModalOpen(false)}
|
||||
>
|
||||
<LazyOnboardingBilling
|
||||
onSave={async () => {
|
||||
if (!selectedTier) {
|
||||
return;
|
||||
}
|
||||
|
||||
await createEngineInstance(selectedTier);
|
||||
setIsBillingModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsBillingModalOpen(false)}
|
||||
/>
|
||||
</OnboardingModal>
|
||||
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-2">
|
||||
Choose an Engine deployment
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-7">
|
||||
Host Engine on thirdweb with no setup or maintenance required
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<EngineTierCard
|
||||
tier="STARTER"
|
||||
onClick={() => {
|
||||
setSelectedTier("STARTER");
|
||||
setIsConfirmationDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EngineTierCard
|
||||
tier="PREMIUM"
|
||||
isPrimaryCta
|
||||
onClick={() => {
|
||||
setSelectedTier("PREMIUM");
|
||||
setIsConfirmationDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EngineTierCard
|
||||
tier="ENTERPRISE"
|
||||
previousTier="Premium Engine"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-cloud-hosted",
|
||||
tier: "ENTERPRISE",
|
||||
});
|
||||
router.push("/contact-us");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Confirmation Dialog for confirming the selected Engine Tier
|
||||
|
||||
const ConfirmEngineTierDialog = (props: {
|
||||
tier: "STARTER" | "PREMIUM";
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
open: boolean;
|
||||
}) => {
|
||||
const { tier, onConfirm, open, onOpenChange } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-snug pr-4">
|
||||
Are you sure you want to deploy a{" "}
|
||||
{tier === "STARTER" ? "Standard" : "Premium"} Engine?
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
You will be charged ${MONTHLY_PRICE_USD[tier]} per month for the
|
||||
subscription
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-5">
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} variant="primary" className="gap-2">
|
||||
<RocketIcon className="size-3" />
|
||||
Deploy now
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
Page.pageId = PageId.EngineCreate;
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page, props) => (
|
||||
<AppLayout {...props} hasSidebar={true}>
|
||||
<EngineSidebar />
|
||||
{page}
|
||||
</AppLayout>
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
CloudDownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useDashboardRouter } from "../../../@/lib/DashboardRouter";
|
||||
import { AppLayout } from "../../../components/app-layouts/app";
|
||||
import { EngineSidebar } from "../../../components/engine/EngineSidebar";
|
||||
import { THIRDWEB_API_HOST } from "../../../constants/urls";
|
||||
import { useSingleQueryParam } from "../../../hooks/useQueryParam";
|
||||
import { PageId } from "../../../page-id";
|
||||
import { FormLabel } from "../../../tw-components";
|
||||
import type { ThirdwebNextPage } from "../../../utils/types";
|
||||
|
||||
type ImportEngineInput = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const Page: ThirdwebNextPage = () => {
|
||||
const defaultUrl = useSingleQueryParam("importUrl");
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const form = useForm<ImportEngineInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
url: defaultUrl ? decodeURIComponent(defaultUrl) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ImportEngineInput) => {
|
||||
try {
|
||||
// Instance URLs should end with a /.
|
||||
const url = data.url.endsWith("/") ? data.url : `${data.url}/`;
|
||||
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/engine`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
url,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}`);
|
||||
}
|
||||
|
||||
toast.success("Engine imported successfully");
|
||||
router.push("/dashboard/engine");
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
"Error importing Engine. Please check if the details are correct.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[550px] mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Import Engine Instance
|
||||
</h1>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
Import an Engine instance hosted on your infrastructure.
|
||||
</p>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
<TrackedLinkTW
|
||||
className="p-3 rounded-lg border border-border bg-secondary flex items-center gap-2 justify-between hover:bg-accent text-sm"
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/get-started"
|
||||
target="_blank"
|
||||
category="engine"
|
||||
label="clicked-self-host-instructions"
|
||||
>
|
||||
Get help setting up Engine for free
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
|
||||
<div className="h-10" />
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter a descriptive label"
|
||||
autoFocus
|
||||
{...form.register("name", {
|
||||
required: "Name is required",
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="Enter your Engine URL"
|
||||
{...form.register("url", {
|
||||
required: "URL is required",
|
||||
})}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<CircleAlertIcon className="size-3 text-warning-foreground !static" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Do not import a URL you do not recognize{" "}
|
||||
</p>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div className="h-10" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full text-base gap-2"
|
||||
>
|
||||
<CloudDownloadIcon className="size-4" />
|
||||
Import
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Page.pageId = PageId.EngineCreate;
|
||||
|
||||
Page.getLayout = (page, props) => (
|
||||
<AppLayout {...props} hasSidebar={true}>
|
||||
<EngineSidebar />
|
||||
{page}
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
@@ -1,38 +1,16 @@
|
||||
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { AppLayout } from "components/app-layouts/app";
|
||||
import { EngineInstancesList } from "components/engine/engine-list";
|
||||
import { EngineNavigation } from "components/engine/engine-navigation";
|
||||
import { SidebarNav } from "core-ui/sidebar/nav";
|
||||
import { PageId } from "page-id";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebNextPage } from "utils/types";
|
||||
import { EngineSidebar } from "../../../components/engine/EngineSidebar";
|
||||
|
||||
const EngineManage: ThirdwebNextPage = () => {
|
||||
const [connectedInstance, setConnectedInstance] = useState<
|
||||
EngineInstance | undefined
|
||||
>();
|
||||
|
||||
if (connectedInstance) {
|
||||
return (
|
||||
// contains its own sidebarnav component
|
||||
<EngineNavigation
|
||||
instance={connectedInstance}
|
||||
setConnectedInstance={setConnectedInstance}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* just placeholder, so it doesn't look empty */}
|
||||
<SidebarNav title="Engine" />
|
||||
<EngineInstancesList setConnectedInstance={setConnectedInstance} />
|
||||
</>
|
||||
);
|
||||
return <EngineInstancesList />;
|
||||
};
|
||||
|
||||
EngineManage.getLayout = (page, props) => (
|
||||
<AppLayout {...props} hasSidebar={true}>
|
||||
<EngineSidebar />
|
||||
{page}
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
Submodule apps/hardhat-boilerplate deleted from 638fd58ec8
@@ -47,10 +47,9 @@ export default function RootLayout({
|
||||
sansFont.variable,
|
||||
monoFont.variable,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Providers>
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<div className="relative flex flex-col min-h-screen">
|
||||
<Header />
|
||||
{children}
|
||||
<div
|
||||
|
||||
@@ -26,7 +26,7 @@ Engine enables your app to:
|
||||
- Deploy and interact with smart accounts.
|
||||
- Subscribe to contract events and transactions.
|
||||
|
||||
🚀 [Get Engine hosted by thirdweb](https://thirdweb.com/dashboard/engine?requestCloudHosted) or [self-host for free](/engine/self-host).
|
||||
🚀 [Get Engine hosted by thirdweb](https://thirdweb.com/dashboard/engine/create) or [self-host for free](/engine/self-host).
|
||||
|
||||
<DocImage
|
||||
src={OverviewImage}
|
||||
@@ -78,7 +78,7 @@ Engine Premium provides added scaling and resilience. It include server autoscal
|
||||
|
||||
## Pricing
|
||||
|
||||
Cloud-hosting Engine with thirdweb starts at $99 per month. Learn more about [Engine pricing](https://thirdweb.com/dashboard/engine?requestCloudHosted).
|
||||
Cloud-hosting Engine with thirdweb starts at $99 per month. Learn more about [Engine pricing](https://thirdweb.com/dashboard/engine/create).
|
||||
|
||||
Self-hosting Engine is always free.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ dashboard](https://thirdweb.com/dashboard/engine).
|
||||
variant="info"
|
||||
>
|
||||
|
||||
[Get Engine hosted by thirdweb.](https://thirdweb.com/dashboard/engine?requestCloudHosted)
|
||||
[Get Engine hosted by thirdweb.](https://thirdweb.com/dashboard/engine/create)
|
||||
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Root domain for the app
|
||||
NEXT_PUBLIC_ROOT_DOMAIN=ecosystem.localhost:3000
|
||||
# Thirdweb client ID
|
||||
NEXT_PUBLIC_THIRDWEB_CLIENT_ID=
|
||||
# Thirdweb API key
|
||||
THIRDWEB_SECRET_KEY=
|
||||
# SimpleHash API key
|
||||
SIMPLEHASH_API_KEY=
|
||||
# Whether or not to include testnets in the UI
|
||||
NEXT_PUBLIC_INCLUDE_TESTNETS=1
|
||||
# Admin private key to generate JWT with
|
||||
THIRDWEB_ADMIN_PRIVATE_KEY=
|
||||
@@ -26,7 +26,8 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*
|
||||
.env.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.8.1/schema.json",
|
||||
"extends": ["../../biome.json"],
|
||||
"files": {
|
||||
"ignore": []
|
||||
}
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "gray",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,23 @@ const securityHeaders = [
|
||||
},
|
||||
];
|
||||
|
||||
export async function headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
webpack: (config) => {
|
||||
config.externals.push("pino-pretty", "lokijs", "encoding");
|
||||
return config;
|
||||
},
|
||||
images: {
|
||||
dangerouslyAllowSVG: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,10 +10,29 @@
|
||||
"fix": "biome check ./src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@noble/curves": "1.4.0",
|
||||
"@radix-ui/react-dialog": "1.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@tanstack/react-query": "^5.48.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"lucide-react": "^0.399.0",
|
||||
"next": "14.2.5",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"thirdweb": "workspace:*"
|
||||
"react-hook-form": "7.52.0",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.9",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,35 @@
|
||||
import ConnectButton from "@/components/ConnectButton";
|
||||
import { authedOnly } from "@/lib/auth";
|
||||
import { getEcosystemInfo } from "@/lib/ecosystems";
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { ecosystem: string };
|
||||
}) {
|
||||
await authedOnly();
|
||||
const ecosystem = await getEcosystemInfo(params.ecosystem);
|
||||
return (
|
||||
<div className="flex flex-col items-stretch w-full">
|
||||
<header className="hidden sm:block w-full py-4 border-b border-accent bg-card">
|
||||
<div className="container mx-auto flex justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
className="w-8 h-8"
|
||||
src={ecosystem.imageUrl}
|
||||
alt={ecosystem.name}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">{ecosystem.name}</h1>
|
||||
</div>
|
||||
<ConnectButton ecosystem={`ecosystem.${params.ecosystem}`} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex w-full h-full">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ChainCombobox } from "@/components/ChainCombobox";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getChains } from "@/lib/chains";
|
||||
import { client } from "@/lib/client";
|
||||
import { getEcosystemInfo } from "@/lib/ecosystems";
|
||||
import { SIMPLEHASH_NFT_SUPPORTED_CHAIN_IDS } from "@/util/simplehash";
|
||||
import type { Metadata, ResolvingMetadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { resolveName } from "thirdweb/extensions/ens";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: { ecosystem: string; address: string } },
|
||||
parent: ResolvingMetadata,
|
||||
): Promise<Metadata> {
|
||||
const [ecosystem, parentMetadata] = await Promise.all([
|
||||
getEcosystemInfo(params.ecosystem),
|
||||
parent,
|
||||
]);
|
||||
const previousImages = parentMetadata.openGraph?.images || [];
|
||||
|
||||
return {
|
||||
title: `${ecosystem.name} | ${shortenAddress(params.address)}`,
|
||||
openGraph: {
|
||||
images: [ecosystem.imageUrl, ...previousImages],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { ecosystem: string; address: string };
|
||||
}) {
|
||||
const userAddressPromise = getCurrentUser();
|
||||
const ensPromise = resolveName({
|
||||
client,
|
||||
address: params.address,
|
||||
});
|
||||
const thirdwebChainsPromise = getChains();
|
||||
|
||||
const [userAddress, ens, thirdwebChains] = await Promise.all([
|
||||
userAddressPromise,
|
||||
ensPromise,
|
||||
thirdwebChainsPromise,
|
||||
]);
|
||||
|
||||
if (userAddress !== params.address) {
|
||||
redirect(`/wallet/${userAddress}`);
|
||||
}
|
||||
|
||||
const simpleHashChains = thirdwebChains.filter((chain) =>
|
||||
SIMPLEHASH_NFT_SUPPORTED_CHAIN_IDS.includes(chain.chainId),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="mb-4 border-b pt-36 bg-card">
|
||||
<div className="container flex flex-col items-end justify-between w-full gap-4 py-4 md:flex-row bg-card">
|
||||
<h2 className="text-4xl font-bold">
|
||||
{ens || shortenAddress(params.address)}
|
||||
</h2>
|
||||
<ChainCombobox chains={simpleHashChains} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="container flex-1 py-4">
|
||||
<div className="h-full">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NftGalleryLoading } from "@/components/NftGallery";
|
||||
|
||||
export default function Loading() {
|
||||
return <NftGalleryLoading />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user