Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
942d20a5d9 | ||
|
|
739a2ae3c5 | ||
|
|
4158a796a8 | ||
|
|
a258e7eaf0 | ||
|
|
e386ac9476 | ||
|
|
0574eac02c | ||
|
|
52cbcd2d57 | ||
|
|
387891dde0 | ||
|
|
562c534d37 | ||
|
|
b882e29935 | ||
|
|
9ad1d3cbbf | ||
|
|
aab3da0729 | ||
|
|
78c9d2fe13 | ||
|
|
557d3e3dbd | ||
|
|
06d6504545 | ||
|
|
e201e3d423 | ||
|
|
c8ef941d8e | ||
|
|
39a5b5c8a3 | ||
|
|
6f381d768c | ||
|
|
0c5a51c5bf | ||
|
|
90428b4574 | ||
|
|
e24547bed1 | ||
|
|
3ff8c3fbb2 | ||
|
|
4bd03b06e7 | ||
|
|
b7158c2b54 | ||
|
|
5f26883fbd | ||
|
|
98b6198687 | ||
|
|
880148b793 | ||
|
|
ee1bc3eb07 | ||
|
|
e9ab8cb770 | ||
|
|
9c33981b72 | ||
|
|
c645b42684 | ||
|
|
7583074c06 | ||
|
|
fbafb65f5c | ||
|
|
6b286c7e83 | ||
|
|
159ffbfaee | ||
|
|
0c1bb6ccae | ||
|
|
dceb47de8b | ||
|
|
dfbdbd06e5 | ||
|
|
66bbedbe3a | ||
|
|
2fbf105d15 | ||
|
|
afcaa91873 | ||
|
|
51f955352d | ||
|
|
6b2a90ac89 | ||
|
|
cf53184bb1 | ||
|
|
815b9b1ce1 | ||
|
|
ae1baa5105 | ||
|
|
92266b7459 | ||
|
|
80ad84b803 | ||
|
|
633f0cadf1 | ||
|
|
91b0367b23 | ||
|
|
c86e13b522 | ||
|
|
bc42c2fdfb | ||
|
|
cad9c74bef | ||
|
|
6f2f92ddea | ||
|
|
97c9ab7e1c | ||
|
|
b73fa4b16f | ||
|
|
cbfe9ec459 |
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"pluralize": "^8.0.0",
|
||||
@@ -89,6 +88,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "^1.7.4",
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 956 KiB |
|
Before Width: | Height: | Size: 695 KiB |
|
Before Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 709 KiB |
|
Before Width: | Height: | Size: 906 KiB |
|
Before Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 601 KiB |
|
Before Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 733 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 333 KiB |
@@ -0,0 +1,28 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// after running logUnusedAssets.ts,
|
||||
// 1. validate the output
|
||||
// 2. If you think that a file should not be deleted, remove it from the output
|
||||
// 3. paste the output files in `filesToDelete` and run this script to delete them
|
||||
const filesToDelete: string[] = [];
|
||||
|
||||
async function deleteFiles() {
|
||||
for (const filePath of filesToDelete) {
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), filePath);
|
||||
await fs.unlink(fullPath);
|
||||
console.log(`Deleted: ${filePath}`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
console.warn(`File not found: ${filePath}`);
|
||||
} else {
|
||||
console.error(`Error deleting ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("--- DONE ---");
|
||||
}
|
||||
|
||||
deleteFiles();
|
||||
@@ -26,9 +26,6 @@ const filesToIgnore = new Set([
|
||||
// macOS stuff
|
||||
"public/assets/product-pages/.DS_Store",
|
||||
"public/assets/.DS_Store",
|
||||
// pdfs
|
||||
"public/Thirdweb_Terms_of_Service.pdf",
|
||||
"public/thirdweb_Privacy_Policy_May_2022.pdf",
|
||||
]);
|
||||
|
||||
function getAllFilesInFolder(folderPath: string) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "../constants/env";
|
||||
|
||||
export async function getGasSponsoredChains() {
|
||||
if (!THIRDWEB_API_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/chains/gas-sponsored`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-service-api-key": THIRDWEB_API_SECRET,
|
||||
},
|
||||
next: {
|
||||
revalidate: 15 * 60, //15 minutes
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to fetch gas sponsored chains",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
res.body?.cancel();
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return (await res.json()).data as number[];
|
||||
} catch (e) {
|
||||
console.error("Failed to parse gas sponsored chains", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export type Team = {
|
||||
image?: string;
|
||||
billingPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingStatus: "validPayment" | (string & {}) | null;
|
||||
supportPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingEmail: string | null;
|
||||
growthTrialEligible: false;
|
||||
enabledScopes: EnabledTeamScope[];
|
||||
@@ -47,6 +48,10 @@ export async function getTeamBySlug(slug: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTeamById(id: string) {
|
||||
return getTeamBySlug(id);
|
||||
}
|
||||
|
||||
export async function getTeams() {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const Mobile: Story = {
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 lg:p-10">
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 py-10">
|
||||
<BadgeContainer label="Base">
|
||||
<DangerSettingCard
|
||||
title="This is a title"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { AsteriskIcon, InfoIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
export function FormFieldSetup(props: {
|
||||
htmlFor?: string;
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
errorMessage: React.ReactNode | undefined;
|
||||
children: React.ReactNode;
|
||||
tooltip?: React.ReactNode;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
@@ -15,8 +22,14 @@ import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "../../../../components/analytics/empty-chart-state";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
header?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
@@ -25,78 +38,99 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
isPending: boolean;
|
||||
hideLabel?: boolean;
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
props: ThirdwebAreaChartProps<TConfig>,
|
||||
) {
|
||||
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
|
||||
return (
|
||||
<div className="rounded-lg border border-border px-4 pt-10 pb-4">
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
) : (
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={20}
|
||||
tickFormatter={(value) => formatDate(new Date(value), "MMM dd")}
|
||||
/>
|
||||
<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 />} className="pt-8" />
|
||||
)}
|
||||
</AreaChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
return (
|
||||
<Card>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
{props.header.description && (
|
||||
<CardDescription>{props.header.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent className={cn(!props.header && "pt-6")}>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={20}
|
||||
tickFormatter={(value) => formatDate(new Date(value), "MMM dd")}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel={
|
||||
props.hideLabel !== undefined ? props.hideLabel : true
|
||||
}
|
||||
labelFormatter={props.toolTipLabelFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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 className="pt-8" />}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,11 @@ import { cn } from "../../../lib/utils";
|
||||
|
||||
type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
// metadata
|
||||
title: string;
|
||||
description?: string;
|
||||
header?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
@@ -39,7 +42,6 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
isPending: boolean;
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
hideLabel?: boolean;
|
||||
titleClassName?: string;
|
||||
};
|
||||
|
||||
export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
@@ -52,15 +54,18 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("mb-2", props.titleClassName)}>
|
||||
{props.title}
|
||||
</CardTitle>
|
||||
{props.description && (
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
{props.header.description && (
|
||||
<CardDescription>{props.header.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent className={cn(!props.header && "pt-6")}>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
@@ -87,7 +92,9 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
}
|
||||
/>
|
||||
{props.showLegend && (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent className="pt-5" />}
|
||||
/>
|
||||
)}
|
||||
{configKeys.map((key, idx) => (
|
||||
<Bar
|
||||
|
||||
@@ -2,27 +2,29 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ClientOnly } from "components/ClientOnly/ClientOnly";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
export function ColorModeToggle() {
|
||||
export function ToggleThemeButton() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<ClientOnly ssr={<Skeleton className="size-10 border bg-accent" />}>
|
||||
<ClientOnly
|
||||
ssr={<Skeleton className="size-[34px] rounded-full border bg-accent" />}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
aria-label="Toggle theme"
|
||||
className="h-auto w-auto rounded-full border border-border bg-background p-2"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<Sun strokeWidth={1} className="size-5" />
|
||||
<SunIcon className="size-4" />
|
||||
) : (
|
||||
<Moon strokeWidth={1} className="size-5" />
|
||||
<MoonIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function DatePickerWithRange(props: {
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
labelOverride?: string;
|
||||
popoverAlign?: "start" | "end" | "center";
|
||||
}) {
|
||||
const [screen, setScreen] = React.useState<"from" | "to">("from");
|
||||
const { from, to, setFrom, setTo } = props;
|
||||
@@ -65,7 +66,11 @@ export function DatePickerWithRange(props: {
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Popover */}
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align={props.popoverAlign || "start"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DynamicHeight>
|
||||
<div>
|
||||
{!isValid && (
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type LinkProps = React.ComponentProps<typeof Link>;
|
||||
|
||||
export function UnderlineLink(props: LinkProps) {
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
className={cn(
|
||||
"underline decoration-muted-foreground/50 decoration-dotted underline-offset-[5px] hover:text-foreground hover:decoration-foreground hover:decoration-solid",
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
|
||||
"right-0 bottom-0 left-0",
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
|
||||
@@ -4,6 +4,8 @@ export const DASHBOARD_THIRDWEB_CLIENT_ID =
|
||||
export const DASHBOARD_THIRDWEB_SECRET_KEY =
|
||||
process.env.DASHBOARD_SECRET_KEY || "";
|
||||
|
||||
export const THIRDWEB_API_SECRET = process.env.API_SERVER_SECRET || "";
|
||||
|
||||
export const IPFS_GATEWAY_URL =
|
||||
(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL as string) ||
|
||||
"https://{clientId}.ipfscdn.io/ipfs/{cid}/{path}";
|
||||
|
||||
@@ -117,7 +117,7 @@ function DashboardRouterTopProgressBarInner() {
|
||||
const width = isLoading ? progress : 100;
|
||||
return (
|
||||
<span
|
||||
className="fixed top-0 block h-[2px] bg-blue-500"
|
||||
className="fixed top-0 block h-[3px] bg-foreground"
|
||||
style={{
|
||||
opacity: isLoading ? "100" : "0",
|
||||
width: `${width}%`,
|
||||
|
||||
@@ -125,4 +125,6 @@ export const engineKeys = {
|
||||
[...engineKeys.all, engineId, "alerts"] as const,
|
||||
notificationChannels: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "notificationChannels"] as const,
|
||||
walletCredentials: (instance: string) =>
|
||||
[...engineKeys.all, instance, "walletCredentials"] as const,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export function useDashboardContractMetadata(contract: ThirdwebContract) {
|
||||
return useQuery({
|
||||
queryKey: ["contract-metadata-header", contract.chain.id, contract.address],
|
||||
queryFn: () => fetchDashboardContractMetadata(contract),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,8 @@ type EngineFeature =
|
||||
| "CONTRACT_SUBSCRIPTIONS"
|
||||
| "IP_ALLOWLIST"
|
||||
| "HETEROGENEOUS_WALLET_TYPES"
|
||||
| "SMART_BACKEND_WALLETS";
|
||||
| "SMART_BACKEND_WALLETS"
|
||||
| "WALLET_CREDENTIALS";
|
||||
|
||||
interface EngineSystemHealth {
|
||||
status: string;
|
||||
@@ -860,6 +861,10 @@ export type SetWalletConfigInput =
|
||||
gcpKmsKeyRingId: string;
|
||||
gcpApplicationCredentialEmail: string;
|
||||
gcpApplicationCredentialPrivateKey: string;
|
||||
}
|
||||
| {
|
||||
type: "circle";
|
||||
circleApiKey: string;
|
||||
};
|
||||
|
||||
export function useEngineSetWalletConfig(params: {
|
||||
@@ -869,8 +874,8 @@ export function useEngineSetWalletConfig(params: {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<WalletConfigResponse, void, SetWalletConfigInput>({
|
||||
mutationFn: async (input) => {
|
||||
return useMutation({
|
||||
mutationFn: async (input: SetWalletConfigInput) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}configuration/wallets`, {
|
||||
@@ -884,7 +889,7 @@ export function useEngineSetWalletConfig(params: {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result;
|
||||
return json.result as WalletConfigResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
@@ -894,10 +899,17 @@ export function useEngineSetWalletConfig(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateBackendWalletInput = {
|
||||
type: EngineBackendWalletType;
|
||||
label?: string;
|
||||
};
|
||||
export type CreateBackendWalletInput =
|
||||
| {
|
||||
type: Exclude<EngineBackendWalletType, "circle" | "smart:circle">;
|
||||
label?: string;
|
||||
}
|
||||
| {
|
||||
type: "circle" | "smart:circle";
|
||||
label?: string;
|
||||
credentialId: string;
|
||||
isTestnet: boolean;
|
||||
};
|
||||
|
||||
export function useEngineCreateBackendWallet(params: {
|
||||
instanceUrl: string;
|
||||
@@ -1851,3 +1863,118 @@ export function useEngineDeleteNotificationChannel(engineId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface WalletCredential {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
isDefault: boolean | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CreateWalletCredentialInput {
|
||||
type: "circle";
|
||||
label: string;
|
||||
entitySecret?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export function useEngineWalletCredentials(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { instanceUrl, authToken, page = 1, limit = 100 } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...engineKeys.walletCredentials(instanceUrl), page, limit],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${instanceUrl}wallet-credentials?page=${page}&limit=${limit}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
return (json.result as WalletCredential[]) || [];
|
||||
},
|
||||
enabled: !!instanceUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEngineCreateWalletCredential(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateWalletCredentialInput) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}wallet-credentials`, {
|
||||
method: "POST",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result as WalletCredential;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.walletCredentials(instanceUrl),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface UpdateWalletCredentialInput {
|
||||
label?: string;
|
||||
isDefault?: boolean;
|
||||
entitySecret?: string;
|
||||
}
|
||||
|
||||
export function useEngineUpdateWalletCredential(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
...input
|
||||
}: UpdateWalletCredentialInput & { id: string }) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}wallet-credentials/${id}`, {
|
||||
method: "PUT",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result as WalletCredential;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.walletCredentials(instanceUrl),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { addContractToMultiChainRegistry } from "components/contract-components/utils";
|
||||
import { MULTICHAIN_REGISTRY_CONTRACT } from "constants/contracts";
|
||||
import {
|
||||
DASHBOARD_ENGINE_RELAYER_URL,
|
||||
DASHBOARD_FORWARDER_ADDRESS,
|
||||
} from "constants/misc";
|
||||
import type { BasicContract } from "contract-ui/types/types";
|
||||
import { getAllMultichainRegistry } from "dashboard-extensions/common/read/getAllMultichainRegistry";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useMemo } from "react";
|
||||
import { sendAndConfirmTransaction } from "thirdweb";
|
||||
import { remove } from "thirdweb/extensions/thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import invariant from "tiny-invariant";
|
||||
|
||||
export function useMultiChainRegContractList(walletAddress?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard-registry", walletAddress, "multichain-contract-list"],
|
||||
queryFn: async () => {
|
||||
invariant(walletAddress, "walletAddress is required");
|
||||
const contracts = await getAllMultichainRegistry({
|
||||
contract: MULTICHAIN_REGISTRY_CONTRACT,
|
||||
address: walletAddress,
|
||||
});
|
||||
|
||||
return contracts;
|
||||
},
|
||||
|
||||
enabled: !!walletAddress,
|
||||
});
|
||||
}
|
||||
|
||||
interface Options {
|
||||
onlyMainnet?: boolean;
|
||||
}
|
||||
|
||||
export const useAllContractList = (
|
||||
walletAddress: string | undefined,
|
||||
{ onlyMainnet }: Options = { onlyMainnet: false },
|
||||
) => {
|
||||
const multiChainQuery = useMultiChainRegContractList(walletAddress);
|
||||
|
||||
// TODO - instead of using ALL chains, fetch only the ones used here
|
||||
const { idToChain } = useAllChainsData();
|
||||
const contractList = useMemo(() => {
|
||||
const data = multiChainQuery.data || [];
|
||||
|
||||
const mainnets: BasicContract[] = [];
|
||||
const testnets: BasicContract[] = [];
|
||||
|
||||
// biome-ignore lint/complexity/noForEach: FIXME
|
||||
data.forEach((net) => {
|
||||
const chn = idToChain.get(net.chainId);
|
||||
if (chn && chn.status !== "deprecated") {
|
||||
if (chn.testnet) {
|
||||
testnets.push(net);
|
||||
} else {
|
||||
mainnets.push(net);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mainnets.sort((a, b) => a.chainId - b.chainId);
|
||||
|
||||
if (onlyMainnet) {
|
||||
return mainnets;
|
||||
}
|
||||
|
||||
testnets.sort((a, b) => a.chainId - b.chainId);
|
||||
return mainnets.concat(testnets);
|
||||
}, [multiChainQuery.data, onlyMainnet, idToChain]);
|
||||
|
||||
return {
|
||||
...multiChainQuery,
|
||||
data: contractList,
|
||||
};
|
||||
};
|
||||
|
||||
type RemoveContractParams = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
};
|
||||
|
||||
export function useRemoveContractMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const account = useActiveAccount();
|
||||
return useMutation({
|
||||
mutationFn: async (data: RemoveContractParams) => {
|
||||
invariant(data.chainId, "chainId not provided");
|
||||
invariant(account, "No wallet connected");
|
||||
const { contractAddress, chainId } = data;
|
||||
const transaction = remove({
|
||||
contract: MULTICHAIN_REGISTRY_CONTRACT,
|
||||
deployer: account.address,
|
||||
deployment: contractAddress,
|
||||
chainId: BigInt(chainId),
|
||||
});
|
||||
return await sendAndConfirmTransaction({
|
||||
transaction,
|
||||
account,
|
||||
gasless: {
|
||||
experimentalChainlessSupport: true,
|
||||
provider: "engine",
|
||||
relayerUrl: DASHBOARD_ENGINE_RELAYER_URL,
|
||||
relayerForwarderAddress: DASHBOARD_FORWARDER_ADDRESS,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["dashboard-registry"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type AddContractParams = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
};
|
||||
|
||||
export function useAddContractMutation() {
|
||||
const account = useActiveAccount();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: AddContractParams) => {
|
||||
invariant(account, "cannot add a contract without an address");
|
||||
|
||||
return await addContractToMultiChainRegistry(
|
||||
{
|
||||
address: data.contractAddress,
|
||||
chainId: data.chainId,
|
||||
},
|
||||
account,
|
||||
300000n,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["dashboard-registry"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ThirdwebClient, ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { MinimalTeamsAndProjects } from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
import { getContractPageSidebarLinks } from "../_utils/getContractPageSidebarLinks";
|
||||
@@ -13,6 +14,8 @@ export function ContractPageLayoutClient(props: {
|
||||
chainMetadata: ChainMetadata;
|
||||
contract: ThirdwebContract;
|
||||
children: React.ReactNode;
|
||||
teamsAndProjects: MinimalTeamsAndProjects | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
const headerMetadataQuery = useQuery({
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import type { DashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
|
||||
import { DeprecatedAlert } from "components/shared/DeprecatedAlert";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ThirdwebClient, ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { MinimalTeamsAndProjects } from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { ContractMetadata } from "./contract-metadata";
|
||||
import { PrimaryDashboardButton } from "./primary-dashboard-button";
|
||||
|
||||
@@ -14,6 +15,8 @@ export function ContractPageLayout(props: {
|
||||
children: React.ReactNode;
|
||||
sidebarLinks: SidebarLink[];
|
||||
dashboardContractMetadata: DashboardContractMetadata | undefined;
|
||||
client: ThirdwebClient;
|
||||
teamsAndProjects: MinimalTeamsAndProjects | undefined;
|
||||
externalLinks:
|
||||
| {
|
||||
name: string;
|
||||
@@ -27,6 +30,8 @@ export function ContractPageLayout(props: {
|
||||
sidebarLinks,
|
||||
dashboardContractMetadata,
|
||||
externalLinks,
|
||||
teamsAndProjects,
|
||||
client,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -49,6 +54,8 @@ export function ContractPageLayout(props: {
|
||||
chainSlug: chainMetadata.slug,
|
||||
contractAddress: contract.address,
|
||||
}}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
<DeprecatedAlert chain={chainMetadata} />
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EVMContractInfo } from "@3rdweb-sdk/react";
|
||||
import {
|
||||
useAddContractMutation,
|
||||
useAllContractList,
|
||||
} from "@3rdweb-sdk/react/hooks/useRegistry";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { EVMContractInfo } from "@3rdweb-sdk/react";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { CodeIcon, PlusIcon } from "lucide-react";
|
||||
import { CircleAlertIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { Chain } from "thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Chain, ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
AddToProjectSelector,
|
||||
type MinimalTeamsAndProjects,
|
||||
} from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { useAddContractToProject } from "../../../../../team/[team_slug]/[project_slug]/hooks/project-contracts";
|
||||
|
||||
const TRACKING_CATEGORY = "add_to_dashboard_upsell";
|
||||
|
||||
@@ -22,6 +34,8 @@ type AddToDashboardCardProps = {
|
||||
chain: Chain;
|
||||
contractInfo: EVMContractInfo;
|
||||
hideCodePageLink?: boolean;
|
||||
teamsAndProjects: MinimalTeamsAndProjects | undefined;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
export const PrimaryDashboardButton: React.FC<AddToDashboardCardProps> = ({
|
||||
@@ -29,40 +43,13 @@ export const PrimaryDashboardButton: React.FC<AddToDashboardCardProps> = ({
|
||||
chain,
|
||||
contractInfo,
|
||||
hideCodePageLink,
|
||||
teamsAndProjects,
|
||||
client,
|
||||
}) => {
|
||||
const addContract = useAddContractMutation();
|
||||
const trackEvent = useTrack();
|
||||
const walletAddress = useActiveAccount()?.address;
|
||||
const pathname = usePathname();
|
||||
const registry = useAllContractList(walletAddress);
|
||||
|
||||
const { onSuccess: onAddSuccess, onError: onAddError } = useTxNotifications(
|
||||
"Successfully imported",
|
||||
"Failed to import",
|
||||
);
|
||||
|
||||
const isInRegistry =
|
||||
registry.isFetched &&
|
||||
registry.data?.find(
|
||||
(c) =>
|
||||
contractAddress &&
|
||||
// compare address...
|
||||
c.address.toLowerCase() === contractAddress.toLowerCase() &&
|
||||
// ... and chainId
|
||||
c.chainId === chain.id,
|
||||
) &&
|
||||
registry.isSuccess;
|
||||
|
||||
if (
|
||||
!walletAddress ||
|
||||
!contractAddress ||
|
||||
!chain ||
|
||||
pathname?.includes("payments")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isInRegistry) {
|
||||
// if user is not logged in
|
||||
if (!teamsAndProjects) {
|
||||
if (hideCodePageLink) {
|
||||
return null;
|
||||
}
|
||||
@@ -84,54 +71,163 @@ export const PrimaryDashboardButton: React.FC<AddToDashboardCardProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!chain) {
|
||||
return;
|
||||
}
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "attempt",
|
||||
contractAddress,
|
||||
});
|
||||
addContract.mutate(
|
||||
{
|
||||
contractAddress,
|
||||
chainId: chain.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onAddSuccess();
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "success",
|
||||
contractAddress,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
onAddError(err);
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "error",
|
||||
contractAddress,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{addContract.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<PlusIcon className="size-4" />
|
||||
)}
|
||||
Import contract
|
||||
</Button>
|
||||
<AddToProjectButton
|
||||
contractAddress={contractAddress}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
chainId={chain.id.toString()}
|
||||
client={client}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function AddToProjectButton(props: {
|
||||
contractAddress: string;
|
||||
teamsAndProjects: MinimalTeamsAndProjects;
|
||||
chainId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" variant="outline">
|
||||
<PlusIcon className="size-4" />
|
||||
Add To Project
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="p-6">
|
||||
<DialogTitle>Add To Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add the contract in a project's contract list on dashboard
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddToProjectModalContent
|
||||
teamsAndProjects={props.teamsAndProjects}
|
||||
chainId={props.chainId}
|
||||
contractAddress={props.contractAddress}
|
||||
client={props.client}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AddToProjectModalContent(props: {
|
||||
teamsAndProjects: MinimalTeamsAndProjects;
|
||||
chainId: string;
|
||||
contractAddress: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const trackEvent = useTrack();
|
||||
const addContractToProject = useAddContractToProject();
|
||||
|
||||
const [importSelection, setImportSelection] = useState({
|
||||
team: props.teamsAndProjects[0]?.team,
|
||||
project: props.teamsAndProjects[0]?.projects[0],
|
||||
});
|
||||
|
||||
const selectedTeam = props.teamsAndProjects.find(
|
||||
(t) => t.team.id === importSelection.team?.id,
|
||||
);
|
||||
|
||||
function handleImport(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "attempt",
|
||||
contractAddress: props.contractAddress,
|
||||
});
|
||||
addContractToProject.mutate(
|
||||
{
|
||||
contractAddress: props.contractAddress,
|
||||
teamId: params.teamId,
|
||||
projectId: params.projectId,
|
||||
chainId: props.chainId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Contract added to the project successfully");
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "success",
|
||||
contractAddress: props.contractAddress,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.message.includes("PROJECT_CONTRACT_ALREADY_EXISTS")) {
|
||||
toast.error("Contract is already added to the project");
|
||||
} else {
|
||||
toast.error("Failed to import contract");
|
||||
}
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "error",
|
||||
contractAddress: props.contractAddress,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isImportEnabled = !!importSelection.project && !!importSelection.team;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-4 border-border border-t px-6 py-6">
|
||||
<AddToProjectSelector
|
||||
selection={importSelection}
|
||||
onSelectionChange={setImportSelection}
|
||||
teamsAndProjects={props.teamsAndProjects}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
{/* No projects alert */}
|
||||
{selectedTeam?.projects.length === 0 && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle> Selected team has no projects </AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnderlineLink
|
||||
href={`/team/${selectedTeam.team.slug}`}
|
||||
className="inline-flex items-center gap-2"
|
||||
target="_blank"
|
||||
>
|
||||
Create Project <ExternalLinkIcon className="size-3.5" />
|
||||
</UnderlineLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end border-border border-t bg-card p-6">
|
||||
<Button
|
||||
disabled={!isImportEnabled}
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
if (!importSelection.team || !importSelection.project) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleImport({
|
||||
teamId: importSelection.team.id,
|
||||
projectId: importSelection.project.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{addContractToProject.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<PlusIcon className="size-4" />
|
||||
)}
|
||||
Add to Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ export function getContractPageSidebarLinks(data: {
|
||||
hide: !data.metadata.isModularCore,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
label: "Cross Chain (Beta)",
|
||||
href: `${layoutPrefix}/cross-chain`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
label: "Code Snippets",
|
||||
href: `${layoutPrefix}/code`,
|
||||
|
||||
@@ -126,8 +126,11 @@ function UniqueWalletsChart(props: ChartProps) {
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Unique Wallets"
|
||||
description="The number of unique wallet addresses that have sent a transaction to this contract."
|
||||
header={{
|
||||
title: "Unique Wallets",
|
||||
description:
|
||||
"The number of unique wallet addresses that have sent a transaction to this contract.",
|
||||
}}
|
||||
data={analyticsQuery.data || []}
|
||||
isPending={analyticsQuery.isPending}
|
||||
config={{
|
||||
@@ -151,8 +154,11 @@ function TotalTransactionsChart(props: ChartProps) {
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Total Transactions"
|
||||
description="The number of transactions that have been sent to this contract."
|
||||
header={{
|
||||
title: "Total Transactions",
|
||||
description:
|
||||
"The number of transactions that have been sent to this contract.",
|
||||
}}
|
||||
data={analyticsQuery.data || []}
|
||||
isPending={analyticsQuery.isPending}
|
||||
config={{
|
||||
@@ -171,8 +177,11 @@ function TotalEventsChart(props: ChartProps) {
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Total Events"
|
||||
description="The number of on-chain events that have been emitted from this contract."
|
||||
header={{
|
||||
title: "Total Events",
|
||||
description:
|
||||
"The number of on-chain events that have been emitted from this contract.",
|
||||
}}
|
||||
data={analyticsQuery.data || []}
|
||||
isPending={analyticsQuery.isPending}
|
||||
config={{
|
||||
@@ -216,8 +225,11 @@ function FunctionBreakdownChart(
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Function Breakdown"
|
||||
description="The breakdown of calls to each write function from transactions."
|
||||
header={{
|
||||
title: "Function Breakdown",
|
||||
description:
|
||||
"The breakdown of calls to each write function from transactions.",
|
||||
}}
|
||||
data={mappedQueryData || []}
|
||||
isPending={analyticsQuery.isPending}
|
||||
config={Object.keys(mappedQueryData?.[0] || {}).reduce(
|
||||
@@ -269,8 +281,10 @@ function EventBreakdownChart(
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Event Breakdown"
|
||||
description="The breakdown of events emitted by this contract."
|
||||
header={{
|
||||
title: "Event Breakdown",
|
||||
description: "The breakdown of events emitted by this contract.",
|
||||
}}
|
||||
data={mappedQueryData || []}
|
||||
isPending={analyticsQuery.isPending}
|
||||
config={Object.keys(mappedQueryData?.[0] || {}).reduce(
|
||||
@@ -319,7 +333,7 @@ const AnalyticsStat: React.FC<AnalyticsStatProps> = ({
|
||||
|
||||
const AnalyticsSkeleton: React.FC<{ label: string }> = ({ label }) => {
|
||||
return (
|
||||
<Card as={Stat}>
|
||||
<Card as={Stat} className="bg-card">
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>{label}</StatLabel>
|
||||
<Skeleton isLoaded={false}>
|
||||
<StatNumber>{0}</StatNumber>
|
||||
@@ -350,7 +364,7 @@ const AnalyticsData: React.FC<AnalyticsStatProps> = ({
|
||||
}, [totalQuery.data]);
|
||||
|
||||
return (
|
||||
<Card as={Stat}>
|
||||
<Card as={Stat} className="bg-card">
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>{label}</StatLabel>
|
||||
<Skeleton isLoaded={totalQuery.isFetched}>
|
||||
<StatNumber>{data.toLocaleString()}</StatNumber>
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { verifyContract } from "app/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/ContractSourcesPage";
|
||||
import {
|
||||
type DeployModalStep,
|
||||
DeployStatusModal,
|
||||
useDeployStatusModal,
|
||||
} from "components/contract-components/contract-deploy-form/deploy-context-modal";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
defineChain,
|
||||
eth_getCode,
|
||||
getRpcClient,
|
||||
prepareTransaction,
|
||||
sendAndConfirmTransaction,
|
||||
} from "thirdweb";
|
||||
import type {
|
||||
FetchDeployMetadataResult,
|
||||
ThirdwebContract,
|
||||
} from "thirdweb/contract";
|
||||
import {
|
||||
deployContractfromDeployMetadata,
|
||||
getOrDeployInfraForPublishedContract,
|
||||
} from "thirdweb/deploys";
|
||||
import { useActiveAccount, useSwitchActiveWalletChain } from "thirdweb/react";
|
||||
import { concatHex, padHex } from "thirdweb/utils";
|
||||
import { z } from "zod";
|
||||
import { SingleNetworkSelector } from "./single-network-selector";
|
||||
|
||||
type CrossChain = {
|
||||
id: number;
|
||||
network: string;
|
||||
chainId: number;
|
||||
status: "DEPLOYED" | "NOT_DEPLOYED";
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
amounts: z.object({
|
||||
"84532": z.string(),
|
||||
"11155420": z.string(),
|
||||
"919": z.string(),
|
||||
"111557560": z.string(),
|
||||
"999999999": z.string(),
|
||||
"11155111": z.string(),
|
||||
"421614": z.string(),
|
||||
}),
|
||||
});
|
||||
type FormSchema = z.output<typeof formSchema>;
|
||||
|
||||
export function DataTable({
|
||||
data,
|
||||
coreMetadata,
|
||||
coreContract,
|
||||
modulesMetadata,
|
||||
initializeData,
|
||||
inputSalt,
|
||||
initCode,
|
||||
isDirectDeploy,
|
||||
}: {
|
||||
data: CrossChain[];
|
||||
coreMetadata: FetchDeployMetadataResult;
|
||||
coreContract: ThirdwebContract;
|
||||
modulesMetadata?: FetchDeployMetadataResult[];
|
||||
initializeData?: `0x${string}`;
|
||||
inputSalt?: `0x${string}`;
|
||||
initCode?: `0x${string}`;
|
||||
isDirectDeploy: boolean;
|
||||
}) {
|
||||
const activeAccount = useActiveAccount();
|
||||
const switchChain = useSwitchActiveWalletChain();
|
||||
const deployStatusModal = useDeployStatusModal();
|
||||
const { onError } = useTxNotifications(
|
||||
"Successfully deployed contract",
|
||||
"Failed to deploy contract",
|
||||
);
|
||||
|
||||
const addRowMutation = useMutation({
|
||||
mutationFn: async (chain: { chainId: number; name: string }) => {
|
||||
const code = await eth_getCode(
|
||||
getRpcClient({
|
||||
client: getThirdwebClient(),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(chain.chainId),
|
||||
}),
|
||||
{ address: coreContract.address },
|
||||
);
|
||||
|
||||
const newRow: CrossChain = {
|
||||
id: chain.chainId,
|
||||
network: chain.name,
|
||||
chainId: chain.chainId,
|
||||
status: code?.length > 2 ? "DEPLOYED" : "NOT_DEPLOYED",
|
||||
};
|
||||
|
||||
if (!customChainData.some((row) => row.chainId === chain.chainId)) {
|
||||
setCustomChainData((prevData) => [...prevData, newRow]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [customChainData, setCustomChainData] = useState<CrossChain[]>(() => {
|
||||
try {
|
||||
const storedData = window.localStorage.getItem(
|
||||
`crosschain-${coreContract.address}`,
|
||||
);
|
||||
return storedData ? JSON.parse(storedData) : [];
|
||||
} catch (error) {
|
||||
console.error("Failed to read from localStorage", error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`crosschain-${coreContract.address}`,
|
||||
JSON.stringify(customChainData),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to write to localStorage", error);
|
||||
}
|
||||
}, [customChainData, coreContract.address]);
|
||||
|
||||
const mergedChainData = useMemo(() => {
|
||||
const chainMap = new Map<number, CrossChain>();
|
||||
for (const item of [...data, ...customChainData]) {
|
||||
chainMap.set(item.chainId, item); // Deduplicate by chainId
|
||||
}
|
||||
return Array.from(chainMap.values());
|
||||
}, [data, customChainData]);
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
amounts: {
|
||||
"84532": "", // Base
|
||||
"11155420": "", // OP testnet
|
||||
"919": "", // Mode Network
|
||||
"111557560": "", // Cyber
|
||||
"999999999": "", // Zora
|
||||
"11155111": "", // Sepolia
|
||||
"421614": "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnDef<CrossChain>[] = [
|
||||
{
|
||||
accessorKey: "network",
|
||||
header: "Network",
|
||||
cell: ({ row }) => {
|
||||
if (row.getValue("status") === "DEPLOYED") {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
className="text-blue-500 underline"
|
||||
href={`/${row.getValue("chainId")}/${coreContract.address}`}
|
||||
>
|
||||
{row.getValue("network")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return row.getValue("network");
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "chainId",
|
||||
header: "Chain ID",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
if (row.getValue("status") === "DEPLOYED") {
|
||||
return <p>Deployed</p>;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => deployContract(row.getValue("chainId"))}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mergedChainData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const deployContract = async (chainId: number) => {
|
||||
try {
|
||||
if (!activeAccount) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(chainId);
|
||||
const client = getThirdwebClient();
|
||||
const salt =
|
||||
inputSalt || concatHex(["0x03", padHex("0x", { size: 31 })]).toString();
|
||||
|
||||
await switchChain(chain);
|
||||
|
||||
const steps: DeployModalStep[] = [
|
||||
{
|
||||
type: "deploy",
|
||||
signatureCount: 1,
|
||||
},
|
||||
];
|
||||
|
||||
deployStatusModal.setViewContractLink("");
|
||||
deployStatusModal.open(steps);
|
||||
|
||||
let crosschainContractAddress: string | undefined;
|
||||
if (initCode && isDirectDeploy) {
|
||||
const tx = prepareTransaction({
|
||||
client,
|
||||
chain,
|
||||
to: "0x4e59b44847b379578588920cA78FbF26c0B4956C",
|
||||
data: initCode,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: tx,
|
||||
account: activeAccount,
|
||||
});
|
||||
|
||||
const code = await eth_getCode(
|
||||
getRpcClient({
|
||||
client,
|
||||
chain,
|
||||
}),
|
||||
{
|
||||
address: coreContract.address,
|
||||
},
|
||||
);
|
||||
|
||||
if (code && code.length > 2) {
|
||||
crosschainContractAddress = coreContract.address;
|
||||
}
|
||||
} else {
|
||||
crosschainContractAddress = await deployContractfromDeployMetadata({
|
||||
account: activeAccount,
|
||||
chain,
|
||||
client,
|
||||
deployMetadata: coreMetadata,
|
||||
isCrosschain: true,
|
||||
initializeData,
|
||||
salt,
|
||||
});
|
||||
|
||||
verifyContract({
|
||||
address: crosschainContractAddress as `0x${string}`,
|
||||
chain,
|
||||
client,
|
||||
});
|
||||
if (modulesMetadata) {
|
||||
for (const m of modulesMetadata) {
|
||||
await getOrDeployInfraForPublishedContract({
|
||||
chain,
|
||||
client,
|
||||
account: activeAccount,
|
||||
contractId: m.name,
|
||||
publisher: m.publisher,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
deployStatusModal.nextStep();
|
||||
deployStatusModal.setViewContractLink(
|
||||
`/${chain.id}/${crosschainContractAddress}`,
|
||||
);
|
||||
deployStatusModal.close();
|
||||
|
||||
setCustomChainData((prevData) =>
|
||||
prevData.map((row) =>
|
||||
row.chainId === chainId ? { ...row, status: "DEPLOYED" } : row,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
console.error("failed to deploy contract", e);
|
||||
deployStatusModal.close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "500px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<DeployStatusModal deployStatusModal={deployStatusModal} />
|
||||
</TableContainer>
|
||||
</div>
|
||||
|
||||
{addRowMutation.isPending && (
|
||||
<div className="my-4 animate-pulse text-center text-blue-500">
|
||||
Processing...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addRowMutation.isError && (
|
||||
<div className="my-4 text-center text-red-500">
|
||||
Error while adding selected chain!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<SingleNetworkSelector
|
||||
onAddRow={addRowMutation.mutate}
|
||||
isAddingRow={addRowMutation.isPending}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function NoCrossChainPrompt() {
|
||||
return (
|
||||
<div>
|
||||
Multi-chain deployments are not available for this contract (or chain).
|
||||
<br />
|
||||
Please ensure the chain is supported or deploy a new contract to enable
|
||||
this functionality.
|
||||
<br />
|
||||
<br />
|
||||
<Link href="/explore" target="_blank" className="text-blue-500 underline">
|
||||
Explore contracts
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { fetchPublishedContractsFromDeploy } from "components/contract-components/fetchPublishedContractsFromDeploy";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
type ContractOptions,
|
||||
eth_getTransactionByHash,
|
||||
eth_getTransactionReceipt,
|
||||
getContractEvents,
|
||||
parseEventLogs,
|
||||
prepareEvent,
|
||||
} from "thirdweb";
|
||||
import { defineChain, getChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
type FetchDeployMetadataResult,
|
||||
getContract,
|
||||
getDeployedCloneFactoryContract,
|
||||
resolveContractAbi,
|
||||
} from "thirdweb/contract";
|
||||
import { moduleInstalledEvent } from "thirdweb/modules";
|
||||
import { eth_getCode, getRpcClient } from "thirdweb/rpc";
|
||||
import type { TransactionReceipt } from "thirdweb/transaction";
|
||||
import { type AbiFunction, decodeFunctionData } from "thirdweb/utils";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
import { DataTable } from "./data-table";
|
||||
import { NoCrossChainPrompt } from "./no-crosschain-prompt";
|
||||
|
||||
export function getModuleInstallParams(mod: FetchDeployMetadataResult) {
|
||||
return (
|
||||
mod.abi
|
||||
.filter((a) => a.type === "function")
|
||||
.find((f) => f.name === "encodeBytesOnInstall")?.inputs || []
|
||||
);
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
}>;
|
||||
}) {
|
||||
const ProxyDeployedEvent = prepareEvent({
|
||||
signature:
|
||||
"event ProxyDeployedV2(address indexed implementation, address indexed proxy, address indexed deployer, bytes32 inputSalt, bytes data, bytes extraData)",
|
||||
});
|
||||
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { contract } = info;
|
||||
|
||||
const isModularCore = (await getContractPageMetadata(contract)).isModularCore;
|
||||
|
||||
let twCloneFactoryContract: Readonly<
|
||||
ContractOptions<[], `0x${string}`>
|
||||
> | null = null;
|
||||
try {
|
||||
twCloneFactoryContract = await getDeployedCloneFactoryContract({
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
const originalCode = await eth_getCode(
|
||||
getRpcClient({
|
||||
client: contract.client,
|
||||
chain: contract.chain,
|
||||
}),
|
||||
{
|
||||
address: contract.address,
|
||||
},
|
||||
);
|
||||
|
||||
let initCode: `0x${string}` = "0x";
|
||||
let creationTxReceipt: TransactionReceipt | undefined;
|
||||
let isDirectDeploy = false;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://contract.thirdweb-dev.com/creation/${contract.chain.id}/${contract.address}`,
|
||||
);
|
||||
const creationData = await res.json();
|
||||
|
||||
if (creationData.status === "1" && creationData.result[0]?.txHash) {
|
||||
const rpcClient = getRpcClient({
|
||||
client: contract.client,
|
||||
chain: contract.chain,
|
||||
});
|
||||
creationTxReceipt = await eth_getTransactionReceipt(rpcClient, {
|
||||
hash: creationData.result[0]?.txHash,
|
||||
});
|
||||
|
||||
const creationTx = await eth_getTransactionByHash(rpcClient, {
|
||||
hash: creationData.result[0]?.txHash,
|
||||
});
|
||||
|
||||
initCode = creationTx.input;
|
||||
isDirectDeploy =
|
||||
creationTx.to?.toLowerCase() ===
|
||||
"0x4e59b44847b379578588920cA78FbF26c0B4956C".toLowerCase();
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
}
|
||||
|
||||
let initializeData: `0x${string}` | undefined;
|
||||
let inputSalt: `0x${string}` | undefined;
|
||||
let creationBlockNumber: bigint | undefined;
|
||||
|
||||
if (twCloneFactoryContract) {
|
||||
const events = await getContractEvents({
|
||||
contract: twCloneFactoryContract,
|
||||
events: [ProxyDeployedEvent],
|
||||
blockRange: 123456n,
|
||||
});
|
||||
const event = events.find(
|
||||
(e) =>
|
||||
e.args.proxy.toLowerCase() === params.contractAddress.toLowerCase(),
|
||||
);
|
||||
|
||||
initializeData = event?.args.data;
|
||||
inputSalt = event?.args.inputSalt;
|
||||
creationBlockNumber = event?.blockNumber;
|
||||
}
|
||||
|
||||
const defaultChains = [
|
||||
1, 137, 8453, 10, 42161, 11155111, 84532, 11155420, 421614,
|
||||
];
|
||||
|
||||
const chainsDeployedOn = (
|
||||
await Promise.all(
|
||||
defaultChains.map(async (c) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(c);
|
||||
|
||||
try {
|
||||
const chainMetadata = await getChainMetadata(chain);
|
||||
|
||||
const rpcRequest = getRpcClient({
|
||||
client: contract.client,
|
||||
chain,
|
||||
});
|
||||
const code = await eth_getCode(rpcRequest, {
|
||||
address: params.contractAddress,
|
||||
});
|
||||
|
||||
return {
|
||||
id: chain.id,
|
||||
network: chainMetadata.name,
|
||||
chainId: chain.id,
|
||||
status:
|
||||
code === originalCode
|
||||
? ("DEPLOYED" as const)
|
||||
: ("NOT_DEPLOYED" as const),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: chain.id,
|
||||
network: "",
|
||||
chainId: chain.id,
|
||||
status: "NOT_DEPLOYED" as const,
|
||||
};
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((c) => c.status === "DEPLOYED");
|
||||
|
||||
let coreMetadata: FetchDeployMetadataResult | undefined;
|
||||
try {
|
||||
coreMetadata = (
|
||||
await fetchPublishedContractsFromDeploy({
|
||||
contract,
|
||||
client: contract.client,
|
||||
})
|
||||
).at(-1) as FetchDeployMetadataResult;
|
||||
} catch {}
|
||||
|
||||
if (!coreMetadata) {
|
||||
return NoCrossChainPrompt();
|
||||
}
|
||||
|
||||
let modulesMetadata: FetchDeployMetadataResult[] | undefined;
|
||||
|
||||
if (isModularCore) {
|
||||
let modules: string[] | undefined;
|
||||
const moduleEvent = moduleInstalledEvent();
|
||||
// extract module address in ModuleInstalled events from transaction receipt
|
||||
if (creationTxReceipt) {
|
||||
const decodedEvent = parseEventLogs({
|
||||
events: [moduleEvent],
|
||||
logs: creationTxReceipt.logs,
|
||||
});
|
||||
|
||||
modules = decodedEvent.map((e) => e.args.installedModule);
|
||||
}
|
||||
|
||||
// fetch events from contract
|
||||
if (!modules && creationBlockNumber) {
|
||||
const events = await getContractEvents({
|
||||
contract: contract,
|
||||
events: [moduleEvent],
|
||||
blockRange: 123456n,
|
||||
});
|
||||
|
||||
const filteredEvents = events.filter(
|
||||
(e) => e.blockNumber === creationBlockNumber,
|
||||
);
|
||||
|
||||
modules = filteredEvents.map((e) => e.args.installedModule);
|
||||
}
|
||||
|
||||
// if receipt not available, try extracting module address from initialize data
|
||||
if (!modules && initializeData) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
const decodedData: any = await decodeFunctionData({
|
||||
contract,
|
||||
data: initializeData,
|
||||
});
|
||||
|
||||
const abi = await resolveContractAbi(contract).catch(() => undefined);
|
||||
|
||||
if (abi) {
|
||||
const initializeFunction = abi.find(
|
||||
(i: AbiFunction) => i.type === "function" && i.name === "initialize",
|
||||
) as unknown as AbiFunction;
|
||||
|
||||
const moduleIndex = initializeFunction.inputs.findIndex(
|
||||
(i) => i.name === "_modules" || i.name === "modules",
|
||||
);
|
||||
|
||||
modules = moduleIndex ? decodedData[moduleIndex] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
modulesMetadata = modules
|
||||
? ((await Promise.all(
|
||||
modules.map(async (m) =>
|
||||
(
|
||||
await fetchPublishedContractsFromDeploy({
|
||||
contract: getContract({
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
address: m,
|
||||
}),
|
||||
client: contract.client,
|
||||
})
|
||||
).at(-1),
|
||||
),
|
||||
)) as FetchDeployMetadataResult[])
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (!isDirectDeploy && !initializeData) {
|
||||
return NoCrossChainPrompt();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h2 className="mb-1 font-bold text-2xl tracking-tight">
|
||||
Deploy Cross-chain
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Deterministically deploy your contracts on multiple networks.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-10" />
|
||||
<DataTable
|
||||
coreMetadata={coreMetadata}
|
||||
modulesMetadata={modulesMetadata}
|
||||
initializeData={initializeData}
|
||||
inputSalt={inputSalt}
|
||||
data={chainsDeployedOn}
|
||||
coreContract={contract}
|
||||
initCode={initCode}
|
||||
isDirectDeploy={isDirectDeploy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
type Option = { label: string; value: string };
|
||||
|
||||
export function SingleNetworkSelector(props: {
|
||||
onAddRow: (chain: { chainId: number; name: string }) => void;
|
||||
isAddingRow: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return allChains.map((chain) => ({
|
||||
label: chain.name,
|
||||
value: String(chain.chainId),
|
||||
}));
|
||||
}, [allChains]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
const chain = idToChain.get(Number(option.value));
|
||||
if (!chain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number.parseInt(searchValue))) {
|
||||
return String(chain.chainId).startsWith(searchValue);
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
},
|
||||
[idToChain],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: Option) => {
|
||||
const chain = idToChain.get(Number(option.value));
|
||||
if (!chain) {
|
||||
return option.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
/>
|
||||
{chain.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="gap-2 max-sm:hidden">
|
||||
<span className="text-muted-foreground">Chain ID</span>
|
||||
{chain.chainId}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[idToChain],
|
||||
);
|
||||
|
||||
const handleChange = async (chainId: string) => {
|
||||
const chain = idToChain.get(Number(chainId));
|
||||
if (chain) {
|
||||
props.onAddRow({ chainId: chain.chainId, name: chain.name });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectWithSearch
|
||||
searchPlaceholder="Search by Name or Chain ID"
|
||||
value={undefined}
|
||||
options={options}
|
||||
onValueChange={handleChange}
|
||||
placeholder={
|
||||
allChains.length === 0
|
||||
? "Loading chains..."
|
||||
: "Select chain to deploy on"
|
||||
}
|
||||
overrideSearchFn={searchFn}
|
||||
renderOption={renderOption}
|
||||
className={props.className}
|
||||
disabled={props.isAddingRow || allChains.length === 0}
|
||||
closeOnSelect={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { isAddress, isContractDeployed } from "thirdweb/utils";
|
||||
import type { MinimalTeamsAndProjects } from "../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { resolveFunctionSelectors } from "../../../../../lib/selectors";
|
||||
import { shortenIfAddress } from "../../../../../utils/usedapp-external";
|
||||
import { ConfigureCustomChain } from "./_layout/ConfigureCustomChain";
|
||||
@@ -38,11 +42,16 @@ export default async function Layout(props: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const client = getThirdwebClient();
|
||||
const teamsAndProjects = await getTeamsAndProjectsIfLoggedIn();
|
||||
|
||||
if (contract.chain.id === localhost.id) {
|
||||
return (
|
||||
<ContractPageLayoutClient
|
||||
chainMetadata={chainMetadata}
|
||||
contract={contract}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
client={client}
|
||||
>
|
||||
{props.children}
|
||||
</ContractPageLayoutClient>
|
||||
@@ -73,12 +82,43 @@ export default async function Layout(props: {
|
||||
sidebarLinks={sidebarLinks}
|
||||
dashboardContractMetadata={contractMetadata}
|
||||
externalLinks={externalLinks}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
client={client}
|
||||
>
|
||||
{props.children}
|
||||
</ContractPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
async function getTeamsAndProjectsIfLoggedIn() {
|
||||
try {
|
||||
const teams = await getTeams();
|
||||
|
||||
if (!teams) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const teamsAndProjects: MinimalTeamsAndProjects = await Promise.all(
|
||||
teams.map(async (team) => ({
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
slug: team.slug,
|
||||
image: team.image,
|
||||
},
|
||||
projects: (await getProjects(team.slug)).map((x) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
return teamsAndProjects;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchPublishedContractVersions } from "components/contract-components/fetch-contracts-with-versions";
|
||||
import {
|
||||
fetchLatestPublishedContractVersion,
|
||||
fetchPublishedContractVersions,
|
||||
} from "components/contract-components/fetch-contracts-with-versions";
|
||||
import { isAddress } from "thirdweb";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { ModuleMeta } from "./install-module-params";
|
||||
@@ -14,16 +18,24 @@ export async function getModuleInstalledParams(ext: ModuleMeta) {
|
||||
client: getThirdwebClient(),
|
||||
name: ext.publisherAddress,
|
||||
});
|
||||
const allPublishedModules = await fetchPublishedContractVersions(
|
||||
publisherAddress,
|
||||
ext.moduleName,
|
||||
);
|
||||
|
||||
// find the version we want
|
||||
const publishedModule =
|
||||
ext.moduleVersion === "latest"
|
||||
? allPublishedModules[0]
|
||||
: allPublishedModules.find((v) => v.version === ext.moduleVersion);
|
||||
let publishedModule: FetchDeployMetadataResult | undefined = undefined;
|
||||
|
||||
if (ext.moduleVersion === "latest") {
|
||||
publishedModule = await fetchLatestPublishedContractVersion(
|
||||
publisherAddress,
|
||||
ext.moduleName,
|
||||
);
|
||||
} else {
|
||||
const allPublishedModules = await fetchPublishedContractVersions(
|
||||
publisherAddress,
|
||||
ext.moduleName,
|
||||
);
|
||||
|
||||
publishedModule = allPublishedModules.find(
|
||||
(v) => v.version === ext.moduleVersion,
|
||||
);
|
||||
}
|
||||
|
||||
invariant(
|
||||
publishedModule,
|
||||
|
||||
@@ -135,7 +135,7 @@ function OverviewAnalytics(props: ChartProps) {
|
||||
data={mergedData || []}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function ContractSettingsPageClient(props: {
|
||||
contract={props.contract}
|
||||
functionSelectors={metadataQuery.data.functionSelectors}
|
||||
twAccount={props.twAccount}
|
||||
hasDefaultFeeConfig={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,8 +79,9 @@ export function ContractSettingsPage(props: {
|
||||
contract: ThirdwebContract;
|
||||
functionSelectors: string[];
|
||||
twAccount: Account | undefined;
|
||||
hasDefaultFeeConfig: boolean;
|
||||
}) {
|
||||
const { functionSelectors, contract, twAccount } = props;
|
||||
const { functionSelectors, contract, twAccount, hasDefaultFeeConfig } = props;
|
||||
return (
|
||||
<ContractSettingsPageInner
|
||||
contract={contract}
|
||||
@@ -96,10 +97,13 @@ export function ContractSettingsPage(props: {
|
||||
CommonExt.isGetDefaultRoyaltyInfoSupported(functionSelectors),
|
||||
CommonExt.isSetDefaultRoyaltyInfoSupported(functionSelectors),
|
||||
].every(Boolean)}
|
||||
isPlatformFeesSupported={[
|
||||
CommonExt.isGetPlatformFeeInfoSupported(functionSelectors),
|
||||
CommonExt.isSetPlatformFeeInfoSupported(functionSelectors),
|
||||
].every(Boolean)}
|
||||
isPlatformFeesSupported={
|
||||
!hasDefaultFeeConfig &&
|
||||
[
|
||||
CommonExt.isGetPlatformFeeInfoSupported(functionSelectors),
|
||||
CommonExt.isSetPlatformFeeInfoSupported(functionSelectors),
|
||||
].every(Boolean)
|
||||
}
|
||||
twAccount={twAccount}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -132,9 +132,8 @@ export const SettingsPlatformFees = ({
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
variant="filled"
|
||||
{...form.register("platform_fee_recipient")}
|
||||
isDisabled={!address || sendAndConfirmTx.isPending}
|
||||
disabled={!address || sendAndConfirmTx.isPending}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
@@ -155,7 +154,6 @@ export const SettingsPlatformFees = ({
|
||||
>
|
||||
<FormLabel>Percentage</FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={form.watch("platform_fee_basis_points")}
|
||||
onChange={(value) =>
|
||||
form.setValue("platform_fee_basis_points", value, {
|
||||
@@ -163,7 +161,7 @@ export const SettingsPlatformFees = ({
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
isDisabled={sendAndConfirmTx.isPending}
|
||||
disabled={sendAndConfirmTx.isPending}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
|
||||
@@ -128,10 +128,9 @@ export const SettingsPrimarySale = ({
|
||||
>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
isDisabled={mutation.isPending || !address}
|
||||
disabled={mutation.isPending || !address}
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
variant="filled"
|
||||
{...form.register("primary_sale_recipient")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
|
||||
@@ -144,9 +144,8 @@ export const SettingsRoyalties = ({
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
variant="filled"
|
||||
{...form.register("fee_recipient")}
|
||||
isDisabled={!address}
|
||||
disabled={!address}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
@@ -165,7 +164,6 @@ export const SettingsRoyalties = ({
|
||||
>
|
||||
<FormLabel>Percentage</FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={form.watch("seller_fee_basis_points")}
|
||||
onChange={(value) =>
|
||||
form.setValue("seller_fee_basis_points", value, {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DEFAULT_FEE_RECIPIENT } from "constants/addresses";
|
||||
import { notFound } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getPlatformFeeInfo } from "thirdweb/extensions/common";
|
||||
import { getRawAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
@@ -33,11 +35,19 @@ export default async function Page(props: {
|
||||
getContractPageMetadata(info.contract),
|
||||
]);
|
||||
|
||||
let hasDefaultFeeConfig = true;
|
||||
try {
|
||||
const feeInfo = await getPlatformFeeInfo({ contract });
|
||||
hasDefaultFeeConfig =
|
||||
feeInfo[0].toLowerCase() === DEFAULT_FEE_RECIPIENT.toLowerCase();
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<ContractSettingsPage
|
||||
contract={info.contract}
|
||||
functionSelectors={metadata.functionSelectors}
|
||||
twAccount={account}
|
||||
hasDefaultFeeConfig={hasDefaultFeeConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import zytronCTA from "./temp-assets/zytronCTA.jpg";
|
||||
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getGasSponsoredChains } from "../../../@/api/chain";
|
||||
import type {
|
||||
ChainMetadataWithServices,
|
||||
ChainService,
|
||||
@@ -133,7 +134,6 @@ export async function getChain(
|
||||
type ExtraChainMetadata = Partial<{
|
||||
headerImgUrl: string;
|
||||
about: string;
|
||||
gasSponsored: boolean;
|
||||
cta: ChainCTAProps;
|
||||
}>;
|
||||
|
||||
@@ -167,34 +167,6 @@ const chainMetaRecord = {
|
||||
headerImgUrl: baseBanner.src,
|
||||
about:
|
||||
"Base is a secure, low-cost, builder-friendly Ethereum L2 built to bring the next billion users onchain.",
|
||||
gasSponsored: true,
|
||||
|
||||
cta: OP_CTA,
|
||||
},
|
||||
//celo Mainnet
|
||||
42220: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
// optimism
|
||||
10: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
// fraxtal
|
||||
252: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
// zora
|
||||
7777777: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
// mode
|
||||
34443: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
// cyber
|
||||
7560: {
|
||||
@@ -207,21 +179,13 @@ const chainMetaRecord = {
|
||||
buttonLink: "https://cyber.co/",
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
gasSponsored: true,
|
||||
},
|
||||
// redstone
|
||||
690: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
|
||||
// ancient8
|
||||
888888888: {
|
||||
about:
|
||||
"Ancient8 is building an ETH gaming Layer 2 built with OP Stack, offering a suite of Web3 gaming infrastructure tools that serve as the distribution and marketing channel for games globally. With Space3 Game Publishing Platform, Ancient8 Gaming Guild, Reneverse Web3 Ads engine, A8ID, and Gosu Network, Ancient8 is dedicated to onboard millions of gamers to Web3 gaming, while providing unparalleled support to game developers looking to reach more players. Ancient8’s products have helped 100+ Web3 games and 200K+ users better navigate Web3.\n\nAncient8 has raised $10M in total financing from leading investors including Pantera, Dragonfly, Hashed, Makers Fund, Mechanism, Coinbase, IOSG, Jump and Animoca.",
|
||||
// TODO: add CTA
|
||||
headerImgUrl: ancient8Banner.src,
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
// mantle
|
||||
5000: {
|
||||
@@ -233,7 +197,6 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn More",
|
||||
buttonLink: "https://x.com/0xMantleDevs",
|
||||
},
|
||||
gasSponsored: true,
|
||||
},
|
||||
// Flare
|
||||
14: {
|
||||
@@ -252,18 +215,12 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
},
|
||||
//Superseed
|
||||
5330: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
|
||||
// Ink
|
||||
57073: {
|
||||
headerImgUrl: inkBanner.src,
|
||||
about:
|
||||
"Ink is an Ethereum OP Stack layer 2 blockchain designed to be the house of DeFi for the Superchain, a powerful base layer for deploying innovative DeFi protocols.",
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
// vanar
|
||||
2040: {
|
||||
@@ -341,11 +298,6 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
},
|
||||
//Arena Z Mainnet
|
||||
7897: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
//Appchain
|
||||
466: {
|
||||
headerImgUrl: appChainBanner.src,
|
||||
@@ -399,8 +351,6 @@ const chainMetaRecord = {
|
||||
headerImgUrl: thirdwebBanner.src,
|
||||
about:
|
||||
"Monetize your passion, your way. Discover the freedom to grow with our creator-focused platform.",
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
//Etherlink Testnet
|
||||
128123: {
|
||||
@@ -554,33 +504,12 @@ const chainMetaRecord = {
|
||||
headerImgUrl: liskBanner.src,
|
||||
about:
|
||||
"Lisk is focused on serving builders in high-growth markets like Africa and Southeast Asia. When you build on Lisk, you become part of the strongest Ethereum collective, alongside Coinbase, Sony, World, Kraken and Uniswap -- the OP Superchain.",
|
||||
// cta: {
|
||||
// backgroundImageUrl: liskCTA.src,
|
||||
// title: "Ready to reshape the world?",
|
||||
// buttonLink: "https://lisk.com",
|
||||
// buttonText: "Learn more",
|
||||
// },
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
//Metal L2
|
||||
1750: {
|
||||
headerImgUrl: metalBanner.src,
|
||||
about:
|
||||
"Metal L2 is a banking-focused Layer 2 blockchain built on the Optimism Superchain, designed to enable direct on-chain fiat deposits through its connection to The Digital Banking Network—an open-source blockchain banking protocol developed by Metallicus",
|
||||
// cta: {
|
||||
// backgroundImageUrl: metalCTA.src,
|
||||
// title: "Connect to The Banking Layer, Metal L2",
|
||||
// buttonLink: "https://metall2.com/",
|
||||
// buttonText: "Connect Here",
|
||||
// },
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
//Ozean Testnet
|
||||
7849306: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
//0G-Newton-Testnet
|
||||
16600: {
|
||||
@@ -678,11 +607,6 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//Unichain Sepolis Testnet
|
||||
1301: {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
//viction Mainnet
|
||||
88: {
|
||||
headerImgUrl: victionBanner.src,
|
||||
@@ -714,14 +638,6 @@ const chainMetaRecord = {
|
||||
headerImgUrl: worldChainBanner.src,
|
||||
about:
|
||||
"World Chain is the blockchain for real humans, offering priority blockspace and gas-free transactions for World ID-verified users.",
|
||||
// cta: {
|
||||
// backgroundImageUrl: worldChainCTA.src,
|
||||
// title: "Build for real humans with World Chain",
|
||||
// buttonLink: "https://world.org/world-chain",
|
||||
// buttonText: "Learn More",
|
||||
// },
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
//World Chain Testnet
|
||||
4801: {
|
||||
@@ -848,14 +764,12 @@ const chainMetaRecord = {
|
||||
headerImgUrl: soneiumBanner.src,
|
||||
about:
|
||||
"Soneium, an Ethereum layer-2 developed by Sony Block Solutions Labs. This versatile chain is a general-purpose blockchain platform that aims to evoke emotion, empower creativity, and meet diverse needs to go mainstream. Soneium will be simplifying blockchain experiences while empowering developers, creators, and communities. Built on accessibility, scalability, and efficiency, it aims to solve real-world problems across industries globally. Soneium will change the way we interact with the internet, opening up a world of innovative applications and unlimited potential for users worldwide.",
|
||||
cta: OP_CTA,
|
||||
},
|
||||
//Soneium Mainnet
|
||||
1868: {
|
||||
headerImgUrl: soneiumBanner.src,
|
||||
about:
|
||||
"Soneium, an Ethereum layer-2 developed by Sony Block Solutions Labs. This versatile chain is a general-purpose blockchain platform that aims to evoke emotion, empower creativity, and meet diverse needs to go mainstream. Soneium will be simplifying blockchain experiences while empowering developers, creators, and communities. Built on accessibility, scalability, and efficiency, it aims to solve real-world problems across industries globally. Soneium will change the way we interact with the internet, opening up a world of innovative applications and unlimited potential for users worldwide.",
|
||||
cta: OP_CTA,
|
||||
},
|
||||
//Shido
|
||||
9008: {
|
||||
@@ -874,10 +788,23 @@ const chainMetaRecord = {
|
||||
|
||||
export async function getChainMetadata(
|
||||
chainId: number,
|
||||
): Promise<ExtraChainMetadata | null> {
|
||||
): Promise<(ExtraChainMetadata & { gasSponsored?: true }) | null> {
|
||||
const gasSponsoredChains = await getGasSponsoredChains();
|
||||
|
||||
const isGasSponsored = gasSponsoredChains.includes(chainId);
|
||||
|
||||
// TODO: fetch this from the API
|
||||
if (chainId in chainMetaRecord) {
|
||||
return chainMetaRecord[chainId as keyof typeof chainMetaRecord];
|
||||
return {
|
||||
...(isGasSponsored ? { cta: OP_CTA, gasSponsored: true } : {}),
|
||||
// this will OVERRIDE the op CTA if there is a custom one configured
|
||||
...chainMetaRecord[chainId as keyof typeof chainMetaRecord],
|
||||
};
|
||||
} else if (isGasSponsored) {
|
||||
return {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ContractPublishForm } from "components/contract-components/contract-pub
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { getPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
|
||||
import { getLatestPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
type DirectDeployPageProps = {
|
||||
params: Promise<{
|
||||
@@ -46,18 +46,12 @@ export default async function PublishContractPage(
|
||||
// - get the publish metadata with name+publisher address
|
||||
// - merge the two objects with publishMetadataFromUri taking higher precedence
|
||||
if (!publishMetadataFromUri.version) {
|
||||
const publishedContractVersions =
|
||||
await getPublishedContractsWithPublisherMapping({
|
||||
const publishedContract =
|
||||
await getLatestPublishedContractsWithPublisherMapping({
|
||||
publisher: address,
|
||||
contract_id: publishMetadataFromUri.name,
|
||||
});
|
||||
|
||||
if (!publishedContractVersions) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const publishedContract = publishedContractVersions[0];
|
||||
|
||||
if (publishedContract) {
|
||||
publishMetadata = {
|
||||
...publishedContract,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { fetchPublishedContracts } from "components/contract-components/fetchPublishedContracts";
|
||||
import { DeployedContracts } from "components/contract-components/tables/deployed-contracts";
|
||||
import { Suspense } from "react";
|
||||
import { getSortedDeployedContracts } from "../../../account/contracts/_components/getSortedDeployedContracts";
|
||||
import { ProfileHeader } from "./components/profile-header";
|
||||
import { PublishedContracts } from "./components/published-contracts";
|
||||
|
||||
@@ -32,36 +30,10 @@ export function ProfileUI(props: {
|
||||
</div>
|
||||
|
||||
<div className="h-12" />
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Deployed contracts
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
List of contracts deployed across all Mainnets
|
||||
</p>
|
||||
|
||||
<div className="h-4" />
|
||||
<Suspense fallback={<LoadingSection />}>
|
||||
<AsyncDeployedContracts profileAddress={profileAddress} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncDeployedContracts(props: {
|
||||
profileAddress: string;
|
||||
}) {
|
||||
const contracts = await getSortedDeployedContracts({
|
||||
address: props.profileAddress,
|
||||
onlyMainnet: true,
|
||||
});
|
||||
|
||||
return <DeployedContracts contractList={contracts} limit={50} />;
|
||||
}
|
||||
|
||||
async function AsyncPublishedContracts(props: {
|
||||
publisherAddress: string;
|
||||
publisherEnsName: string | undefined;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PublishedContractBreadcrumbs } from "./components/breadcrumbs.client";
|
||||
import { getPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
import { getLatestPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
type Params = { publisher: string; contract_id: string };
|
||||
|
||||
@@ -20,16 +19,11 @@ export async function generateMetadata(props: { params: Promise<Params> }) {
|
||||
const params = await props.params;
|
||||
const { publisher, contract_id } = params;
|
||||
|
||||
const publishedContracts = await getPublishedContractsWithPublisherMapping({
|
||||
publisher: publisher,
|
||||
contract_id: contract_id,
|
||||
});
|
||||
|
||||
if (!publishedContracts) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const publishedContract = publishedContracts[0];
|
||||
const publishedContract =
|
||||
await getLatestPublishedContractsWithPublisherMapping({
|
||||
publisher: publisher,
|
||||
contract_id: contract_id,
|
||||
});
|
||||
|
||||
if (!publishedContract) {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { format } from "date-fns/format";
|
||||
import { resolveEns } from "lib/ens";
|
||||
import { correctAndUniqueLicenses } from "lib/licenses";
|
||||
import { getSocialProfiles } from "thirdweb/social";
|
||||
import { getPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
import { getLatestPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
import { publishedContractOGImageTemplate } from "./utils/publishedContractOGImageTemplate";
|
||||
|
||||
export const runtime = "edge";
|
||||
@@ -22,8 +22,8 @@ export default async function Image(props: {
|
||||
const client = getThirdwebClient();
|
||||
const { publisher, contract_id } = props.params;
|
||||
|
||||
const [publishedContracts, socialProfiles] = await Promise.all([
|
||||
getPublishedContractsWithPublisherMapping({
|
||||
const [publishedContract, socialProfiles] = await Promise.all([
|
||||
getLatestPublishedContractsWithPublisherMapping({
|
||||
publisher: publisher,
|
||||
contract_id: contract_id,
|
||||
}),
|
||||
@@ -42,12 +42,6 @@ export default async function Image(props: {
|
||||
};
|
||||
})();
|
||||
|
||||
if (!publishedContracts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publishedContract = publishedContracts[0];
|
||||
|
||||
if (!publishedContract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchPublishedContractVersions } from "components/contract-components/fetch-contracts-with-versions";
|
||||
import {
|
||||
fetchLatestPublishedContractVersion,
|
||||
fetchPublishedContractVersions,
|
||||
} from "components/contract-components/fetch-contracts-with-versions";
|
||||
import { isAddress } from "thirdweb";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
|
||||
@@ -36,3 +39,24 @@ export async function getPublishedContractsWithPublisherMapping(options: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestPublishedContractsWithPublisherMapping(options: {
|
||||
publisher: string;
|
||||
contract_id: string;
|
||||
}) {
|
||||
const { publisher, contract_id } = options;
|
||||
|
||||
try {
|
||||
// resolve ENS
|
||||
const publisherAddress = isAddress(publisher)
|
||||
? publisher
|
||||
: await resolveAddress({
|
||||
client: getThirdwebClient(),
|
||||
name: mapThirdwebPublisher(publisher),
|
||||
});
|
||||
|
||||
return fetchLatestPublishedContractVersion(publisherAddress, contract_id);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { shareLink } from "@/lib/shareLink";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export function DeployActions(props: {
|
||||
publisher: string;
|
||||
contract_id: string;
|
||||
version?: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
const searchparams = useSearchParams();
|
||||
|
||||
const stringifiedSearchParams = searchparams?.toString();
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/${props.publisher}/${props.contract_id}${props.version ? `/${props.version}` : ""}${stringifiedSearchParams ? `?${stringifiedSearchParams}` : ""}`}
|
||||
target="_blank"
|
||||
>
|
||||
Contract
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
shareLink({
|
||||
title: `Deploy ${props.displayName}`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function PublishedActions(props: {
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-card"
|
||||
onClick={() => {
|
||||
shareLink({
|
||||
title: `Deploy ${props.displayName}`,
|
||||
@@ -27,7 +28,7 @@ export function PublishedActions(props: {
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
<Button asChild variant="primary" className="gap-2">
|
||||
<Button asChild className="gap-2">
|
||||
<Link
|
||||
href={`/${props.publisher}/${props.contract_id}${props.version ? `/${props.version}` : ""}/deploy${stringifiedSearchParams ? `?${stringifiedSearchParams}` : ""}`}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function DeployContractInfo(props: {
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
{contractImageLink && (
|
||||
<div className="hidden shrink-0 items-center justify-center rounded-xl border border-border p-2 md:flex">
|
||||
<div className="hidden shrink-0 items-center justify-center rounded-xl border border-border bg-card p-2 md:flex">
|
||||
{/*eslint-disable-next-line @next/next/no-img-element*/}
|
||||
<img className="size-12" alt={props.name} src={contractImageLink} />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import {
|
||||
fetchPublishedContractVersion,
|
||||
@@ -7,7 +6,6 @@ import {
|
||||
import { isAddress } from "thirdweb";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import { DeployActions } from "./contract-actions-deploy.client";
|
||||
import { DeployContractHeader } from "./contract-header";
|
||||
import { DeployFormForUri } from "./uri-based-deploy";
|
||||
|
||||
@@ -73,23 +71,17 @@ export async function DeployFormForPublishInfo(props: PublishBasedDeployProps) {
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-8 pb-20">
|
||||
<DeployContractHeader
|
||||
{...props}
|
||||
allVersions={publishedContractVersions}
|
||||
activeVersion={publishedContract}
|
||||
>
|
||||
<DeployActions
|
||||
{...props}
|
||||
displayName={publishedContract.displayName || publishedContract.name}
|
||||
/>
|
||||
</DeployContractHeader>
|
||||
<Separator />
|
||||
/>
|
||||
<DeployFormForUri
|
||||
contractMetadata={contractMetadata}
|
||||
modules={fetchedModules.filter((m) => m !== null)}
|
||||
pathname={`/${props.publisher}/${props.contract_id}${props.version ? `/${props.version}` : ""}/deploy`}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { CustomContractForm } from "components/contract-components/contract-deploy-form/custom-contract";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAddress } from "thirdweb";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
|
||||
type DeployFormForUriProps = {
|
||||
contractMetadata: FetchDeployMetadataResult | null;
|
||||
@@ -19,24 +19,35 @@ export async function DeployFormForUri(props: DeployFormForUriProps) {
|
||||
return <div>Could not fetch metadata</div>;
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const address = cookieStore.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
if (!address) {
|
||||
redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
const authCookieName = COOKIE_PREFIX_TOKEN + getAddress(address);
|
||||
const token = cookieStore.get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
const [authToken, teams] = await Promise.all([getAuthToken(), getTeams()]);
|
||||
|
||||
if (!teams || !authToken) {
|
||||
loginRedirect(pathname);
|
||||
}
|
||||
|
||||
const teamsAndProjects = await Promise.all(
|
||||
teams.map(async (team) => ({
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
slug: team.slug,
|
||||
image: team.image,
|
||||
},
|
||||
projects: (await getProjects(team.slug)).map((x) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
// TODO: remove the `ChakraProviderSetup` wrapper once the form is updated to no longer use chakra
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<CustomContractForm
|
||||
metadata={contractMetadata}
|
||||
modules={modules?.filter((m) => m !== null)}
|
||||
jwt={token}
|
||||
jwt={authToken}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
/>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ export function DeployContractVersionSelector({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-[180px] bg-transparent hover:bg-accent">
|
||||
<SelectTrigger className="min-w-[180px] bg-card hover:bg-accent">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTeamById } from "@/api/team";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
|
||||
type State = {
|
||||
success: boolean;
|
||||
@@ -71,33 +71,37 @@ export async function createTicketAction(
|
||||
_previousState: State,
|
||||
formData: FormData,
|
||||
) {
|
||||
const cookieManager = await cookies();
|
||||
const activeAccount = cookieManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookieManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
: null;
|
||||
if (!activeAccount || !token) {
|
||||
// user is not logged in, make them log in
|
||||
redirect(`/login?next=${encodeURIComponent("/support")}`);
|
||||
}
|
||||
const accountRes = await fetch(`${API_SERVER_URL}/v1/account/me`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (accountRes.status !== 200) {
|
||||
// user is not logged in, make them log in
|
||||
redirect(`/login?next=${encodeURIComponent("/support")}`);
|
||||
const teamId = formData.get("teamId")?.toString();
|
||||
|
||||
if (!teamId) {
|
||||
return {
|
||||
success: false,
|
||||
message: "teamId is required",
|
||||
};
|
||||
}
|
||||
|
||||
const account = (await accountRes.json()) as {
|
||||
data: { name: string; email: string; plan: string; id: string };
|
||||
};
|
||||
const team = await getTeamById(teamId);
|
||||
|
||||
const customerId = isValidPlan(account.data.plan)
|
||||
? planToCustomerId[account.data.plan]
|
||||
: undefined;
|
||||
if (!team) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Team with id "${teamId}" not found`,
|
||||
};
|
||||
}
|
||||
|
||||
const [walletAddress, account] = await Promise.all([
|
||||
getAuthTokenWalletAddress(),
|
||||
getRawAccount(),
|
||||
]);
|
||||
|
||||
if (!walletAddress || !account) {
|
||||
loginRedirect("/support");
|
||||
}
|
||||
|
||||
const customerId = isValidPlan(team.supportPlan)
|
||||
? planToCustomerId[team.supportPlan]
|
||||
: // fallback to "free" tier
|
||||
planToCustomerId.free;
|
||||
|
||||
const product = formData.get("product")?.toString() || "";
|
||||
const problemArea = formData.get("extraInfo_Problem_Area")?.toString() || "";
|
||||
@@ -105,8 +109,8 @@ export async function createTicketAction(
|
||||
const title = prepareEmailTitle(
|
||||
product,
|
||||
problemArea,
|
||||
account.data.email,
|
||||
account.data.name,
|
||||
account.email || "",
|
||||
account.name || "",
|
||||
);
|
||||
|
||||
const keyVal: Record<string, string> = {};
|
||||
@@ -117,10 +121,10 @@ export async function createTicketAction(
|
||||
const markdown = prepareEmailBody({
|
||||
product,
|
||||
markdownInput: keyVal.markdown || "",
|
||||
email: account.data.email,
|
||||
name: account.data.name,
|
||||
email: account.email || "",
|
||||
name: account.name || "",
|
||||
extraInfoInput: keyVal,
|
||||
walletAddress: activeAccount,
|
||||
walletAddress: walletAddress,
|
||||
});
|
||||
|
||||
const content = {
|
||||
@@ -129,9 +133,9 @@ export async function createTicketAction(
|
||||
markdown,
|
||||
status: "open",
|
||||
onBehalfOf: {
|
||||
email: account.data.email,
|
||||
name: account.data.name,
|
||||
id: account.data.id,
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
id: account.id,
|
||||
},
|
||||
customerId,
|
||||
emailInboxId: process.env.UNTHREAD_EMAIL_INBOX_ID,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SupportForm_SelectInput } from "components/help/contact-forms/shared/SupportForm_SelectInput";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { SupportForm_TeamSelection } from "../../../../../components/help/contact-forms/shared/SupportForm_TeamSelection";
|
||||
import { createTicketAction } from "./create-ticket.action";
|
||||
|
||||
const ConnectSupportForm = dynamic(
|
||||
@@ -75,14 +77,46 @@ const productOptions: { label: string; component: ReactElement }[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function CreateTicket() {
|
||||
function ProductAreaSelection(props: {
|
||||
productLabel: string;
|
||||
setProductLabel: (val: string) => void;
|
||||
}) {
|
||||
const { productLabel, setProductLabel } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SupportForm_SelectInput
|
||||
formLabel="What do you need help with?"
|
||||
name="product"
|
||||
options={productOptions.map((o) => o.label)}
|
||||
promptText="Select a product"
|
||||
onValueChange={setProductLabel}
|
||||
value={productLabel}
|
||||
required={true}
|
||||
/>
|
||||
{productOptions.find((o) => o.label === productLabel)?.component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateTicket(props: {
|
||||
teams: {
|
||||
name: string;
|
||||
id: string;
|
||||
}[];
|
||||
}) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(
|
||||
props.teams[0]?.id,
|
||||
);
|
||||
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
|
||||
const [state, formAction] = useActionState(createTicketAction, {
|
||||
message: "",
|
||||
success: false,
|
||||
});
|
||||
|
||||
// needed here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
@@ -112,16 +146,19 @@ export function CreateTicket() {
|
||||
<div className="h-7" />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<SupportForm_SelectInput
|
||||
formLabel="What do you need help with?"
|
||||
name="product"
|
||||
options={productOptions.map((o) => o.label)}
|
||||
promptText="Select a product"
|
||||
onValueChange={setProductLabel}
|
||||
value={productLabel}
|
||||
required={true}
|
||||
{/* Don't conditionally render this - it has be rendered to submit the input values */}
|
||||
<div className={cn(props.teams.length === 1 && "hidden")}>
|
||||
<SupportForm_TeamSelection
|
||||
selectedTeamId={selectedTeamId}
|
||||
onChange={(teamId) => setSelectedTeamId(teamId)}
|
||||
teams={props.teams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProductAreaSelection
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
/>
|
||||
{productOptions.find((o) => o.label === productLabel)?.component}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getTeams } from "@/api/team";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -7,17 +8,16 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { CreateTicket } from "./components/create-ticket.client";
|
||||
|
||||
export default async function Page() {
|
||||
const authToken = await getAuthToken();
|
||||
const teams = await getTeams();
|
||||
|
||||
if (!authToken) {
|
||||
return redirect(
|
||||
`/login?next=${encodeURIComponent("/support/create-ticket")}`,
|
||||
);
|
||||
const pagePath = "/support/create-ticket";
|
||||
|
||||
if (!teams || teams.length === 0) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -36,7 +36,12 @@ export default async function Page() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="container max-w-[750px] py-10">
|
||||
<CreateTicket />
|
||||
<CreateTicket
|
||||
teams={teams.map((t) => ({
|
||||
name: t.name,
|
||||
id: t.id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,7 @@ function TailwindTheme(props: { children: React.ReactNode }) {
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
defaultTheme="dark"
|
||||
enableColorScheme={true}
|
||||
>
|
||||
{props.children}
|
||||
</ThemeProvider>
|
||||
|
||||