Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4ef087ef | ||
|
|
fc2ecdfd98 | ||
|
|
33936b3a86 | ||
|
|
1133ccc239 | ||
|
|
3eef910a24 | ||
|
|
3d52dd4587 | ||
|
|
24ff6f2ab6 | ||
|
|
e9ae98084a | ||
|
|
d936d4e5b9 | ||
|
|
bc7288370c | ||
|
|
0a4e44575d | ||
|
|
f2307fde02 | ||
|
|
a4dac95630 | ||
|
|
f424d0ab2d | ||
|
|
915442e1c4 | ||
|
|
89728c6578 | ||
|
|
2707db136e | ||
|
|
ca23a95bfd | ||
|
|
997bfa84cc | ||
|
|
824b1bdb69 | ||
|
|
6e3669d384 | ||
|
|
09eb0e6a3f | ||
|
|
786ef9d777 | ||
|
|
f8e69989a3 | ||
|
|
07d191bd8b | ||
|
|
f8d3578bbc | ||
|
|
60a77e0b31 | ||
|
|
eb79810afa | ||
|
|
200e8da84b | ||
|
|
2ece661620 | ||
|
|
fb92f873cd | ||
|
|
63ada206a4 | ||
|
|
7d547a4e33 | ||
|
|
c1008a5314 | ||
|
|
11a833e1dc | ||
|
|
c67d8a9dd2 | ||
|
|
dab7b4ebd8 | ||
|
|
a491d8bd7c | ||
|
|
a7b3d0e72c | ||
|
|
b0337849a5 | ||
|
|
3229e1f03c | ||
|
|
f228e03ecc | ||
|
|
0a7448cb2b | ||
|
|
f51dd8f1ac | ||
|
|
448dc21cba | ||
|
|
2a51d3c5ae | ||
|
|
80a2eba0f4 | ||
|
|
5b48590034 | ||
|
|
8fa6227fe4 | ||
|
|
fbffa718b4 | ||
|
|
9e11c15c2d | ||
|
|
5c23e5237f | ||
|
|
a16293a10c | ||
|
|
7ff46a50d2 | ||
|
|
0c97d7965e | ||
|
|
4a87109289 | ||
|
|
dddf95daca | ||
|
|
06cd6a62d9 |
@@ -11,7 +11,6 @@ packages/thirdweb/src/react/ @joaquim-verges @gregfromstl @MananTank @jnsdls @ed
|
||||
packages/thirdweb/src/reactive/ @joaquim-verges @gregfromstl @MananTank @jnsdls
|
||||
packages/thirdweb/src/gas/ @joaquim-verges @jnsdls
|
||||
packages/thirdweb/src/pay/ @joaquim-verges @gregfromstl @MananTank @jnsdls @edwardysun
|
||||
packages/typedoc-gen/ @MananTank @jnsdls @joaquim-verges
|
||||
packages/service-utils/ @arcoraven @jnsdls @joaquim-verges
|
||||
packages/eslint-config-thirdweb/ @jnsdls @joaquim-verges
|
||||
packages/tw-tsconfig/ @jnsdls @joaquim-verges
|
||||
|
||||
@@ -93,4 +93,7 @@ UNTHREAD_GROWTH_TIER_ID=""
|
||||
UNTHREAD_PRO_TIER_ID=""
|
||||
|
||||
# Demo Engine
|
||||
NEXT_PUBLIC_DEMO_ENGINE_URL=""
|
||||
NEXT_PUBLIC_DEMO_ENGINE_URL=""
|
||||
|
||||
# API server secret (required for thirdweb.com SIWE login). Copy from Vercel.
|
||||
API_SERVER_SECRET=""
|
||||
@@ -14,6 +14,11 @@ module.exports = {
|
||||
message:
|
||||
'Are you *sure* you need to use "useEffect" here? If you loading any async function prefer using "useQuery".',
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='createContext']",
|
||||
message:
|
||||
'Are you *sure* you need to use a "Context"? In almost all cases you should prefer passing props directly.',
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='defineChain']",
|
||||
message:
|
||||
@@ -50,6 +55,8 @@ module.exports = {
|
||||
"FormErrorMessage",
|
||||
"MenuGroup",
|
||||
"MenuItem",
|
||||
"VStack",
|
||||
"HStack",
|
||||
// also the types
|
||||
"ButtonProps",
|
||||
"BadgeProps",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"next": true,
|
||||
"ignore": ["src/@/components/ui/**"],
|
||||
"project": ["src/**"],
|
||||
"ignoreBinaries": ["only-allow", "knip", "biome"],
|
||||
"ignoreBinaries": ["only-allow", "biome"],
|
||||
"ignoreDependencies": ["@storybook/blocks", "@thirdweb-dev/service-utils"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"format": "biome format ./src --write",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"gen:theme-typings": "chakra-cli tokens src/theme/index.ts",
|
||||
"postinstall": "pnpm run gen:theme-typings",
|
||||
"postbuild": "next-sitemap",
|
||||
@@ -110,21 +111,21 @@
|
||||
"@next/bundle-analyzer": "14.2.12",
|
||||
"@next/eslint-plugin-next": "14.2.12",
|
||||
"@playwright/test": "1.47.1",
|
||||
"@storybook/addon-essentials": "8.3.1",
|
||||
"@storybook/addon-interactions": "8.3.1",
|
||||
"@storybook/addon-links": "8.3.1",
|
||||
"@storybook/addon-onboarding": "8.3.1",
|
||||
"@storybook/addon-essentials": "8.3.2",
|
||||
"@storybook/addon-interactions": "8.3.2",
|
||||
"@storybook/addon-links": "8.3.2",
|
||||
"@storybook/addon-onboarding": "8.3.2",
|
||||
"@storybook/addon-viewport": "8.3.1",
|
||||
"@storybook/blocks": "8.3.1",
|
||||
"@storybook/nextjs": "8.3.1",
|
||||
"@storybook/react": "8.3.1",
|
||||
"@storybook/test": "8.3.1",
|
||||
"@storybook/react": "8.3.2",
|
||||
"@storybook/test": "8.3.2",
|
||||
"@types/color": "^3.0.6",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.7",
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
@@ -137,9 +138,10 @@
|
||||
"eslint-config-biome": "1.8.3",
|
||||
"eslint-plugin-react-compiler": "0.0.0-experimental-ca16900-20240916",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"knip": "^5.30.4",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.4.47",
|
||||
"storybook": "8.3.1",
|
||||
"storybook": "8.3.2",
|
||||
"tailwindcss": "3.4.12",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function Story() {
|
||||
description="This is a description"
|
||||
buttonLabel="Some Action"
|
||||
buttonOnClick={() => {}}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
confirmationDialog={{
|
||||
title: "This is confirmation title",
|
||||
description: "This is confirmation description",
|
||||
@@ -50,7 +50,7 @@ function Story() {
|
||||
description="This is a description"
|
||||
buttonLabel="Some Action"
|
||||
buttonOnClick={() => {}}
|
||||
isLoading={true}
|
||||
isPending={true}
|
||||
confirmationDialog={{
|
||||
title: "This is confirmation title",
|
||||
description: "This is confirmation description",
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DangerSettingCard(props: {
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonOnClick: () => void;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
confirmationDialog: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -39,9 +39,9 @@ export function DangerSettingCard(props: {
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
|
||||
disabled={props.isLoading}
|
||||
disabled={props.isPending}
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-3" />}
|
||||
{props.isPending && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -66,9 +66,9 @@ export function DangerSettingCard(props: {
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
|
||||
onClick={props.buttonOnClick}
|
||||
disabled={props.isLoading}
|
||||
disabled={props.isPending}
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-3" />}
|
||||
{props.isPending && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { AsteriskIcon, InfoIcon } from "lucide-react";
|
||||
|
||||
export function FormFieldSetup(props: {
|
||||
htmlFor: string;
|
||||
label: string;
|
||||
errorMessage: React.ReactNode | undefined;
|
||||
children: React.ReactNode;
|
||||
tooltip: React.ReactNode | undefined;
|
||||
isRequired: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 inline-flex gap-1 items-center">
|
||||
<Label htmlFor={props.htmlFor}>{props.label}</Label>
|
||||
|
||||
{props.isRequired && (
|
||||
<AsteriskIcon className="text-destructive-text size-3.5" />
|
||||
)}
|
||||
|
||||
{props.tooltip && (
|
||||
<ToolTipLabel label={props.tooltip}>
|
||||
<InfoIcon className="text-muted-foreground size-3.5" />
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
{props.children}
|
||||
{props.errorMessage && (
|
||||
<p className="text-destructive-text text-sm mt-1">
|
||||
{props.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function Story() {
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={"You do not have permission to edit this"}
|
||||
@@ -100,7 +100,7 @@ function Story() {
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
@@ -119,7 +119,7 @@ function Story() {
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: true,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
@@ -138,7 +138,7 @@ function Story() {
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isLoading: true,
|
||||
isPending: true,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function SettingsCard(props: {
|
||||
saveButton?: {
|
||||
onClick?: () => void;
|
||||
disabled: boolean;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
type?: "submit";
|
||||
};
|
||||
}) {
|
||||
@@ -59,12 +59,12 @@ export function SettingsCard(props: {
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={props.saveButton.onClick}
|
||||
disabled={props.saveButton.disabled || props.saveButton.isLoading}
|
||||
disabled={props.saveButton.disabled || props.saveButton.isPending}
|
||||
variant="outline"
|
||||
type={props.saveButton.type}
|
||||
>
|
||||
{props.saveButton.isLoading && <Spinner className="size-3" />}
|
||||
{props.saveButton.isLoading ? "Saving" : "Save"}
|
||||
{props.saveButton.isPending && <Spinner className="size-3" />}
|
||||
{props.saveButton.isPending ? "Saving" : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { MultiSelect } from "./multi-select";
|
||||
|
||||
const meta = {
|
||||
title: "blocks/MultiSelect",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function createList(len: number) {
|
||||
return Array.from({ length: len }, (_, i) => ({
|
||||
value: `${i}`,
|
||||
label: `Item ${i}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="py-6 max-w-[600px] mx-auto w-full px-4 flex flex-col gap-6">
|
||||
<VariantTest defaultValues={[]} storyLabel="5 items" listLen={5} />
|
||||
<VariantTest defaultValues={[]} storyLabel="5000 items" listLen={5000} />
|
||||
<VariantTest
|
||||
defaultValues={["1", "3", "5"]}
|
||||
storyLabel="20 items, 3 selected by default"
|
||||
listLen={20}
|
||||
/>
|
||||
<VariantTest
|
||||
defaultValues={["1", "2", "3", "4", "5", "6", "7"]}
|
||||
storyLabel="20 items, 7 selected, show max 3"
|
||||
listLen={20}
|
||||
maxCount={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VariantTest(props: {
|
||||
defaultValues: string[];
|
||||
maxCount?: number;
|
||||
storyLabel: string;
|
||||
listLen: number;
|
||||
}) {
|
||||
const list = useMemo(() => createList(props.listLen), [props.listLen]);
|
||||
const [values, setValues] = useState<string[]>(props.defaultValues);
|
||||
|
||||
return (
|
||||
<BadgeContainer label={props.storyLabel}>
|
||||
<MultiSelect
|
||||
selectedValues={values}
|
||||
options={list}
|
||||
onSelectedValuesChange={(values) => {
|
||||
setValues(values);
|
||||
}}
|
||||
placeholder="Select items"
|
||||
maxCount={props.maxCount}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, ChevronDown, SearchIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useShowMore } from "../../lib/useShowMore";
|
||||
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
interface MultiSelectProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
selectedValues: string[];
|
||||
onSelectedValuesChange: (value: string[]) => void;
|
||||
placeholder: string;
|
||||
|
||||
/**
|
||||
* Maximum number of items to display. Extra selected items will be summarized.
|
||||
* Optional, defaults to 3.
|
||||
*/
|
||||
maxCount?: number;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MultiSelect = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
MultiSelectProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
options,
|
||||
onSelectedValuesChange,
|
||||
placeholder,
|
||||
maxCount = Number.POSITIVE_INFINITY,
|
||||
className,
|
||||
selectedValues,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState("");
|
||||
|
||||
const handleInputKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
setIsPopoverOpen(true);
|
||||
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
||||
const newSelectedValues = [...selectedValues];
|
||||
newSelectedValues.pop();
|
||||
onSelectedValuesChange(newSelectedValues);
|
||||
}
|
||||
},
|
||||
[selectedValues, onSelectedValuesChange],
|
||||
);
|
||||
|
||||
const toggleOption = React.useCallback(
|
||||
(option: string) => {
|
||||
const newSelectedValues = selectedValues.includes(option)
|
||||
? selectedValues.filter((value) => value !== option)
|
||||
: [...selectedValues, option];
|
||||
onSelectedValuesChange(newSelectedValues);
|
||||
},
|
||||
[selectedValues, onSelectedValuesChange],
|
||||
);
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
onSelectedValuesChange([]);
|
||||
}, [onSelectedValuesChange]);
|
||||
|
||||
const handleTogglePopover = () => {
|
||||
setIsPopoverOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const clearExtraOptions = React.useCallback(() => {
|
||||
const newSelectedValues = selectedValues.slice(0, maxCount);
|
||||
onSelectedValuesChange(newSelectedValues);
|
||||
}, [selectedValues, onSelectedValuesChange, maxCount]);
|
||||
|
||||
// show 50 initially and then 20 more when reaching the end
|
||||
const { itemsToShow, lastItemRef } = useShowMore<HTMLButtonElement>(50, 20);
|
||||
|
||||
const optionsToShow = React.useMemo(() => {
|
||||
const searchValLowercase = searchValue.toLowerCase();
|
||||
const filteredOptions = options.filter((option) => {
|
||||
return option.label.toLowerCase().includes(searchValLowercase);
|
||||
});
|
||||
|
||||
return filteredOptions.slice(0, itemsToShow);
|
||||
}, [options, searchValue, itemsToShow]);
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
onClick={handleTogglePopover}
|
||||
className={cn(
|
||||
"flex w-full p-3 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selectedValues.length > 0 ? (
|
||||
<div className="flex justify-between w-full">
|
||||
{/* badges */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{selectedValues.slice(0, maxCount).map((value) => {
|
||||
const option = options.find((o) => o.value === value);
|
||||
return (
|
||||
<ClosableBadge
|
||||
key={value}
|
||||
label={option?.label || ""}
|
||||
onClose={() => toggleOption(value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* +X more */}
|
||||
{selectedValues.length > maxCount && (
|
||||
<ClosableBadge
|
||||
label={`+ ${selectedValues.length - maxCount} more`}
|
||||
onClose={clearExtraOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Clear All */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
{/* biome-ignore lint/a11y/useFocusableInteractive: <explanation> */}
|
||||
<div
|
||||
role="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
className="hover:bg-muted p-1 rounded"
|
||||
>
|
||||
<XIcon className="h-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="flex min-h-6 h-full"
|
||||
/>
|
||||
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{placeholder}
|
||||
</span>
|
||||
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||
style={{
|
||||
width: "var(--radix-popover-trigger-width)",
|
||||
maxHeight: "var(--radix-popover-content-available-height)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Search"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="pl-10 py-4 !h-auto focus-visible:ring-0 focus-visible:ring-offset-0 border-0 border-b border-border rounded-b-none"
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
<SearchIcon className="size-4 absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<ScrollShadow
|
||||
scrollableClassName="max-h-[350px] p-1"
|
||||
className="rounded"
|
||||
>
|
||||
{/* List */}
|
||||
<div>
|
||||
{optionsToShow.length === 0 && (
|
||||
<div className="py-10 justify-center flex">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{optionsToShow.map((option, i) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => toggleOption(option.value)}
|
||||
variant="ghost"
|
||||
className="cursor-pointer flex gap-3 py-2 px-3 w-full justify-start rounded-sm"
|
||||
ref={
|
||||
i === optionsToShow.length - 1 ? lastItemRef : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-4 items-center justify-center rounded-sm border border-foreground",
|
||||
isSelected
|
||||
? "bg-inverted text-inverted-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
</div>
|
||||
|
||||
<span>{option.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function ClosableBadge(props: {
|
||||
label: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="rounded-xl py-1 px-2 text-xs flex items-center gap-3 bg-muted border border-border text-foreground">
|
||||
{props.label}
|
||||
<XIcon
|
||||
className="h-3 w-3 cursor-pointer text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClose();
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
MultiSelect.displayName = "MultiSelect";
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { Check, Copy, ExternalLinkIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { type ThirdwebClient, isAddress } from "thirdweb";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
@@ -45,7 +46,7 @@ export function WalletAddress(props: {
|
||||
client: thirdwebClient,
|
||||
});
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { onCopy, hasCopied } = useClipboard(address, 2000);
|
||||
|
||||
if (!isAddress(address)) {
|
||||
return <span>Invalid Address ({address})</span>;
|
||||
@@ -56,16 +57,6 @@ export function WalletAddress(props: {
|
||||
return <span className="font-mono cursor-pointer">{shortenedAddress}</span>;
|
||||
}
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000); // Reset after 2 seconds
|
||||
} catch (err) {
|
||||
console.error("Failed to copy: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
@@ -102,22 +93,22 @@ export function WalletAddress(props: {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
onClick={onCopy}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isCopied ? (
|
||||
{hasCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{isCopied ? "Copied!" : "Copy"}
|
||||
{hasCopied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm font-mono bg-muted p-2 rounded text-center">
|
||||
{lessShortenedAddress}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">Social Profiles</h3>
|
||||
{profiles.isLoading ? (
|
||||
{profiles.isPending ? (
|
||||
<p className="text-sm text-muted-foreground">Loading profiles...</p>
|
||||
) : !profiles.data?.length ? (
|
||||
<p className="text-sm text-muted-foreground">No profiles found</p>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./button";
|
||||
import { ToolTipLabel } from "./tooltip";
|
||||
|
||||
@@ -12,20 +12,16 @@ export function CopyButton(props: {
|
||||
iconClassName?: string;
|
||||
variant?: "ghost" | "primary" | "secondary";
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { hasCopied, onCopy } = useClipboard(props.text, 1000);
|
||||
return (
|
||||
<ToolTipLabel label="Copy">
|
||||
<Button
|
||||
variant={props.variant || "ghost"}
|
||||
aria-label="Copy"
|
||||
className={cn("h-auto w-auto p-1", props.className)}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(props.text);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
}}
|
||||
onClick={onCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
{hasCopied ? (
|
||||
<CheckIcon
|
||||
className={cn("size-4 text-green-500", props.iconClassName)}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./button";
|
||||
import { ToolTipLabel } from "./tooltip";
|
||||
|
||||
@@ -22,8 +22,8 @@ export function CopyTextButton(props: {
|
||||
copyIconPosition: "left" | "right";
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copyButton = isCopied ? (
|
||||
const { hasCopied, onCopy } = useClipboard(props.textToCopy, 1000);
|
||||
const copyButton = hasCopied ? (
|
||||
<CheckIcon
|
||||
className={cn("size-3 text-green-500 shrink-0", props.iconClassName)}
|
||||
/>
|
||||
@@ -46,9 +46,7 @@ export function CopyTextButton(props: {
|
||||
props.className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
navigator.clipboard.writeText(props.textToCopy);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
onCopy();
|
||||
e.stopPropagation();
|
||||
props.onClick?.();
|
||||
}}
|
||||
|
||||
@@ -71,8 +71,15 @@ export function ScrollShadow(props: {
|
||||
});
|
||||
});
|
||||
content.addEventListener("scroll", handleScroll);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleScroll();
|
||||
});
|
||||
|
||||
resizeObserver.observe(content);
|
||||
return () => {
|
||||
content.removeEventListener("scroll", handleScroll);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const RadioGroupItem = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"aspect-square h-4 w-4 rounded-full border border-inverted text-inverted ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -51,12 +51,12 @@ const RadioGroupItemButton = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"cursor-pointer group flex items-center px-3 peer-hover:border-nonce py-4 space-x-3 space-y-0 border-2 rounded-md min-w-32 font-medium hover:border-foreground/25 transition-all data-[state=checked]:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"cursor-pointer group flex items-center px-3 peer-hover:border-nonce py-4 space-x-3 space-y-0 border-2 rounded-md min-w-32 font-medium hover:border-foreground/25 transition-all data-[state=checked]:border-inverted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-center w-4 h-4 border-2 rounded-full transition-all group-data-[state=checked]:border-primary text-primary group-hover:border-foreground/25 aspect-square ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<div className="flex items-center justify-center w-4 h-4 border-2 rounded-full transition-all group-data-[state=checked]:border-inverted text-inverted group-hover:border-foreground/25 aspect-square ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{/* Show on checked */}
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center transition-all rounded-full">
|
||||
<Circle className="w-2 h-2 text-current fill-current" />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {} from "@/constants/env";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
type Store<T> = {
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type Store<T> = {
|
||||
getValue(): T;
|
||||
setValue(newValue: T): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
@@ -45,3 +47,7 @@ export function createStore<T>(initialValue: T): Store<T> {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useStore<T>(store: Store<T>): T {
|
||||
return useSyncExternalStore(store.subscribe, store.getValue, store.getValue);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
/**
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useShowMore<T extends HTMLElement>(
|
||||
initialItemsToShow: number,
|
||||
itemsToAdd: number,
|
||||
) {
|
||||
// start with showing first `initialItemsToShow` items, when the last item is in view, show `itemsToAdd` more
|
||||
const [itemsToShow, setItemsToShow] = useState(initialItemsToShow);
|
||||
const lastItemRef = useCallback(
|
||||
(node: T) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setItemsToShow((prev) => prev + itemsToAdd); // show 10 more items
|
||||
}
|
||||
},
|
||||
{ threshold: 1 },
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
// when the node is removed from the DOM, observer will be disconnected automatically by the browser
|
||||
},
|
||||
[itemsToAdd],
|
||||
);
|
||||
|
||||
return { itemsToShow, lastItemRef };
|
||||
}
|
||||
@@ -140,7 +140,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Fix colors on autofilled inputs */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
/* Revert text color */
|
||||
-webkit-text-fill-color: hsl(var(--foreground)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
caret-color: hsl(var(--foreground)) !important;
|
||||
|
||||
/* Revert background color */
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
@@ -104,4 +104,11 @@ export const engineKeys = {
|
||||
[...engineKeys.all, engineId, "systemMetrics"] as const,
|
||||
queueMetrics: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "queueMetrics"] as const,
|
||||
|
||||
alertRules: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "alertRules"] as const,
|
||||
alerts: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "alerts"] as const,
|
||||
notificationChannels: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "notificationChannels"] as const,
|
||||
};
|
||||
|
||||
@@ -3,27 +3,31 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import { getSDKTheme } from "app/components/sdk-component-theme";
|
||||
import { CustomChainRenderer } from "components/selects/CustomChainRenderer";
|
||||
import { mapV4ChainToV5Chain } from "contexts/map-chains";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import {
|
||||
useSupportedChains,
|
||||
useSupportedChainsRecord,
|
||||
} from "hooks/chains/configureChains";
|
||||
import {
|
||||
useAddRecentlyUsedChainId,
|
||||
useRecentlyUsedChains,
|
||||
} from "hooks/chains/recentlyUsedChains";
|
||||
import { useSetIsNetworkConfigModalOpen } from "hooks/networkConfigModal";
|
||||
import { useTheme } from "next-themes";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { Chain } from "thirdweb";
|
||||
import { AutoConnect, ConnectButton, useConnectModal } from "thirdweb/react";
|
||||
import { useFavoriteChains } from "../../hooks/useFavoriteChains";
|
||||
import {
|
||||
AutoConnect,
|
||||
ConnectButton,
|
||||
type NetworkSelectorProps,
|
||||
useConnectModal,
|
||||
} from "thirdweb/react";
|
||||
import { useFavoriteChainIds } from "../../../../app/(dashboard)/(chain)/components/client/star-button";
|
||||
import { LazyConfigureNetworkModal } from "../../../../components/configure-networks/LazyConfigureNetworkModal";
|
||||
import { useAllChainsData } from "../../../../hooks/chains/allChains";
|
||||
import {
|
||||
type StoredChain,
|
||||
addRecentlyUsedChainId,
|
||||
recentlyUsedChainIdsStore,
|
||||
} from "../../../../stores/chainStores";
|
||||
import { useLoggedInUser } from "../../hooks/useLoggedInUser";
|
||||
import { popularChains } from "../popularChains";
|
||||
|
||||
@@ -35,51 +39,93 @@ export const CustomConnectWallet = (props: {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
const loginRequired =
|
||||
props.loginRequired === undefined ? true : props.loginRequired;
|
||||
|
||||
const { theme } = useTheme();
|
||||
const recentChainsv4 = useRecentlyUsedChains();
|
||||
const addRecentlyUsedChainId = useAddRecentlyUsedChainId();
|
||||
// const setIsNetworkConfigModalOpen = useSetIsNetworkConfigModalOpen();
|
||||
const t = theme === "light" ? "light" : "dark";
|
||||
const allv4Chains = useSupportedChains();
|
||||
const chainsRecord = useSupportedChainsRecord();
|
||||
const favChainsQuery = useFavoriteChains();
|
||||
const setIsNetworkConfigModalOpen = useSetIsNetworkConfigModalOpen();
|
||||
const allChains = useMemo(() => {
|
||||
return allv4Chains.map(mapV4ChainToV5Chain);
|
||||
}, [allv4Chains]);
|
||||
|
||||
const popularChainsWithMeta = useMemo(() => {
|
||||
return popularChains.map((x) => chainsRecord[x.id] || x);
|
||||
}, [chainsRecord]);
|
||||
// chains
|
||||
const favChainIdsQuery = useFavoriteChainIds();
|
||||
const recentChainIds = useStore(recentlyUsedChainIdsStore);
|
||||
const { idToChain, allChains } = useAllChainsData();
|
||||
|
||||
const chainSections = useMemo(() => {
|
||||
const allChainsWithMetadata = useMemo(
|
||||
() => allChains.map(mapV4ChainToV5Chain),
|
||||
[allChains],
|
||||
);
|
||||
|
||||
const recentlyUsedChainsWithMetadata = useMemo(
|
||||
() =>
|
||||
recentChainIds
|
||||
.map((id) => {
|
||||
const c = idToChain.get(id);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return c ? mapV4ChainToV5Chain(c) : undefined;
|
||||
})
|
||||
.filter((x) => !!x),
|
||||
[recentChainIds, idToChain],
|
||||
);
|
||||
|
||||
const favoriteChainsWithMetadata = useMemo(() => {
|
||||
return (favChainIdsQuery.data || [])
|
||||
.map((id) => {
|
||||
const c = idToChain.get(Number(id));
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return c ? mapV4ChainToV5Chain(c) : undefined;
|
||||
})
|
||||
.filter((x) => !!x);
|
||||
}, [idToChain, favChainIdsQuery.data]);
|
||||
|
||||
const popularChainsWithMetadata = useMemo(() => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return popularChains.map((x) =>
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
{
|
||||
const v4Chain = idToChain.get(x.id);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return v4Chain ? mapV4ChainToV5Chain(v4Chain) : x;
|
||||
},
|
||||
);
|
||||
}, [idToChain]);
|
||||
|
||||
// Network Config Modal
|
||||
const [isNetworkConfigModalOpen, setIsNetworkConfigModalOpen] =
|
||||
useState(false);
|
||||
const [editChain, setEditChain] = useState<StoredChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const chainSections: NetworkSelectorProps["sections"] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: "Favorites",
|
||||
chains: favChainsQuery.data.map(mapV4ChainToV5Chain),
|
||||
},
|
||||
{
|
||||
label: "Popular",
|
||||
chains: popularChainsWithMeta.map(mapV4ChainToV5Chain),
|
||||
chains: favoriteChainsWithMetadata,
|
||||
},
|
||||
{
|
||||
label: "Recent",
|
||||
chains: recentChainsv4.map(mapV4ChainToV5Chain),
|
||||
chains: recentlyUsedChainsWithMetadata,
|
||||
},
|
||||
{
|
||||
label: "Popular",
|
||||
chains: popularChainsWithMetadata,
|
||||
},
|
||||
];
|
||||
}, [recentChainsv4, favChainsQuery.data, popularChainsWithMeta]);
|
||||
}, [
|
||||
recentlyUsedChainsWithMetadata,
|
||||
favoriteChainsWithMetadata,
|
||||
popularChainsWithMetadata,
|
||||
]);
|
||||
|
||||
// ensures login status on pages that need it
|
||||
const { isLoading, isLoggedIn } = useLoggedInUser();
|
||||
const { isPending, isLoggedIn } = useLoggedInUser();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-[144px] h-[48px] bg-muted border border-border rounded-lg flex items-center justify-center">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{/* need autoconnect here so that we actually connect */}
|
||||
{/* need auto connect here so that we actually connect */}
|
||||
<AutoConnect client={thirdwebClient} />
|
||||
</>
|
||||
);
|
||||
@@ -95,57 +141,76 @@ export const CustomConnectWallet = (props: {
|
||||
Sign In
|
||||
</Link>
|
||||
</Button>
|
||||
{/* need autoconnect here so that we actually connect */}
|
||||
{/* need auto connect here so that we actually connect */}
|
||||
<AutoConnect client={thirdwebClient} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectButton
|
||||
theme={getSDKTheme(t)}
|
||||
client={thirdwebClient}
|
||||
connectModal={{
|
||||
privacyPolicyUrl: "/privacy",
|
||||
termsOfServiceUrl: "/tos",
|
||||
showThirdwebBranding: false,
|
||||
welcomeScreen: () => <ConnectWalletWelcomeScreen theme={t} />,
|
||||
}}
|
||||
appMetadata={{
|
||||
name: "thirdweb",
|
||||
logoUrl: "https://thirdweb.com/favicon.ico",
|
||||
url: "https://thirdweb.com",
|
||||
}}
|
||||
onDisconnect={async () => {
|
||||
try {
|
||||
// log out the user
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to log out", err);
|
||||
}
|
||||
}}
|
||||
connectButton={{
|
||||
className: props.connectButtonClassName,
|
||||
}}
|
||||
detailsButton={{
|
||||
className: props.detailsButtonClassName,
|
||||
}}
|
||||
chains={allChains}
|
||||
detailsModal={{
|
||||
networkSelector: {
|
||||
sections: chainSections,
|
||||
onSwitch(chain) {
|
||||
addRecentlyUsedChainId(chain.id);
|
||||
<>
|
||||
<ConnectButton
|
||||
theme={getSDKTheme(t)}
|
||||
client={thirdwebClient}
|
||||
connectModal={{
|
||||
privacyPolicyUrl: "/privacy",
|
||||
termsOfServiceUrl: "/tos",
|
||||
showThirdwebBranding: false,
|
||||
welcomeScreen: () => <ConnectWalletWelcomeScreen theme={t} />,
|
||||
}}
|
||||
appMetadata={{
|
||||
name: "thirdweb",
|
||||
logoUrl: "https://thirdweb.com/favicon.ico",
|
||||
url: "https://thirdweb.com",
|
||||
}}
|
||||
onDisconnect={async () => {
|
||||
try {
|
||||
// log out the user
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to log out", err);
|
||||
}
|
||||
}}
|
||||
connectButton={{
|
||||
className: props.connectButtonClassName,
|
||||
}}
|
||||
detailsButton={{
|
||||
className: props.detailsButtonClassName,
|
||||
}}
|
||||
chains={allChainsWithMetadata}
|
||||
detailsModal={{
|
||||
networkSelector: {
|
||||
sections: chainSections,
|
||||
onSwitch(chain) {
|
||||
addRecentlyUsedChainId(chain.id);
|
||||
},
|
||||
renderChain(props) {
|
||||
return (
|
||||
<CustomChainRenderer
|
||||
{...props}
|
||||
openEditChainModal={(c) => {
|
||||
setIsNetworkConfigModalOpen(true);
|
||||
setEditChain(c);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
onCustomClick: () => {
|
||||
setEditChain(undefined);
|
||||
setIsNetworkConfigModalOpen(true);
|
||||
},
|
||||
},
|
||||
renderChain: CustomChainRenderer,
|
||||
onCustomClick: () => {
|
||||
setIsNetworkConfigModalOpen(true);
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
|
||||
<LazyConfigureNetworkModal
|
||||
open={isNetworkConfigModalOpen}
|
||||
onOpenChange={setIsNetworkConfigModalOpen}
|
||||
editChain={editChain}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -17,10 +17,14 @@ export type EVMContractInfo = {
|
||||
|
||||
type SetEVMContractInfo = (info: EVMContractInfo) => void;
|
||||
|
||||
// Legacy: will be removed with contract page -> app router refactor
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const EVMContractInfoContext = createContext<EVMContractInfo | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Legacy: will be removed with contract page -> app router refactor
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const SetEVMContractInfoContext = createContext<SetEVMContractInfo | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { THIRDWEB_ANALYTICS_API_HOST, THIRDWEB_API_HOST } from "constants/urls";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import invariant from "tiny-invariant";
|
||||
import { accountKeys, apiKeys, authorizedWallets } from "../cache-keys";
|
||||
import { useMutationWithInvalidate } from "./query/useQueryWithNetwork";
|
||||
@@ -947,28 +946,3 @@ export function useAuthorizedWallets() {
|
||||
gcTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function fetchChainsFromApi() {
|
||||
// always fetch from prod for chains for now
|
||||
// TODO: re-visit this
|
||||
const res = await fetch("https://api.thirdweb.com/v1/chains");
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data as ChainMetadata[];
|
||||
}
|
||||
|
||||
export function useApiChains() {
|
||||
return useQuery({
|
||||
queryKey: ["all-chains"],
|
||||
queryFn: () => {
|
||||
return fetchChainsFromApi();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1539,3 +1539,177 @@ export function useEngineSystemMetrics(engineId: string) {
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export interface EngineAlertRule {
|
||||
id: string;
|
||||
title: string;
|
||||
// A unique string identifying the alert.
|
||||
// Note the "." which allows `subscriptionRoutes` to use wildcards in notificationChannel.
|
||||
// Example: "alert.sla-5xx-99"
|
||||
routingKey: string;
|
||||
description: string;
|
||||
createdAt: Date;
|
||||
pausedAt: Date | null;
|
||||
}
|
||||
|
||||
export function useEngineAlertRules(engineId: string) {
|
||||
const { isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: engineKeys.alertRules(engineId),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/${engineId}/alert-rules`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as EngineAlertRule[];
|
||||
},
|
||||
enabled: isLoggedIn,
|
||||
});
|
||||
}
|
||||
|
||||
export interface EngineAlert {
|
||||
id: string;
|
||||
alertRuleId: string;
|
||||
status: "pending" | "firing" | "resolved";
|
||||
startsAt: Date;
|
||||
endsAt: Date | null;
|
||||
}
|
||||
|
||||
export function useEngineAlerts(engineId: string, limit: number, offset = 0) {
|
||||
const { isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: engineKeys.alerts(engineId),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/${engineId}/alerts?limit=${limit}&offset=${offset}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as EngineAlert[];
|
||||
},
|
||||
enabled: isLoggedIn,
|
||||
});
|
||||
}
|
||||
|
||||
export const EngineNotificationChannelTypeConfig = {
|
||||
slack: {
|
||||
display: "Slack",
|
||||
valueDisplay: "Slack Webhook URL",
|
||||
},
|
||||
email: {
|
||||
display: "Email",
|
||||
valueDisplay: "Email Address",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type EngineNotificationChannelType =
|
||||
keyof typeof EngineNotificationChannelTypeConfig;
|
||||
|
||||
export type EngineNotificationChannel = {
|
||||
id: string;
|
||||
type: EngineNotificationChannelType;
|
||||
value: string;
|
||||
createdAt: Date;
|
||||
pausedAt: Date | null;
|
||||
// A list of routingKeys to listen to. Supports wildcards.
|
||||
// Example: [ 'alert.sla-5xx-99' ] or [ 'alert.*' ] will both notify when
|
||||
// the alert with routingKey `alert.sla-5xx-99` triggers.
|
||||
subscriptionRoutes: string[];
|
||||
};
|
||||
|
||||
export function useEngineNotificationChannels(engineId: string) {
|
||||
const { isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: engineKeys.notificationChannels(engineId),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/${engineId}/notification-channels`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as EngineNotificationChannel[];
|
||||
},
|
||||
enabled: isLoggedIn,
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateNotificationChannelInput {
|
||||
subscriptionRoutes: string[];
|
||||
type: "slack" | "email"; // TODO: Add others when implemented.
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useEngineCreateNotificationChannel(engineId: string) {
|
||||
const { isLoggedIn } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutationWithInvalidate(
|
||||
async (input: CreateNotificationChannelInput) => {
|
||||
invariant(isLoggedIn, "Must be logged in.");
|
||||
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/${engineId}/notification-channels`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as EngineNotificationChannel;
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.notificationChannels(engineId),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useEngineDeleteNotificationChannel(engineId: string) {
|
||||
const { isLoggedIn } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutationWithInvalidate(
|
||||
async (notificationChannelId: string) => {
|
||||
invariant(isLoggedIn, "Must be logged in.");
|
||||
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/${engineId}/notification-channels/${notificationChannelId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
res.body?.cancel();
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.notificationChannels(engineId),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useFavouriteChainIds } from "app/(dashboard)/(chain)/components/client/star-button";
|
||||
import { useSupportedChains } from "hooks/chains/configureChains";
|
||||
import { useMemo } from "react";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
|
||||
export function useFavoriteChains() {
|
||||
const allChains = useSupportedChains();
|
||||
const favChainsQuery = useFavouriteChainIds();
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (favChainsQuery.data) {
|
||||
const _chains: ChainMetadata[] = [];
|
||||
// biome-ignore lint/complexity/noForEach: FIXME
|
||||
favChainsQuery.data.forEach((chainId) => {
|
||||
const chain = allChains.find((c) => String(c.chainId) === chainId);
|
||||
if (chain) {
|
||||
_chains.push(chain);
|
||||
}
|
||||
});
|
||||
|
||||
return _chains;
|
||||
}
|
||||
|
||||
return [] as ChainMetadata[];
|
||||
}, [favChainsQuery.data, allChains]);
|
||||
|
||||
return {
|
||||
isLoading: favChainsQuery.isLoading,
|
||||
data,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ declare global {
|
||||
}
|
||||
|
||||
export function useLoggedInUser(): {
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isLoggedIn: boolean;
|
||||
user: { address: string; jwt?: string } | null;
|
||||
} {
|
||||
@@ -82,7 +82,7 @@ export function useLoggedInUser(): {
|
||||
// if we are "disconnected" we are not logged in
|
||||
if (connectionStatus === "disconnected") {
|
||||
return {
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
};
|
||||
@@ -91,7 +91,7 @@ export function useLoggedInUser(): {
|
||||
// if we are still connecting, we are "loading"
|
||||
if (connectionStatus === "connecting") {
|
||||
return {
|
||||
isLoading: true,
|
||||
isPending: true,
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
};
|
||||
@@ -100,7 +100,7 @@ export function useLoggedInUser(): {
|
||||
// same if we do not yet have a path
|
||||
if (!pathname) {
|
||||
return {
|
||||
isLoading: true,
|
||||
isPending: true,
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
};
|
||||
@@ -109,7 +109,7 @@ export function useLoggedInUser(): {
|
||||
// if we do not have an address we are not logged in
|
||||
if (!connectedAddress) {
|
||||
return {
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
};
|
||||
@@ -118,7 +118,7 @@ export function useLoggedInUser(): {
|
||||
// if we are not on a logged in path, we can simply return the connected address
|
||||
if (!isLoginRequired(pathname)) {
|
||||
return {
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isLoggedIn: true,
|
||||
user: { address: connectedAddress },
|
||||
};
|
||||
@@ -126,7 +126,7 @@ export function useLoggedInUser(): {
|
||||
|
||||
// otherwise we return the query data
|
||||
return {
|
||||
isLoading: query.isLoading,
|
||||
isPending: query.isPending,
|
||||
isLoggedIn: query.data ? query.data.isLoggedIn : false,
|
||||
user: query.data?.isLoggedIn
|
||||
? { address: connectedAddress, jwt: query.data.jwt }
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import type { BasicContract } from "contract-ui/types/types";
|
||||
import { getAllMultichainRegistry } from "dashboard-extensions/common/read/getAllMultichainRegistry";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useSupportedChainsRecord } from "hooks/chains/configureChains";
|
||||
import { useMemo } from "react";
|
||||
import { sendAndConfirmTransaction } from "thirdweb";
|
||||
import { remove } from "thirdweb/extensions/thirdweb";
|
||||
@@ -42,7 +41,8 @@ export const useAllContractList = (
|
||||
) => {
|
||||
const multiChainQuery = useMultiChainRegContractList(walletAddress);
|
||||
|
||||
const configuredChainsRecord = useSupportedChainsRecord();
|
||||
// TODO - instead of using ALL chains, fetch only the ones used here
|
||||
const { idToChain } = useAllChainsData();
|
||||
const contractList = useMemo(() => {
|
||||
const data = multiChainQuery.data || [];
|
||||
|
||||
@@ -51,14 +51,12 @@ export const useAllContractList = (
|
||||
|
||||
// biome-ignore lint/complexity/noForEach: FIXME
|
||||
data.forEach((net) => {
|
||||
if (net.chainId in configuredChainsRecord) {
|
||||
const chainRecord = configuredChainsRecord[net.chainId];
|
||||
if (chainRecord.status !== "deprecated") {
|
||||
if (chainRecord.testnet) {
|
||||
testnets.push(net);
|
||||
} else {
|
||||
mainnets.push(net);
|
||||
}
|
||||
const chn = idToChain.get(net.chainId);
|
||||
if (chn && chn.status !== "deprecated") {
|
||||
if (chn.testnet) {
|
||||
testnets.push(net);
|
||||
} else {
|
||||
mainnets.push(net);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -71,7 +69,7 @@ export const useAllContractList = (
|
||||
|
||||
testnets.sort((a, b) => a.chainId - b.chainId);
|
||||
return mainnets.concat(testnets);
|
||||
}, [multiChainQuery.data, onlyMainnet, configuredChainsRecord]);
|
||||
}, [multiChainQuery.data, onlyMainnet, idToChain]);
|
||||
|
||||
return {
|
||||
...multiChainQuery,
|
||||
@@ -85,12 +83,10 @@ type RemoveContractParams = {
|
||||
};
|
||||
|
||||
export function useRemoveContractMutation() {
|
||||
const { chainIdToChainRecord } = useAllChainsData();
|
||||
const queryClient = useQueryClient();
|
||||
const account = useActiveAccount();
|
||||
return useMutation({
|
||||
mutationFn: async (data: RemoveContractParams) => {
|
||||
invariant(chainIdToChainRecord, "chains not initialzed yet");
|
||||
invariant(data.chainId, "chainId not provided");
|
||||
invariant(account, "No wallet connected");
|
||||
const { contractAddress, chainId } = data;
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ export function FaucetButton({
|
||||
faucetWalletBalanceQuery.data.value < toUnits("1", 17);
|
||||
|
||||
// loading state
|
||||
if (faucetWalletBalanceQuery.isLoading || canClaimFaucetQuery.isLoading) {
|
||||
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2">
|
||||
Checking Faucet <Spinner className="size-3" />
|
||||
|
||||
@@ -42,7 +42,7 @@ async function removeChainFromFavorites(chainId: number) {
|
||||
return result?.data?.favorite;
|
||||
}
|
||||
|
||||
export function useFavouriteChainIds() {
|
||||
export function useFavoriteChainIds() {
|
||||
const loggedInUser = useLoggedInUser();
|
||||
return useQuery({
|
||||
queryKey: ["favoriteChains", loggedInUser.user?.address],
|
||||
@@ -59,7 +59,7 @@ export function StarButton(props: {
|
||||
}) {
|
||||
const loggedInUser = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
const favChainsQuery = useFavouriteChainIds();
|
||||
const favChainsQuery = useFavoriteChainIds();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (preferred: boolean) => {
|
||||
@@ -92,7 +92,7 @@ export function StarButton(props: {
|
||||
disabled={
|
||||
!loggedInUser.user?.address ||
|
||||
mutation.isPending ||
|
||||
favChainsQuery.isLoading
|
||||
favChainsQuery.isPending
|
||||
}
|
||||
>
|
||||
<ToolTipLabel label={label}>
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { Ecosystem } from "../../../../types";
|
||||
import { AddPartnerForm } from "./add-partner-form.client";
|
||||
|
||||
export function AddPartnerDialogButton(props: {
|
||||
ecosystem: Ecosystem;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 max-sm:w-full">
|
||||
<PlusIcon className="size-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-2">
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight">
|
||||
Add Partner
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddPartnerForm
|
||||
ecosystem={props.ecosystem}
|
||||
onPartnerAdded={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+30
-90
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -6,17 +7,10 @@ import {
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -26,14 +20,17 @@ import type { Ecosystem } from "../../../../types";
|
||||
import { partnerFormSchema } from "../../constants";
|
||||
import { useAddPartner } from "../../hooks/use-add-partner";
|
||||
|
||||
export function AddPartnerForm({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
export function AddPartnerForm({
|
||||
ecosystem,
|
||||
onPartnerAdded,
|
||||
}: { ecosystem: Ecosystem; onPartnerAdded: () => void }) {
|
||||
const form = useForm<z.input<typeof partnerFormSchema>>({
|
||||
resolver: zodResolver(partnerFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: addPartner, isPending } = useAddPartner({
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onPartnerAdded();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
@@ -57,18 +54,18 @@ export function AddPartnerForm({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
permissions: [values.permissions],
|
||||
});
|
||||
})}
|
||||
className="flex flex-col gap-2 lg:flex-row"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="grid gap-2 lg:grid-cols-12 grow">
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 lg:col-span-3">
|
||||
<FormItem>
|
||||
<FormLabel> App Name </FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="App name"
|
||||
@@ -94,22 +91,16 @@ export function AddPartnerForm({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
name="domains"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 lg:col-span-4">
|
||||
<FormItem>
|
||||
<FormLabel> Domains </FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
<Input placeholder="Domains" className="peer" {...field} />
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"hidden text-xs transition-all lg:block lg:-translate-y-4 lg:opacity-0 peer-focus-visible:opacity-100 peer-focus-visible:translate-y-0",
|
||||
form.formState.errors.domains?.message &&
|
||||
"text-destructive lg:translate-y-0 lg:opacity-100 block", // If there are errors show them rather than the tip
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.domains?.message ??
|
||||
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
|
||||
</FormDescription>
|
||||
</>
|
||||
<Input placeholder="Domains" className="peer" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Space or comma-separated list of regex domains (e.g.
|
||||
*.example.com)
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -118,75 +109,24 @@ export function AddPartnerForm({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
name="bundleIds"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 lg:col-span-3">
|
||||
<FormItem>
|
||||
<FormLabel> Bundle ID </FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
<Input
|
||||
placeholder="Bundle ID"
|
||||
className="peer"
|
||||
{...field}
|
||||
/>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"hidden text-xs transition-all lg:block lg:-translate-y-4 lg:opacity-0 peer-focus-visible:opacity-100 peer-focus-visible:translate-y-0",
|
||||
form.formState.errors.bundleIds?.message &&
|
||||
"text-destructive translate-y-0 opacity-100 block",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.bundleIds?.message ??
|
||||
"Space or comma-separated list of bundle IDs"}
|
||||
</FormDescription>
|
||||
</>
|
||||
<Input placeholder="Bundle ID" className="peer" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Space or comma-separated list of bundle IDs
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permissions"
|
||||
defaultValue="PROMPT_USER_V1" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 lg:col-span-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<ToolTipLabel label="Should wallet actions prompt the user for approval?">
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Wallet prompts" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
</ToolTipLabel>
|
||||
<SelectContent>
|
||||
<SelectItem value="FULL_CONTROL_V1">
|
||||
Never prompt
|
||||
</SelectItem>
|
||||
<SelectItem value="PROMPT_USER_V1">Prompt user</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"hidden text-xs transition-all lg:block lg:-translate-y-4 lg:opacity-0 peer-focus-visible:opacity-100 peer-focus-visible:translate-y-0",
|
||||
form.formState.errors.permissions?.message &&
|
||||
"text-destructive lg:translate-y-0 lg:opacity-100 block", // If there are errors show them rather than the tip
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.permissions?.message ??
|
||||
"Wallet signing"}
|
||||
</FormDescription>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full lg:w-auto"
|
||||
>
|
||||
|
||||
<Button disabled={isPending} type="submit" className="w-full gap-2">
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
+2
-2
@@ -64,9 +64,9 @@ function EcosystemSelect(props: {
|
||||
ecosystem: Ecosystem;
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
const { data: ecosystems, isLoading } = useEcosystemList();
|
||||
const { data: ecosystems, isPending } = useEcosystemList();
|
||||
|
||||
return isLoading ? (
|
||||
return isPending ? (
|
||||
<Skeleton className="h-10 w-full md:w-[160px]" />
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
|
||||
+31
-83
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -6,20 +7,12 @@ import {
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { z } from "zod";
|
||||
@@ -38,7 +31,6 @@ export function UpdatePartnerForm({
|
||||
name: partner.name,
|
||||
domains: partner.allowlistedDomains.join(","),
|
||||
bundleIds: partner.allowlistedBundleIds.join(","),
|
||||
permissions: partner.permissions[0],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,18 +62,18 @@ export function UpdatePartnerForm({
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
permissions: [values.permissions],
|
||||
});
|
||||
})}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid gap-4 grow">
|
||||
<div className="flex flex-col gap-5 grow">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel> Name </FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="App name"
|
||||
@@ -108,21 +100,20 @@ export function UpdatePartnerForm({
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 lg:col-span-4">
|
||||
<FormLabel> Domains </FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
<Input placeholder="Domains" className="peer" {...field} />
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"text-xs block",
|
||||
form.formState.errors.domains?.message &&
|
||||
"text-destructive translate-y-0 opacity-100 block", // If there are errors show them rather than the tip
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.domains?.message ??
|
||||
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
|
||||
</FormDescription>
|
||||
</>
|
||||
<Input placeholder="Domains" className="peer" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"text-xs block",
|
||||
form.formState.errors.domains?.message &&
|
||||
"text-destructive translate-y-0 opacity-100 block", // If there are errors show them rather than the tip
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.domains?.message ??
|
||||
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -132,75 +123,32 @@ export function UpdatePartnerForm({
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel> Bundle ID </FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
<Input
|
||||
placeholder="Bundle ID"
|
||||
className="peer"
|
||||
{...field}
|
||||
/>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"text-xs block",
|
||||
form.formState.errors.bundleIds?.message &&
|
||||
"text-destructive block",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.bundleIds?.message ??
|
||||
"Space or comma-separated list of bundle IDs"}
|
||||
</FormDescription>
|
||||
</>
|
||||
<Input placeholder="Bundle ID" className="peer" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"text-xs block",
|
||||
form.formState.errors.bundleIds?.message &&
|
||||
"text-destructive block",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.bundleIds?.message ??
|
||||
"Space or comma-separated list of bundle IDs"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permissions"
|
||||
defaultValue="PROMPT_USER_V1" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<ToolTipLabel label="Should wallet actions prompt the user for approval?">
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Wallet prompts" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
</ToolTipLabel>
|
||||
<SelectContent>
|
||||
<SelectItem value="FULL_CONTROL_V1">
|
||||
Never prompt
|
||||
</SelectItem>
|
||||
<SelectItem value="PROMPT_USER_V1">Prompt user</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"hidden text-xs transition-all peer-focus-visible:opacity-100 peer-focus-visible:translate-y-0",
|
||||
form.formState.errors.permissions?.message &&
|
||||
"text-destructive block", // If there are errors show them rather than the tip
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.permissions?.message ??
|
||||
"Wallet signing"}
|
||||
</FormDescription>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={isPending}
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
className="w-full gap-2 mt-4"
|
||||
>
|
||||
{isPending && <Loader2 className="size-4 mr-1 animate-spin" />}
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
+2
-5
@@ -20,11 +20,8 @@ export function UpdatePartnerModal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger>{children}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="w-[90vw] sm:w-auto z-[10001]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
<DialogHeader className="mb-2">
|
||||
<DialogTitle>Update {partner.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UpdatePartnerForm
|
||||
|
||||
+16
-12
@@ -1,24 +1,28 @@
|
||||
import type { Ecosystem } from "../../../../types";
|
||||
import { AddPartnerForm } from "../client/add-partner-form.client";
|
||||
import { AddPartnerDialogButton } from "../client/AddPartnerDialogButton";
|
||||
import { PartnersTable } from "./partners-table";
|
||||
|
||||
export function EcosystemPartnersSection({
|
||||
ecosystem,
|
||||
}: { ecosystem: Ecosystem }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-2xl font-semibold text-foreground">
|
||||
Ecosystem partners
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure apps that can use your wallet. Creating a partner will
|
||||
generate a unique partner ID that can access your ecosystem. You will
|
||||
need to generate at least one partner ID for use in your own app.
|
||||
</p>
|
||||
<section className="flex flex-col gap-8 lg:gap-6">
|
||||
<div className="flex lg:justify-between gap-6 lg:items-start flex-col lg:flex-row">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-2xl font-semibold text-foreground">
|
||||
Ecosystem partners
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-normal">
|
||||
Configure apps that can use your wallet. Creating a partner will
|
||||
generate a unique partner ID that can access your ecosystem. <br />{" "}
|
||||
You will need to generate at least one partner ID for use in your
|
||||
own app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AddPartnerDialogButton ecosystem={ecosystem} />
|
||||
</div>
|
||||
|
||||
<AddPartnerForm ecosystem={ecosystem} />
|
||||
<PartnersTable ecosystem={ecosystem} />
|
||||
</section>
|
||||
);
|
||||
|
||||
+7
-13
@@ -22,9 +22,9 @@ import { usePartners } from "../../hooks/use-partners";
|
||||
import { UpdatePartnerModal } from "../client/update-partner-modal.client";
|
||||
|
||||
export function PartnersTable({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
const { partners, isLoading } = usePartners({ ecosystem });
|
||||
const { partners, isPending } = usePartners({ ecosystem });
|
||||
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
@@ -44,16 +44,14 @@ export function PartnersTable({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
<TableHead className="hidden md:table-cell">Domains</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Bundle ID</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Partner ID</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">
|
||||
Wallet Prompts
|
||||
</TableHead>
|
||||
|
||||
{/* Empty space for delete button */}
|
||||
<th className="table-cell" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...partners].reverse().map((partner: Partner) => (
|
||||
<EcosystemRow
|
||||
<PartnerRow
|
||||
key={partner.id}
|
||||
partner={partner}
|
||||
ecosystem={ecosystem}
|
||||
@@ -65,7 +63,7 @@ export function PartnersTable({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EcosystemRow(props: {
|
||||
function PartnerRow(props: {
|
||||
partner: Partner;
|
||||
ecosystem: Ecosystem;
|
||||
}) {
|
||||
@@ -108,13 +106,9 @@ function EcosystemRow(props: {
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
</TableCell>
|
||||
<TableCell className="hidden align-top lg:table-cell">
|
||||
{props.partner.permissions.includes("PROMPT_USER_V1")
|
||||
? "Prompt user"
|
||||
: "Never prompt"}
|
||||
</TableCell>
|
||||
|
||||
<td className="table-cell py-1 align-middle">
|
||||
<div className="flex gap-1.5 justify-end">
|
||||
<div className="flex gap-1.5 pr-1.5 justify-end">
|
||||
<UpdatePartnerModal
|
||||
partner={props.partner}
|
||||
ecosystem={props.ecosystem}
|
||||
|
||||
-8
@@ -1,6 +1,4 @@
|
||||
import z from "zod";
|
||||
import type { PartnerPermission } from "../../types";
|
||||
import { isValidPermission } from "./utils";
|
||||
|
||||
const isDomainRegex =
|
||||
/^(?:\*|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*(?:\*(?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9](?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*)?)|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]|localhost(?::\d{1,5})?)$/;
|
||||
@@ -32,10 +30,4 @@ export const partnerFormSchema = z.object({
|
||||
.filter((d) => d.length > 0)
|
||||
.join(","),
|
||||
),
|
||||
permissions: z.custom<PartnerPermission>(
|
||||
(value) => isValidPermission(value),
|
||||
{
|
||||
message: "Invalid permissions setting",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
+2
-2
@@ -11,7 +11,6 @@ type AddPartnerParams = {
|
||||
name: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
permissions: ["PROMPT_USER_V1" | "FULL_CONTROL_V1"];
|
||||
};
|
||||
|
||||
export function useAddPartner(
|
||||
@@ -44,7 +43,8 @@ export function useAddPartner(
|
||||
name: params.name,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
permissions: params.permissions,
|
||||
// TODO - remove the requirement for permissions in API endpoint
|
||||
permissions: ["FULL_CONTROL_V1"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export function usePartners({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
});
|
||||
|
||||
return {
|
||||
isLoading: user?.jwt ? partnersQuery.isLoading : false,
|
||||
isPending: user?.jwt ? partnersQuery.isPending : false,
|
||||
partners: (partnersQuery.data ?? []) satisfies Partner[],
|
||||
};
|
||||
}
|
||||
|
||||
-2
@@ -12,7 +12,6 @@ type UpdatePartnerParams = {
|
||||
name: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
permissions: ["PROMPT_USER_V1" | "FULL_CONTROL_V1"];
|
||||
};
|
||||
|
||||
export function useUpdatePartner(
|
||||
@@ -45,7 +44,6 @@ export function useUpdatePartner(
|
||||
name: params.name,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
permissions: params.permissions,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
import type { PartnerPermission } from "../../types.ts";
|
||||
|
||||
export const isValidPermission = (
|
||||
permission: string,
|
||||
): permission is PartnerPermission =>
|
||||
["FULL_CONTROL_V1", "PROMPT_USER_V1"].includes(permission);
|
||||
+1
-1
@@ -26,7 +26,7 @@ export function useEcosystemList() {
|
||||
|
||||
return {
|
||||
...ecosystemQuery,
|
||||
isLoading: isLoggedIn ? ecosystemQuery.isLoading : false,
|
||||
isPending: isLoggedIn ? ecosystemQuery.isPending : false,
|
||||
ecosystems: (ecosystemQuery.data ?? []) satisfies Ecosystem[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type Ecosystem = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
|
||||
type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
|
||||
export type Partner = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
},
|
||||
});
|
||||
|
||||
if (webhooksQuery.isLoading) {
|
||||
if (webhooksQuery.isPending) {
|
||||
return <Spinner className="size-8 mx-auto" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ErrorProvider } from "../../contexts/error-handler";
|
||||
export default function DashboardLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ErrorProvider>
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
<main className="grow">{props.children}</main>
|
||||
<AppFooter />
|
||||
|
||||
+7
-6
@@ -50,7 +50,8 @@ export const TransactionSimulator = (props: {
|
||||
}) => {
|
||||
const activeAccount = useActiveAccount();
|
||||
const initialFormValues = props.searchParams;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// TODO - replace this with a mutation.isPending
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [state, setState] = useState<State>({
|
||||
success: false,
|
||||
message: "",
|
||||
@@ -66,7 +67,7 @@ export const TransactionSimulator = (props: {
|
||||
|
||||
async function handleSimulation(data: SimulateTransactionForm) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setIsPending(true);
|
||||
const { from, to, value, functionArgs, functionName } = data;
|
||||
if (!chain) {
|
||||
throw new Error("Invalid chainId");
|
||||
@@ -154,7 +155,7 @@ ${Object.keys(populatedTransaction)
|
||||
shareUrl: "",
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
setIsPending(false);
|
||||
}
|
||||
return (
|
||||
<div className="max-w-[800px] space-y-4">
|
||||
@@ -259,7 +260,7 @@ ${Object.keys(populatedTransaction)
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="w-4 h-4 mr-2" />
|
||||
Simulating
|
||||
@@ -341,11 +342,11 @@ const getCodeExample = (
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return `import {
|
||||
return `import {
|
||||
getContract,
|
||||
defineChain,
|
||||
prepareContractCall,
|
||||
createThirdwebClient
|
||||
createThirdwebClient
|
||||
} from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
|
||||
@@ -85,7 +85,7 @@ function AccountAvatarFormControl() {
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: false,
|
||||
isLoading: updateAvatarMutation.isPending,
|
||||
isPending: updateAvatarMutation.isPending,
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
@@ -143,7 +143,7 @@ function AccountNameFormControl(props: {
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: accountName.length === 0,
|
||||
isLoading: updateAccountNameMutation.isPending,
|
||||
isPending: updateAccountNameMutation.isPending,
|
||||
}}
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined} // TODO
|
||||
@@ -191,7 +191,7 @@ function DeleteAccountCard() {
|
||||
description={description}
|
||||
buttonLabel={title}
|
||||
buttonOnClick={handleDelete}
|
||||
isLoading={deleteAccount.isPending}
|
||||
isPending={deleteAccount.isPending}
|
||||
confirmationDialog={{
|
||||
title: "Are you sure you want to delete your account?",
|
||||
description:
|
||||
|
||||
@@ -13,34 +13,46 @@ import type {
|
||||
const THIRDWEB_API_HOST =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
|
||||
|
||||
const THIRDWEB_API_SECRET = process.env.API_SERVER_SECRET || "";
|
||||
|
||||
export async function getLoginPayload(
|
||||
params: GenerateLoginPayloadParams,
|
||||
): Promise<LoginPayload> {
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/auth/payload`, {
|
||||
if (!THIRDWEB_API_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v2/siwe/payload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-service-api-key": THIRDWEB_API_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
address: params.address,
|
||||
chainId: params.chainId?.toString(),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch login payload", res.status, res.statusText);
|
||||
throw new Error("Failed to fetch login payload");
|
||||
}
|
||||
return (await res.json()).payload;
|
||||
return (await res.json()).data.payload;
|
||||
}
|
||||
|
||||
export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
if (!THIRDWEB_API_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
|
||||
// forward the request to the API server
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/auth/login`, {
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v2/siwe/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-service-api-key": THIRDWEB_API_SECRET,
|
||||
},
|
||||
body: JSON.stringify({ payload }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -67,7 +79,9 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.token) {
|
||||
const jwt = json.data.jwt;
|
||||
|
||||
if (!jwt) {
|
||||
console.error("Failed to login - invalid json", json);
|
||||
throw new Error("Failed to login - invalid json");
|
||||
}
|
||||
@@ -77,7 +91,7 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${json.token}`,
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
// if we do not have a user, create one
|
||||
@@ -85,7 +99,7 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
const newUserRes = await fetch(`${THIRDWEB_API_HOST}/v1/account/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${json.token}`,
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,7 +118,7 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
// set the token cookie
|
||||
cookieStore.set(
|
||||
COOKIE_PREFIX_TOKEN + getAddress(payload.payload.address),
|
||||
json.token,
|
||||
jwt,
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
|
||||
@@ -13,25 +13,25 @@ import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="bg-background h-full">
|
||||
<div className="bg-muted/30 h-screen relative grid place-items-center">
|
||||
<nav className="fixed top-0 w-full flex flex-row justify-between items-center px-6 py-4 z-20">
|
||||
<ThirdwebMiniLogo className="max-h-7" />
|
||||
<ColorModeToggle />
|
||||
</nav>
|
||||
<main className="h-full relative grid place-items-center">
|
||||
<div className="flex flex-col gap-8 z-10">
|
||||
<h1 className="font-semibold text-2xl">Get started with thirdweb</h1>
|
||||
<Suspense>
|
||||
<CustomConnectEmmbed />
|
||||
</Suspense>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
src="/assets/login/background.svg"
|
||||
className="fixed bottom-0 right-0"
|
||||
/>
|
||||
<main className="flex flex-col gap-6 z-10">
|
||||
<h1 className="font-semibold text-2xl lg:text-3xl tracking-tight text-center">
|
||||
Get started with thirdweb
|
||||
</h1>
|
||||
<Suspense>
|
||||
<CustomConnectEmmbed />
|
||||
</Suspense>
|
||||
</main>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
src="/assets/login/background.svg"
|
||||
className="fixed -bottom-12 -right-12 lg:bottom-0 lg:right-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ function CustomConnectEmmbed() {
|
||||
client={client}
|
||||
modalSize={isLG ? "wide" : "compact"}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AllChainsProvider } from "contexts/all-chains";
|
||||
import { ChainsProvider } from "contexts/configured-chains";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { SyncChainStores } from "../stores/chainStores";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AllChainsProvider>
|
||||
<ChainsProvider>
|
||||
<ThirdwebProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
{props.children}
|
||||
</ThemeProvider>
|
||||
</ThirdwebProvider>
|
||||
</ChainsProvider>
|
||||
</AllChainsProvider>
|
||||
<SyncChainStores />
|
||||
<ThirdwebProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
{props.children}
|
||||
</ThemeProvider>
|
||||
</ThirdwebProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-7
@@ -2,14 +2,13 @@
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { ApplyForOpCreditsModal } from "components/onboarding/ApplyForOpCreditsModal";
|
||||
import { Heading, LinkButton } from "tw-components";
|
||||
|
||||
export const SettingsGasCreditsPage = () => {
|
||||
const { isLoading } = useLoggedInUser();
|
||||
const { isPending } = useLoggedInUser();
|
||||
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="grid w-full min-h-[400px] place-items-center">
|
||||
<Spinner className="size-10" />
|
||||
@@ -18,8 +17,8 @@ export const SettingsGasCreditsPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={8}>
|
||||
<Flex direction="row" gap={4} align="center">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<Heading size="title.lg" as="h1">
|
||||
Apply to the Optimism Superchain App Accelerator
|
||||
</Heading>
|
||||
@@ -32,9 +31,9 @@ export const SettingsGasCreditsPage = () => {
|
||||
>
|
||||
Learn More
|
||||
</LinkButton>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<ApplyForOpCreditsModal />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+5
-5
@@ -63,7 +63,7 @@ function TeamNameFormControl(props: {
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: teamName.length === 0,
|
||||
isLoading: updateTeamMutation.isPending,
|
||||
isPending: updateTeamMutation.isPending,
|
||||
}}
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined} // TODO
|
||||
@@ -121,7 +121,7 @@ function TeamSlugFormControl(props: {
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: teamSlug.length === 0,
|
||||
isLoading: updateTeamMutation.isPending,
|
||||
isPending: updateTeamMutation.isPending,
|
||||
}}
|
||||
noPermissionText={undefined} // TODO
|
||||
>
|
||||
@@ -168,7 +168,7 @@ function TeamAvatarFormControl() {
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: false,
|
||||
isLoading: updateTeamAvatarMutation.isPending,
|
||||
isPending: updateTeamAvatarMutation.isPending,
|
||||
}}
|
||||
noPermissionText={undefined} // TODO
|
||||
errorText={undefined}
|
||||
@@ -250,7 +250,7 @@ export function LeaveTeamCard(props: {
|
||||
description={description}
|
||||
buttonLabel={title}
|
||||
buttonOnClick={handleLeave}
|
||||
isLoading={leaveTeam.isPending}
|
||||
isPending={leaveTeam.isPending}
|
||||
confirmationDialog={{
|
||||
title: `Are you sure you want to leave team "${props.teamName}" ?`,
|
||||
description:
|
||||
@@ -311,7 +311,7 @@ export function DeleteTeamCard(props: {
|
||||
description={description}
|
||||
buttonLabel={title}
|
||||
buttonOnClick={handleDelete}
|
||||
isLoading={deleteTeam.isPending}
|
||||
isPending={deleteTeam.isPending}
|
||||
confirmationDialog={{
|
||||
title: `Are you sure you want to delete team "${props.teamName}" ?`,
|
||||
description: description,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useAccount, useAccountUsage } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { Flex, HStack } from "@chakra-ui/react";
|
||||
import { BillingPeriod } from "components/settings/Account/Billing/Period";
|
||||
import { BillingPlan } from "components/settings/Account/Billing/Plan";
|
||||
import { Usage } from "components/settings/Account/Usage";
|
||||
@@ -12,7 +11,7 @@ export const SettingsUsagePage = () => {
|
||||
const usageQuery = useAccountUsage();
|
||||
const account = meQuery.data;
|
||||
|
||||
if (meQuery.isLoading || !account) {
|
||||
if (meQuery.isPending || !account) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner className="size-10" />
|
||||
@@ -21,20 +20,16 @@ export const SettingsUsagePage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={8}>
|
||||
<Flex direction="column">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="font-semibold text-3xl tracking-tight mb-2">Usage</h1>
|
||||
<HStack
|
||||
justifyContent="space-between"
|
||||
flexDir={{ base: "column", lg: "row" }}
|
||||
alignItems={{ base: "flex-start", lg: "center" }}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center">
|
||||
<BillingPlan account={account} />
|
||||
<BillingPeriod account={account} usage={usageQuery.data} />
|
||||
</HStack>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Usage usage={usageQuery.data} usageLoading={usageQuery.isLoading} />
|
||||
</Flex>
|
||||
<Usage usage={usageQuery.data} usagePending={usageQuery.isPending} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ export function AccountAbstractionPage(props: {
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{looggedInUserQuery.isLoading ? (
|
||||
{looggedInUserQuery.isPending ? (
|
||||
<div className="flex items-center justify-center h-[400px] border border-border rounded-lg">
|
||||
<Spinner className="size-14" />
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ export function ConnectAnalyticsDashboard(props: {
|
||||
<ConnectAnalyticsDashboardUI
|
||||
walletUsage={walletUsageQuery.data || []}
|
||||
aggregateWalletUsage={walletUsageAggregateQuery.data || []}
|
||||
isLoading={
|
||||
walletUsageQuery.isLoading || walletUsageAggregateQuery.isLoading
|
||||
isPending={
|
||||
walletUsageQuery.isPending || walletUsageAggregateQuery.isPending
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@ import { WalletDistributionChartCard } from "./_components/WalletDistributionCha
|
||||
export function ConnectAnalyticsDashboardUI(props: {
|
||||
walletUsage: WalletStats[];
|
||||
aggregateWalletUsage: WalletStats[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { totalWallets, uniqueWallets } = useMemo(() => {
|
||||
return props.aggregateWalletUsage.reduce(
|
||||
@@ -36,17 +36,17 @@ export function ConnectAnalyticsDashboardUI(props: {
|
||||
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={props.walletUsage}
|
||||
isLoading={props.isLoading}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={props.walletUsage}
|
||||
isLoading={props.isLoading}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
|
||||
<WalletDistributionChartCard
|
||||
walletStats={props.walletUsage}
|
||||
isLoading={props.isLoading}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ function Component() {
|
||||
<ConnectAnalyticsDashboardUI
|
||||
walletUsage={createWalletStatsStub(30)}
|
||||
aggregateWalletUsage={createWalletStatsStub(30)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-5
@@ -34,35 +34,35 @@ function Component() {
|
||||
<BadgeContainer label="30 days">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(30)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="60 days">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(60)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(10)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 days">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isLoading={true}
|
||||
isPending={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -46,7 +46,7 @@ const chartLabelToShow: Record<ChartToShow, string> = {
|
||||
|
||||
export function DailyConnectionsChartCard(props: {
|
||||
walletStats: WalletStats[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { walletStats } = props;
|
||||
|
||||
@@ -73,7 +73,7 @@ export function DailyConnectionsChartCard(props: {
|
||||
return Array.from(chartDataMap.values());
|
||||
}, [walletStats]);
|
||||
|
||||
const disableActions = props.isLoading || barChartData.length === 0;
|
||||
const disableActions = props.isPending || barChartData.length === 0;
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 md:p-6 relative w-full">
|
||||
@@ -126,7 +126,7 @@ export function DailyConnectionsChartCard(props: {
|
||||
config={chartConfig}
|
||||
className="w-full h-[250px] md:h-[350px]"
|
||||
>
|
||||
{props.isLoading ? (
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : barChartData.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
|
||||
+3
-3
@@ -34,7 +34,7 @@ const chartLabelToShow: Record<ChartToShow, string> = {
|
||||
};
|
||||
export function WalletConnectorsChartCard(props: {
|
||||
walletStats: WalletStats[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { walletStats } = props;
|
||||
const [chartToShow, setChartToShow] = useState<ChartToShow>(
|
||||
@@ -90,7 +90,7 @@ export function WalletConnectorsChartCard(props: {
|
||||
}, [walletStats, chartToShow]);
|
||||
|
||||
const uniqueWalletTypes = Object.keys(chartConfig);
|
||||
const disableActions = props.isLoading || chartData.length === 0;
|
||||
const disableActions = props.isPending || chartData.length === 0;
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 md:p-6 relative w-full">
|
||||
@@ -142,7 +142,7 @@ export function WalletConnectorsChartCard(props: {
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer config={chartConfig} className="w-full h-[400px]">
|
||||
{props.isLoading ? (
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
|
||||
+5
-5
@@ -34,35 +34,35 @@ function Component() {
|
||||
<BadgeContainer label="30 days">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(30)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="60 days">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(60)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(10)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 days">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isLoading={true}
|
||||
isPending={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
+5
-5
@@ -34,35 +34,35 @@ function Component() {
|
||||
<BadgeContainer label="30 days">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(30)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="60 days">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(60)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(10)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 days">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isLoading={false}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isLoading={true}
|
||||
isPending={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -37,7 +37,7 @@ const chartLabelToShow: Record<ChartToShow, string> = {
|
||||
|
||||
export function WalletDistributionChartCard(props: {
|
||||
walletStats: WalletStats[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { walletStats } = props;
|
||||
const [chartToShow, setChartToShow] = useState<ChartToShow>("total");
|
||||
@@ -114,7 +114,7 @@ export function WalletDistributionChartCard(props: {
|
||||
};
|
||||
}, [walletStats]);
|
||||
|
||||
const disableActions = props.isLoading || chartData.length === 0;
|
||||
const disableActions = props.isPending || chartData.length === 0;
|
||||
return (
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 md:p-6 relative w-full">
|
||||
<h3 className="text-xl md:text-2xl font-semibold tracking-tight mb-1">
|
||||
@@ -131,7 +131,7 @@ export function WalletDistributionChartCard(props: {
|
||||
setChartToShow(v as ChartToShow);
|
||||
}}
|
||||
value={chartToShow}
|
||||
disabled={props.isLoading || chartData.length === 0}
|
||||
disabled={props.isPending || chartData.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -184,7 +184,7 @@ export function WalletDistributionChartCard(props: {
|
||||
config={chartConfig}
|
||||
className="w-full h-[500px] [&_.recharts-pie-label-text]:fill-foreground mt-6"
|
||||
>
|
||||
{props.isLoading ? (
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function Page() {
|
||||
const hasContracts =
|
||||
deployedContracts.data && deployedContracts.data?.length > 0;
|
||||
|
||||
if (deployedContracts.isLoading) {
|
||||
if (deployedContracts.isPending) {
|
||||
return (
|
||||
<div className="min-h-[400px] flex justify-center items-center">
|
||||
<Spinner className="size-10" />
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { WithEngineInstance } from "components/engine/EnginePageLayout";
|
||||
import { EngineAlertsPage } from "../../../../../../../../components/engine/alerts/EngineAlertsPage";
|
||||
import type { EngineInstancePageProps } from "../types";
|
||||
|
||||
export default function Page(props: EngineInstancePageProps) {
|
||||
const { params } = props;
|
||||
|
||||
return (
|
||||
<WithEngineInstance
|
||||
engineId={props.params.engineId}
|
||||
content={(res) => <EngineAlertsPage instance={res.instance} />}
|
||||
rootPath={`/team/${params.team_slug}/${params.project_slug}/engine`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -283,7 +283,7 @@ function ProjectNameSetting(props: {
|
||||
saveButton={{
|
||||
onClick: handleSubmit,
|
||||
disabled: !isNameDirty,
|
||||
isLoading: updateMutation.isPending && isNameDirty,
|
||||
isPending: updateMutation.isPending && isNameDirty,
|
||||
}}
|
||||
bottomText="Please use 64 characters at maximum"
|
||||
>
|
||||
@@ -349,7 +349,7 @@ function AllowedDomainsSetting(props: {
|
||||
saveButton={{
|
||||
onClick: handleSubmit,
|
||||
disabled: !isDomainsDirty,
|
||||
isLoading: updateMutation.isPending && isDomainsDirty,
|
||||
isPending: updateMutation.isPending && isDomainsDirty,
|
||||
}}
|
||||
bottomText={"This is only applicable for web applications"}
|
||||
>
|
||||
@@ -417,7 +417,7 @@ function AllowedBundleIDsSetting(props: {
|
||||
saveButton={{
|
||||
onClick: handleSubmit,
|
||||
disabled: !isBundleIdsDirty,
|
||||
isLoading: updateMutation.isPending && isBundleIdsDirty,
|
||||
isPending: updateMutation.isPending && isBundleIdsDirty,
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
header={{
|
||||
@@ -523,7 +523,7 @@ function EnabledServicesSetting(props: {
|
||||
saveButton={{
|
||||
onClick: handleSubmit,
|
||||
disabled: !form.formState.isDirty,
|
||||
isLoading: updateMutation.isPending,
|
||||
isPending: updateMutation.isPending,
|
||||
}}
|
||||
bottomText=""
|
||||
>
|
||||
@@ -739,7 +739,7 @@ function DeleteProject(props: {
|
||||
description: description,
|
||||
}}
|
||||
description={description}
|
||||
isLoading={deleteMutation.isPending}
|
||||
isPending={deleteMutation.isPending}
|
||||
title={wording === "project" ? "Delete Project" : "Delete API Key"}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function RootTeamLayout(props: {
|
||||
params: { team_slug: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-full flex flex-col bg-background">
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="grow flex flex-col">{props.children}</div>
|
||||
<TWAutoConnect />
|
||||
<AppFooter />
|
||||
|
||||
@@ -4,31 +4,24 @@ import {
|
||||
EVMContractInfoProvider,
|
||||
} from "@3rdweb-sdk/react";
|
||||
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { Flex, SimpleGrid } from "@chakra-ui/react";
|
||||
import { SimpleGrid } from "@chakra-ui/react";
|
||||
import {
|
||||
type DehydratedState,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { ConfigureNetworkModal } from "components/configure-networks/ConfigureNetworkModal";
|
||||
import { AppShell, type AppShellProps } from "components/layout/app-shell";
|
||||
import { Onboarding as OnboardingModal } from "components/onboarding";
|
||||
import { OpCreditsGrantedModalWrapper } from "components/onboarding/OpCreditsGrantedModalWrapper";
|
||||
import { PosthogIdentifier } from "components/wallets/PosthogIdentifier";
|
||||
import { AllChainsProvider } from "contexts/all-chains";
|
||||
import { ChainsProvider } from "contexts/configured-chains";
|
||||
import { ErrorProvider } from "contexts/error-handler";
|
||||
import { isSanctionedAddress } from "data/eth-sanctioned-addresses";
|
||||
import { useAddRecentlyUsedChainId } from "hooks/chains/recentlyUsedChains";
|
||||
import {
|
||||
useIsNetworkConfigModalOpen,
|
||||
useSetIsNetworkConfigModalOpen,
|
||||
} from "hooks/networkConfigModal";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Heading } from "tw-components";
|
||||
import type { ComponentWithChildren } from "types/component-with-children";
|
||||
import { SyncChainStores } from "../../stores/chainStores";
|
||||
import { DashboardThirdwebProvider } from "./providers";
|
||||
|
||||
interface AppLayoutProps extends AppShellProps {
|
||||
@@ -56,23 +49,19 @@ export const AppLayout: ComponentWithChildren<AppLayoutProps> = (props) => {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={props.dehydratedState}>
|
||||
<ErrorProvider>
|
||||
<AllChainsProvider>
|
||||
<ChainsProvider>
|
||||
<EVMContractInfoProvider value={props.contractInfo}>
|
||||
<DashboardThirdwebProvider>
|
||||
<SanctionedAddressesChecker>
|
||||
<PosthogIdentifier />
|
||||
<ConfigModal />
|
||||
<SyncChainStores />
|
||||
<EVMContractInfoProvider value={props.contractInfo}>
|
||||
<DashboardThirdwebProvider>
|
||||
<SanctionedAddressesChecker>
|
||||
<PosthogIdentifier />
|
||||
|
||||
<OnboardingModal />
|
||||
<OpCreditsGrantedModalWrapper />
|
||||
<OnboardingModal />
|
||||
<OpCreditsGrantedModalWrapper />
|
||||
|
||||
<AppShell {...props} />
|
||||
</SanctionedAddressesChecker>
|
||||
</DashboardThirdwebProvider>
|
||||
</EVMContractInfoProvider>
|
||||
</ChainsProvider>
|
||||
</AllChainsProvider>
|
||||
<AppShell {...props} />
|
||||
</SanctionedAddressesChecker>
|
||||
</DashboardThirdwebProvider>
|
||||
</EVMContractInfoProvider>
|
||||
</ErrorProvider>
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>
|
||||
@@ -96,31 +85,12 @@ const SanctionedAddressesChecker: ComponentWithChildren = ({ children }) => {
|
||||
bg="black"
|
||||
zIndex="banner"
|
||||
>
|
||||
<Flex gap={4} direction="column" align="center">
|
||||
<div className="gap-4 flex flex-col items-center">
|
||||
<Heading as="p">Address is blocked</Heading>
|
||||
<CustomConnectWallet />
|
||||
</Flex>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
function ConfigModal() {
|
||||
const isNetworkConfigModalOpen = useIsNetworkConfigModalOpen();
|
||||
const setIsNetworkConfigModalOpen = useSetIsNetworkConfigModalOpen();
|
||||
const addRecentlyUsedChains = useAddRecentlyUsedChainId();
|
||||
|
||||
if (!isNetworkConfigModalOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigureNetworkModal
|
||||
onNetworkAdded={(_chain) => {
|
||||
addRecentlyUsedChains(_chain.chainId);
|
||||
}}
|
||||
onClose={() => setIsNetworkConfigModalOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {
|
||||
import { getSDKTheme } from "app/components/sdk-component-theme";
|
||||
import { LOCAL_NODE_PKEY } from "constants/misc";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useSupportedChain } from "hooks/chains/configureChains";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
@@ -58,6 +57,7 @@ import {
|
||||
import { privateKeyToAccount } from "thirdweb/wallets";
|
||||
import { Button, type ButtonProps, Card, Heading, Text } from "tw-components";
|
||||
import { THIRDWEB_API_HOST } from "../../constants/urls";
|
||||
import { useAllChainsData } from "../../hooks/chains/allChains";
|
||||
import { useV5DashboardChain } from "../../lib/v5-adapter";
|
||||
|
||||
const GAS_FREE_CHAINS = [
|
||||
@@ -121,7 +121,7 @@ export const MismatchButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
isLoading={evmBalance.isLoading}
|
||||
isLoading={evmBalance.isPending}
|
||||
{...props}
|
||||
type={networksMismatch || notEnoughBalance ? "button" : type}
|
||||
loadingText={loadingText}
|
||||
@@ -408,10 +408,12 @@ const MismatchNotice: React.FC<{
|
||||
const activeWallet = useActiveWallet();
|
||||
const actuallyCanAttemptSwitch =
|
||||
activeWallet && activeWallet.id !== "global.safe";
|
||||
const walletConnectedNetworkInfo = useSupportedChain(connectedChainId || -1);
|
||||
const { idToChain } = useAllChainsData();
|
||||
const walletConnectedNetworkInfo = connectedChainId
|
||||
? idToChain.get(connectedChainId)
|
||||
: undefined;
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
const chain = useSupportedChain(desiredChainId || -1);
|
||||
const chain = desiredChainId ? idToChain.get(desiredChainId) : undefined;
|
||||
const chainV5 = useV5DashboardChain(desiredChainId);
|
||||
|
||||
const onSwitchWallet = useCallback(async () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ function useWalletRequiresExternalConfirmation() {
|
||||
export const TransactionButton: React.FC<TransactionButtonProps> = ({
|
||||
children,
|
||||
transactionCount,
|
||||
isLoading,
|
||||
isLoading: isPending,
|
||||
size,
|
||||
colorScheme,
|
||||
variant,
|
||||
@@ -88,7 +88,7 @@ export const TransactionButton: React.FC<TransactionButtonProps> = ({
|
||||
returnFocusOnClose={false}
|
||||
initialFocusRef={initialFocusRef}
|
||||
isLazy
|
||||
isOpen={walletRequiresExternalConfirmation && isLoading}
|
||||
isOpen={walletRequiresExternalConfirmation && isPending}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<ButtonComponent
|
||||
@@ -96,14 +96,14 @@ export const TransactionButton: React.FC<TransactionButtonProps> = ({
|
||||
position="relative"
|
||||
role="group"
|
||||
colorScheme={colorScheme}
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
size={size}
|
||||
variant={variant}
|
||||
{...restButtonProps}
|
||||
overflow="hidden"
|
||||
boxSizing="border-box"
|
||||
pl={
|
||||
isLoading || !isConnected
|
||||
isPending || !isConnected
|
||||
? undefined
|
||||
: `calc(${52 + numberWidth}px + var(--chakra-space-${
|
||||
size === "sm" ? 3 : size === "lg" ? 6 : size === "xs" ? 2 : 4
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { ChakraNextImage } from "components/Image";
|
||||
|
||||
const partnersCompanies = [
|
||||
@@ -46,13 +45,7 @@ const partnersCompanies = [
|
||||
|
||||
const PartnersSection = () => {
|
||||
return (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexWrap="wrap"
|
||||
gap={10}
|
||||
mt="24px"
|
||||
>
|
||||
<div className="items-center justify-center flex-wrap gap-10 mt-6 flex flex-row">
|
||||
{partnersCompanies.slice(0, 10).map((partner, idx) => (
|
||||
<ChakraNextImage
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
@@ -62,7 +55,7 @@ const PartnersSection = () => {
|
||||
alt="partner"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
FormControl,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from "@chakra-ui/react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import type { StoredChain } from "contexts/configured-chains";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useSupportedChainsNameRecord } from "hooks/chains/configureChains";
|
||||
import { useRemoveChainModification } from "hooks/chains/useModifyChain";
|
||||
import { getDashboardChainRpc } from "lib/rpc";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { useAllChainsData } from "../../hooks/chains/allChains";
|
||||
import {
|
||||
type StoredChain,
|
||||
removeChainOverrides,
|
||||
} from "../../stores/chainStores";
|
||||
import { ChainIdInput } from "./Form/ChainIdInput";
|
||||
import { ConfirmationPopover } from "./Form/ConfirmationPopover";
|
||||
import { IconUpload } from "./Form/IconUpload";
|
||||
import { NetworkIDInput } from "./Form/NetworkIdInput";
|
||||
import { RpcInput } from "./Form/RpcInput";
|
||||
import { TooltipBox } from "./Form/TooltipBox";
|
||||
|
||||
export type NetworkConfigFormData = {
|
||||
name: string;
|
||||
@@ -30,7 +25,6 @@ export type NetworkConfigFormData = {
|
||||
type: "testnet" | "mainnet";
|
||||
icon: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
// lowercase it, replace all spaces with hyphens, and then strip all non-alphanumeric characters
|
||||
@@ -55,9 +49,7 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
prefillSlug,
|
||||
prefillChainId,
|
||||
}) => {
|
||||
const configuredChainNameRecord = useSupportedChainsNameRecord();
|
||||
const { chainIdToChainRecord } = useAllChainsData();
|
||||
const removeChainModification = useRemoveChainModification();
|
||||
const { idToChain, nameToChain } = useAllChainsData();
|
||||
|
||||
const form = useForm<NetworkConfigFormData>({
|
||||
values: {
|
||||
@@ -73,7 +65,6 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
type: editingChain?.testnet ? "testnet" : "mainnet",
|
||||
icon: editingChain?.icon?.url || "",
|
||||
slug: editingChain?.slug || "",
|
||||
status: editingChain?.status === "deprecated" ? "deprecated" : "active",
|
||||
},
|
||||
defaultValues: {
|
||||
slug: prefillSlug,
|
||||
@@ -81,17 +72,15 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const isFullyEditable =
|
||||
!editingChain || editingChain?.isCustom || editingChain.isOverwritten;
|
||||
const isFullyEditable = !editingChain || editingChain?.isCustom;
|
||||
|
||||
const chainId = Number(form.watch("chainId"));
|
||||
const isChainIdDirty = form.formState.dirtyFields.chainId;
|
||||
const overwritingChain = isChainIdDirty && chainIdToChainRecord[chainId];
|
||||
const overwritingChain = isChainIdDirty && idToChain.get(chainId);
|
||||
|
||||
const editedFrom =
|
||||
editingChain?.isModified || editingChain?.isOverwritten
|
||||
? chainIdToChainRecord[editingChain.chainId]
|
||||
: undefined;
|
||||
const editedFrom = editingChain?.isModified
|
||||
? idToChain.get(editingChain.chainId)
|
||||
: undefined;
|
||||
|
||||
const { ref } = form.register("name", {
|
||||
required: true,
|
||||
@@ -104,7 +93,7 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
const chain = configuredChainNameRecord[value];
|
||||
const chain = nameToChain.get(value);
|
||||
|
||||
// valid if chain is not found
|
||||
if (!chain) {
|
||||
@@ -118,6 +107,10 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
if (overwritingChain) {
|
||||
return;
|
||||
}
|
||||
|
||||
let configuredNetwork: StoredChain;
|
||||
|
||||
if (editingChain) {
|
||||
@@ -143,7 +136,6 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
format: "",
|
||||
},
|
||||
testnet: data.type === "testnet",
|
||||
status: data.status,
|
||||
};
|
||||
} else {
|
||||
configuredNetwork = {
|
||||
@@ -168,40 +160,25 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
format: "",
|
||||
}
|
||||
: undefined,
|
||||
status: data.status,
|
||||
};
|
||||
}
|
||||
|
||||
if (editingChain && editingChain.chainId !== configuredNetwork.chainId) {
|
||||
removeChainModification(editingChain.chainId);
|
||||
removeChainOverrides(editingChain.chainId);
|
||||
}
|
||||
|
||||
if (overwritingChain) {
|
||||
configuredNetwork.isOverwritten = true;
|
||||
configuredNetwork.isCustom = false;
|
||||
configuredNetwork.isModified = false;
|
||||
} else {
|
||||
if (editingChain) {
|
||||
if (editingChain.isCustom) {
|
||||
configuredNetwork.isOverwritten = false;
|
||||
configuredNetwork.isCustom = true;
|
||||
configuredNetwork.isModified = false;
|
||||
} else if (editingChain.isOverwritten) {
|
||||
configuredNetwork.isOverwritten = true;
|
||||
configuredNetwork.isCustom = false;
|
||||
configuredNetwork.isModified = false;
|
||||
} else {
|
||||
configuredNetwork.isCustom = false;
|
||||
configuredNetwork.isModified = true;
|
||||
configuredNetwork.isOverwritten = false;
|
||||
}
|
||||
} else {
|
||||
if (editingChain) {
|
||||
if (editingChain.isCustom) {
|
||||
configuredNetwork.isCustom = true;
|
||||
configuredNetwork.isModified = false;
|
||||
configuredNetwork.isOverwritten = false;
|
||||
} else {
|
||||
configuredNetwork.isCustom = false;
|
||||
configuredNetwork.isModified = true;
|
||||
}
|
||||
} else {
|
||||
configuredNetwork.isCustom = true;
|
||||
configuredNetwork.isModified = false;
|
||||
}
|
||||
|
||||
onSubmit(configuredNetwork);
|
||||
});
|
||||
|
||||
@@ -211,17 +188,7 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
? "Network already exists"
|
||||
: "Network name is required");
|
||||
|
||||
const disableSubmit = !form.formState.isDirty || !form.formState.isValid;
|
||||
|
||||
const submitBtn = (
|
||||
<Button
|
||||
variant="primary"
|
||||
type={disableSubmit ? "submit" : overwritingChain ? "button" : "submit"}
|
||||
disabled={disableSubmit}
|
||||
>
|
||||
{editingChain ? "Update Network" : "Add Network"}
|
||||
</Button>
|
||||
);
|
||||
const disableSubmit = !form.formState.isDirty || !!overwritingChain;
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -231,176 +198,164 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
|
||||
return handleSubmit(e);
|
||||
}}
|
||||
>
|
||||
{/* Network Name for Custom Network */}
|
||||
<FormControl isRequired isInvalid={!!networkNameErrorMessage}>
|
||||
<FormLabel>Network name</FormLabel>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="e.g. My Network"
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
<div className="flex flex-col gap-5 mt-4 px-6 pb-6">
|
||||
{/* Name + Slug */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* name */}
|
||||
<FormFieldSetup
|
||||
htmlFor="name"
|
||||
label="Name"
|
||||
errorMessage={networkNameErrorMessage}
|
||||
tooltip={undefined}
|
||||
isRequired
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
autoComplete="off"
|
||||
placeholder="My Network"
|
||||
className="disabled:opacity-100 disabled:bg-muted/50 disabled:text-muted-foreground bg-muted/50"
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
form.setValue("name", value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (isFullyEditable) {
|
||||
if (!form.formState.dirtyFields.slug) {
|
||||
form.setValue("slug", nameToSlug(value), {
|
||||
form.setValue("name", value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
<FormErrorMessage>{networkNameErrorMessage}</FormErrorMessage>
|
||||
</FormControl>
|
||||
if (isFullyEditable) {
|
||||
if (!form.formState.dirtyFields.slug) {
|
||||
form.setValue("slug", nameToSlug(value), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
ref={ref}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
{/* Slug URL */}
|
||||
<NetworkIDInput form={form} disabled={!isFullyEditable} />
|
||||
{/* Slug */}
|
||||
<NetworkIDInput form={form} disabled={!isFullyEditable} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 mt-6">
|
||||
{/* Chain ID + Currency Symbol */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ChainIdInput form={form} disabled={!isFullyEditable} />
|
||||
|
||||
{/* Currency Symbol */}
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Currency Symbol</FormLabel>
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
label="Currency Symbol"
|
||||
errorMessage={form.formState.errors.currencySymbol?.message}
|
||||
tooltip={undefined}
|
||||
htmlFor="currency-symbol"
|
||||
>
|
||||
<Input
|
||||
id="currency-symbol"
|
||||
disabled={!isFullyEditable}
|
||||
placeholder="e.g. ETH"
|
||||
placeholder="ETH"
|
||||
autoComplete="off"
|
||||
className="disabled:opacity-100 disabled:bg-muted/50 disabled:text-muted-foreground bg-muted/50 font-mono"
|
||||
type="text"
|
||||
{...form.register("currencySymbol", { required: true })}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Testnet / Mainnet */}
|
||||
<FormControl>
|
||||
<FormLabel className="!flex gap-1 items-center">
|
||||
Network type
|
||||
<TooltipBox
|
||||
content={
|
||||
<>
|
||||
<span className="mb-2 block">
|
||||
The network type indicates whether it is intended for
|
||||
production or testing.
|
||||
</span>
|
||||
<FormFieldSetup
|
||||
htmlFor="network-type"
|
||||
errorMessage={undefined}
|
||||
isRequired
|
||||
label="Type"
|
||||
tooltip={
|
||||
<span>
|
||||
<span className="mb-2 block">
|
||||
The network type indicates whether it is intended for
|
||||
production or testing.
|
||||
</span>
|
||||
|
||||
<span className="block">
|
||||
It{`'`}s only used for displaying network type on the
|
||||
dashboard and does not affect functionality.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</FormLabel>
|
||||
<span className="block">
|
||||
It{`'`}s only used for displaying network type on the
|
||||
dashboard and does not affect functionality.
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<RadioGroup
|
||||
onChange={(value: "testnet" | "mainnet") => {
|
||||
form.setValue("type", value, {
|
||||
id="network-type"
|
||||
value={form.watch("type")}
|
||||
onValueChange={(v) => {
|
||||
form.setValue("type", v === "testnet" ? "testnet" : "mainnet", {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
value={form.watch("type")}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex gap-4 mt-3">
|
||||
<Radio value="mainnet">Mainnet</Radio>
|
||||
<Radio value="testnet">Testnet</Radio>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="mainnet" id="mainnet" />
|
||||
<Label htmlFor="mainnet">Mainnet</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="testnet" id="testnet" />
|
||||
<Label htmlFor="testnet">Testnet</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
|
||||
{/* Icon */}
|
||||
<FormControl isInvalid={!!form.formState.errors.icon}>
|
||||
<FormLabel>Icon</FormLabel>
|
||||
|
||||
<FormFieldSetup
|
||||
isRequired={false}
|
||||
errorMessage={form.formState.errors.icon?.message}
|
||||
htmlFor="network-icon"
|
||||
label="Icon"
|
||||
tooltip={undefined}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
<ChainIcon size={24} ipfsSrc={form.watch("icon")} />
|
||||
<ChainIcon size={20} ipfsSrc={form.watch("icon")} />
|
||||
<IconUpload
|
||||
onUpload={(uri) => {
|
||||
form.setValue("icon", uri, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
|
||||
{editingChain?.status === "deprecated" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Active / Deprecated */}
|
||||
<FormControl>
|
||||
<FormLabel display="flex">Network status</FormLabel>
|
||||
<RadioGroup
|
||||
onChange={(value: "active" | "deprecated") => {
|
||||
form.setValue("status", value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
value={form.watch("status")}
|
||||
>
|
||||
<div className="flex mt-3 gap-4">
|
||||
<Radio value="active">Live</Radio>
|
||||
<Radio value="deprecated">Deprecated</Radio>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RPC URL */}
|
||||
<RpcInput form={form} />
|
||||
|
||||
{overwritingChain && (
|
||||
<Alert status="error" fontSize={"small"} borderRadius="md" p={3}>
|
||||
<AlertIcon />
|
||||
Chain ID {chainId} is being used by {`"${overwritingChain.name}"`}{" "}
|
||||
<br />
|
||||
Adding this network will overwrite it
|
||||
</Alert>
|
||||
<div className="p-3 text-sm text-destructive-text rounded-lg border border-border flex items-center gap-2">
|
||||
<CircleAlertIcon className="size-4" />
|
||||
<p>
|
||||
Chain ID {chainId} is taken by {`${overwritingChain.name}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-4 flex-col md:flex-row justify-end">
|
||||
{/* Add / Update Button */}
|
||||
{overwritingChain && !disableSubmit ? (
|
||||
<ConfirmationPopover
|
||||
onConfirm={handleSubmit}
|
||||
prompt="Are you sure?"
|
||||
confirmationText="Yes"
|
||||
description={
|
||||
<>
|
||||
This action can be reversed later by resetting this network
|
||||
{`'s`} config
|
||||
</>
|
||||
}
|
||||
>
|
||||
{submitBtn}
|
||||
</ConfirmationPopover>
|
||||
) : (
|
||||
submitBtn
|
||||
)}
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-4 flex-col md:flex-row justify-end p-6 bg-muted/50 border-t border-border">
|
||||
<Button type={"submit"} disabled={disableSubmit}>
|
||||
{editingChain ? "Update Network" : "Add Network"}
|
||||
</Button>
|
||||
|
||||
{editedFrom && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSubmit(editedFrom);
|
||||
removeChainModification(editedFrom.chainId);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editedFrom && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSubmit(editedFrom);
|
||||
removeChainOverrides(editedFrom.chainId);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import type { StoredChain } from "contexts/configured-chains";
|
||||
import { useSetEditChain } from "hooks/networkConfigModal";
|
||||
import {
|
||||
type StoredChain,
|
||||
addRecentlyUsedChainId,
|
||||
} from "../../stores/chainStores";
|
||||
import { ConfigureNetworks } from "./ConfigureNetworks";
|
||||
|
||||
interface AddNetworkModalProps {
|
||||
onClose: () => void;
|
||||
export type ConfigureNetworkModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onNetworkAdded?: (chain: StoredChain) => void;
|
||||
}
|
||||
editChain: StoredChain | undefined;
|
||||
};
|
||||
|
||||
export const ConfigureNetworkModal: React.FC<AddNetworkModalProps> = (
|
||||
export const ConfigureNetworkModal: React.FC<ConfigureNetworkModalProps> = (
|
||||
props,
|
||||
) => {
|
||||
const setEditChain = useSetEditChain();
|
||||
const onModalClose = () => {
|
||||
props.onClose();
|
||||
setEditChain(undefined);
|
||||
props.onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
onModalClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
className="p-0 z-[10001] max-w-[480px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<ConfigureNetworks
|
||||
onNetworkAdded={(chain) => {
|
||||
addRecentlyUsedChainId(chain.chainId);
|
||||
props.onNetworkAdded?.(chain);
|
||||
onModalClose();
|
||||
}}
|
||||
onNetworkConfigured={onModalClose}
|
||||
editChain={props.editChain}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { StoredChain } from "contexts/configured-chains";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAddRecentlyUsedChainId } from "hooks/chains/recentlyUsedChains";
|
||||
import { useModifyChain } from "hooks/chains/useModifyChain";
|
||||
import { useEditChain } from "hooks/networkConfigModal";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type StoredChain,
|
||||
addChainOverrides,
|
||||
addRecentlyUsedChainId,
|
||||
} from "../../stores/chainStores";
|
||||
import { ConfigureNetworkForm } from "./ConfigureNetworkForm";
|
||||
|
||||
function useChainConfigTrack() {
|
||||
@@ -22,16 +23,15 @@ interface ConfigureNetworksProps {
|
||||
onNetworkAdded?: (chain: StoredChain) => void;
|
||||
prefillSlug?: string;
|
||||
prefillChainId?: string;
|
||||
editChain: StoredChain | undefined;
|
||||
}
|
||||
|
||||
export const ConfigureNetworks: React.FC<ConfigureNetworksProps> = (props) => {
|
||||
const trackChainConfig = useChainConfigTrack();
|
||||
const addRecentlyUsedChainId = useAddRecentlyUsedChainId();
|
||||
const modifyChain = useModifyChain();
|
||||
const editChain = useEditChain();
|
||||
const { editChain } = props;
|
||||
|
||||
const handleSubmit = (chain: StoredChain) => {
|
||||
modifyChain(chain);
|
||||
addChainOverrides(chain);
|
||||
addRecentlyUsedChainId(chain.chainId);
|
||||
|
||||
if (chain.isCustom) {
|
||||
@@ -52,27 +52,31 @@ export const ConfigureNetworks: React.FC<ConfigureNetworksProps> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h3 className="text-2xl font-semibold tracking-tight mb-5">
|
||||
{editChain ? "Edit Network" : "Add Custom Network"}
|
||||
</h3>
|
||||
<div>
|
||||
<div className="px-6 pt-6">
|
||||
<h3 className="text-xl font-semibold tracking-tight">
|
||||
{editChain ? "Edit Network" : "Add Custom Network"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Modify the given chain */}
|
||||
{editChain && (
|
||||
<ConfigureNetworkForm
|
||||
editingChain={editChain}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{/* Modify the given chain */}
|
||||
{editChain && (
|
||||
<ConfigureNetworkForm
|
||||
editingChain={editChain}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom chain */}
|
||||
{!editChain && (
|
||||
<ConfigureNetworkForm
|
||||
prefillSlug={props.prefillSlug}
|
||||
prefillChainId={props.prefillChainId}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
{/* Custom chain */}
|
||||
{!editChain && (
|
||||
<ConfigureNetworkForm
|
||||
prefillSlug={props.prefillSlug}
|
||||
prefillChainId={props.prefillChainId}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormLabel } from "tw-components";
|
||||
import type { NetworkConfigFormData } from "../ConfigureNetworkForm";
|
||||
|
||||
export const ChainIdInput: React.FC<{
|
||||
@@ -9,15 +8,22 @@ export const ChainIdInput: React.FC<{
|
||||
disabled: boolean;
|
||||
}> = ({ form, disabled }) => {
|
||||
return (
|
||||
<FormControl
|
||||
<FormFieldSetup
|
||||
label="Chain ID"
|
||||
htmlFor="chain-id"
|
||||
isRequired
|
||||
isInvalid={form.formState.errors.chainId?.type === "taken"}
|
||||
errorMessage={
|
||||
form.formState.errors.chainId?.type === "taken"
|
||||
? form.formState.errors.chainId?.message
|
||||
: undefined
|
||||
}
|
||||
tooltip={undefined}
|
||||
>
|
||||
<FormLabel>Chain ID</FormLabel>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
placeholder="e.g. 152"
|
||||
id="chain-id"
|
||||
autoComplete="off"
|
||||
className="disabled:opacity-100 disabled:bg-muted/50 disabled:text-muted-foreground bg-muted/50 font-mono"
|
||||
onKeyDown={(e) => {
|
||||
// prevent typing e, +, -
|
||||
if (e.key === "e" || e.key === "+" || e.key === "-") {
|
||||
@@ -29,6 +35,6 @@ export const ChainIdInput: React.FC<{
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import {
|
||||
ButtonGroup,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverFooter,
|
||||
PopoverHeader,
|
||||
PopoverTrigger,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button, Card, Heading, Text } from "tw-components";
|
||||
|
||||
export const ConfirmationPopover: React.FC<{
|
||||
onConfirm: () => void;
|
||||
children: React.ReactNode;
|
||||
prompt: string;
|
||||
description?: React.ReactNode;
|
||||
confirmationText: React.ReactNode;
|
||||
}> = (props) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
return (
|
||||
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
|
||||
<PopoverTrigger>{props.children}</PopoverTrigger>
|
||||
<Card
|
||||
maxW="md"
|
||||
mr={4}
|
||||
mb={1}
|
||||
w="330px"
|
||||
p={2}
|
||||
as={PopoverContent}
|
||||
bg="backgroundBody"
|
||||
boxShadow="0px 0px 2px 0px var(--popper-arrow-shadow-color)"
|
||||
>
|
||||
<PopoverArrow bg="backgroundBody" />
|
||||
|
||||
<PopoverHeader border="none">
|
||||
<Heading size="label.lg" mb={4}>
|
||||
{props.prompt}
|
||||
</Heading>
|
||||
|
||||
<Text mb={4}> {props.description} </Text>
|
||||
</PopoverHeader>
|
||||
<PopoverFooter border="none" display="flex">
|
||||
<ButtonGroup size="sm">
|
||||
<Button colorScheme="red" onClick={props.onConfirm}>
|
||||
{props.confirmationText}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</PopoverFooter>
|
||||
</Card>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -36,14 +36,22 @@ export const IconUpload: React.FC<{ onUpload: (url: string) => void }> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<FileInput setValue={handleIconUpload} accept={{ "image/*": [] }}>
|
||||
<Button variant="ghost" className="gap-2 px-2" size="sm">
|
||||
{storageUpload.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<FiUpload className="size-4" />
|
||||
)}
|
||||
<FileInput
|
||||
setValue={handleIconUpload}
|
||||
accept={{ "image/*": [] }}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-1.5 px-1 py-0 !h-auto hover:bg-transparent text-muted-foreground hover:text-foreground"
|
||||
size="sm"
|
||||
>
|
||||
Upload Icon
|
||||
{storageUpload.isPending ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<FiUpload className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</FileInput>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,49 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
import type { NetworkConfigFormData } from "../ConfigureNetworkForm";
|
||||
import { TooltipBox } from "./TooltipBox";
|
||||
|
||||
export const NetworkIDInput: React.FC<{
|
||||
form: UseFormReturn<NetworkConfigFormData>;
|
||||
disabled?: boolean;
|
||||
}> = ({ form, disabled }) => {
|
||||
const slug = form.watch("slug");
|
||||
const { slugToChainRecord } = useAllChainsData();
|
||||
const { slugToChain } = useAllChainsData();
|
||||
const existingChain = slugToChain.get(slug);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
<FormFieldSetup
|
||||
htmlFor="slug"
|
||||
isRequired
|
||||
mt={6}
|
||||
isInvalid={form.formState.errors.slug?.type === "taken"}
|
||||
label="Slug"
|
||||
tooltip={
|
||||
<span>
|
||||
Network slug is used to identify the network in the thirdweb
|
||||
dashboard.
|
||||
<p className="font-semibold mt-2 mb-1">Example</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{"thirdweb.com/<slug>/..."}
|
||||
</p>
|
||||
</span>
|
||||
}
|
||||
errorMessage={
|
||||
form.formState.errors.slug?.type === "taken" ? (
|
||||
<>
|
||||
Can not use {`"${slug}"`}.{" "}
|
||||
{slug &&
|
||||
existingChain &&
|
||||
`It is being used by "${existingChain.name}"`}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<FormLabel className="!flex items-center gap-1">
|
||||
Network ID
|
||||
<TooltipBox
|
||||
content={
|
||||
<>
|
||||
Network ID is used to identify the network in the URL
|
||||
<p className="font-semibold mt-2 mb-1">Example</p>
|
||||
<p className="text-link-foreground text-xs">
|
||||
{"thirdweb.com/<network-id>/<contract-address>"}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</FormLabel>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
placeholder="e.g. ethereum"
|
||||
placeholder="ethereum"
|
||||
id="slug"
|
||||
className="disabled:opacity-100 disabled:bg-muted/50 disabled:text-muted-foreground bg-muted/50 font-mono"
|
||||
onKeyDown={(e) => {
|
||||
// only allow alphanumeric characters and dashes
|
||||
if (!/^[a-z0-9-]*$/i.test(e.key)) {
|
||||
@@ -52,22 +59,11 @@ export const NetworkIDInput: React.FC<{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !(_slug in slugToChainRecord);
|
||||
return !slugToChain.has(_slug);
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
Can not use Network ID {`"${slug}"`}.
|
||||
{slug && slug in slugToChainRecord && (
|
||||
<>
|
||||
{" "}
|
||||
It is being used by {`"`}
|
||||
{slugToChainRecord[slug].name}
|
||||
{`"`}
|
||||
</>
|
||||
)}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Alert, AlertIcon, FormControl } from "@chakra-ui/react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { IoWarning } from "react-icons/io5";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import type { NetworkConfigFormData } from "../ConfigureNetworkForm";
|
||||
|
||||
export const RpcInput: React.FC<{
|
||||
@@ -23,21 +21,26 @@ export const RpcInput: React.FC<{
|
||||
});
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.rpcUrl}
|
||||
isDisabled={form.watch("status") !== "active"}
|
||||
errorMessage={form.formState.errors.rpcUrl?.message}
|
||||
htmlFor="rpc-url"
|
||||
tooltip={undefined}
|
||||
label="RPC URL"
|
||||
>
|
||||
<FormLabel>RPC URL</FormLabel>
|
||||
<Input autoComplete="off" placeholder="https://" type="url" {...reg} />
|
||||
<Input
|
||||
id="rpc-url"
|
||||
autoComplete="off"
|
||||
placeholder="https://"
|
||||
type="url"
|
||||
className="bg-muted/50"
|
||||
{...reg}
|
||||
/>
|
||||
|
||||
<FormErrorMessage fontSize="12px">Invalid RPC URL</FormErrorMessage>
|
||||
|
||||
<Alert bg="transparent" p={0} mt={2} fontSize="12px" color="paragraph">
|
||||
<AlertIcon as={IoWarning} color="inherit" />
|
||||
Only add custom networks that you trust. <br /> Malicious RPCs can
|
||||
record activity and lie about the state of the network.
|
||||
</Alert>
|
||||
</FormControl>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-normal">
|
||||
Only add RPC URL that you trust. Malicious RPCs can record activity and
|
||||
lie about the state of the network.
|
||||
</p>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
|
||||
export const TooltipBox: React.FC<{
|
||||
content: React.ReactNode;
|
||||
}> = ({ content }) => {
|
||||
return (
|
||||
<ToolTipLabel label={<div>{content}</div>}>
|
||||
<CircleHelpIcon className="size-4 text-muted-foreground" />
|
||||
</ToolTipLabel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
ConfigureNetworkModal,
|
||||
type ConfigureNetworkModalProps,
|
||||
} from "./ConfigureNetworkModal";
|
||||
|
||||
export function LazyConfigureNetworkModal(props: ConfigureNetworkModalProps) {
|
||||
const [hasEverOpened, setHasEverOpened] = useState(false);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
setHasEverOpened(true);
|
||||
}
|
||||
}, [props.open]);
|
||||
|
||||
if (hasEverOpened) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ConfigureNetworkModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+48
-18
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
@@ -22,7 +23,7 @@ import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { replaceTemplateValues } from "lib/deployment/template-values";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { CircleAlertIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { FormProvider, type UseFormReturn, useForm } from "react-hook-form";
|
||||
@@ -394,6 +395,16 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// handle split things
|
||||
const payees: string[] = [];
|
||||
const shares: string[] = [];
|
||||
if (isSplit && params.recipients?.length) {
|
||||
for (const recipient of params.recipients) {
|
||||
payees.push(recipient.address);
|
||||
shares.push(recipient.sharesBps.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.name === "MarketplaceV3") {
|
||||
// special case for marketplace
|
||||
return await deployMarketplaceContract({
|
||||
@@ -416,6 +427,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
const initializeParams = {
|
||||
...params.contractMetadata,
|
||||
...params.deployParams,
|
||||
payees,
|
||||
shares,
|
||||
_contractURI,
|
||||
};
|
||||
|
||||
@@ -468,6 +481,9 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
enabled: walletChain !== undefined && metadata !== undefined,
|
||||
});
|
||||
|
||||
const shouldShowDeterministicDeployWarning =
|
||||
constructorParams.length > 0 && form.watch("deployDeterministic");
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
@@ -825,23 +841,37 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
{metadata?.deployType === "standard" && (
|
||||
<>
|
||||
{/* Deterministic deploy */}
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
{...form.register("deployDeterministic")}
|
||||
checked={form.watch("deployDeterministic")}
|
||||
onCheckedChange={(c) =>
|
||||
form.setValue("deployDeterministic", !!c)
|
||||
}
|
||||
/>
|
||||
<ToolTipLabel label="Allows having the same contract address on multiple chains. You can control the address by specifying a salt for create2 deployment below">
|
||||
<div className="inline-flex gap-1.5 items-center">
|
||||
<span className="tex-sm">
|
||||
Deploy at a deterministic address
|
||||
</span>
|
||||
<FiHelpCircle className="size-4" />
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
</CheckboxWithLabel>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
{...form.register("deployDeterministic")}
|
||||
checked={form.watch("deployDeterministic")}
|
||||
onCheckedChange={(c) =>
|
||||
form.setValue("deployDeterministic", !!c)
|
||||
}
|
||||
/>
|
||||
<ToolTipLabel label="Allows having the same contract address on multiple chains. You can control the address by specifying a salt for create2 deployment below">
|
||||
<div className="inline-flex gap-1.5 items-center">
|
||||
<span className="tex-sm">
|
||||
Deploy at a deterministic address
|
||||
</span>
|
||||
<FiHelpCircle className="size-4" />
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
</CheckboxWithLabel>
|
||||
|
||||
{shouldShowDeterministicDeployWarning && (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Deterministic deployment would only result in the same
|
||||
contract address if you use the same contructor params
|
||||
on every deployment.
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional Salt Input */}
|
||||
{isCreate2Deployment && (
|
||||
|
||||
+6
-7
@@ -1,9 +1,8 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { Select } from "chakra-react-select";
|
||||
import type { SizeProp } from "chakra-react-select";
|
||||
import { useSupportedChains } from "hooks/chains/configureChains";
|
||||
import { useMemo } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
|
||||
interface NetworkDropdownProps {
|
||||
useCleanChainName?: boolean;
|
||||
@@ -27,10 +26,10 @@ export const NetworkDropdown: React.FC<NetworkDropdownProps> = ({
|
||||
size = "md",
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
const supportedChains = useSupportedChains();
|
||||
const { allChains } = useAllChainsData();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return supportedChains.map((chain) => {
|
||||
return allChains.map((chain) => {
|
||||
return {
|
||||
label: useCleanChainName
|
||||
? cleanChainName(chain.name)
|
||||
@@ -38,7 +37,7 @@ export const NetworkDropdown: React.FC<NetworkDropdownProps> = ({
|
||||
value: chain.chainId,
|
||||
};
|
||||
});
|
||||
}, [supportedChains, useCleanChainName]);
|
||||
}, [allChains, useCleanChainName]);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const networksEnabled = form?.watch(
|
||||
@@ -54,7 +53,7 @@ export const NetworkDropdown: React.FC<NetworkDropdownProps> = ({
|
||||
}, [form, options]);
|
||||
|
||||
return (
|
||||
<Flex gap={2} alignItems="center" w="full">
|
||||
<div className="flex flex-row gap-2 items-center w-full">
|
||||
<Select
|
||||
size={size}
|
||||
placeholder={`${
|
||||
@@ -106,6 +105,6 @@ export const NetworkDropdown: React.FC<NetworkDropdownProps> = ({
|
||||
: options.find(({ value: val }) => val === value)
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-3
@@ -1,4 +1,3 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import type { Abi } from "abitype";
|
||||
import { Select } from "chakra-react-select";
|
||||
import { useMemo } from "react";
|
||||
@@ -27,7 +26,7 @@ export const AbiSelector: React.FC<AbiSelectorProps> = ({
|
||||
}, [abi]);
|
||||
|
||||
return (
|
||||
<Flex gap={2} alignItems="center" w="full">
|
||||
<div className="flex flex-row gap-2 items-center w-full">
|
||||
<Select
|
||||
placeholder="Select function"
|
||||
options={options}
|
||||
@@ -45,6 +44,6 @@ export const AbiSelector: React.FC<AbiSelectorProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ export function ContractPublishForm(props: {
|
||||
: initializerParams;
|
||||
|
||||
// during loading and after success we should stay in loading state
|
||||
const isLoading = sendTx.isPending || sendTx.isSuccess;
|
||||
const isPending = sendTx.isPending || sendTx.isSuccess;
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
window?.scrollTo({
|
||||
@@ -400,7 +400,7 @@ export function ContractPublishForm(props: {
|
||||
position="relative"
|
||||
role="group"
|
||||
colorScheme="blue"
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="contract-release-form"
|
||||
isDisabled={isDisabled}
|
||||
loadingText={
|
||||
|
||||
@@ -96,7 +96,7 @@ function ImportForm() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const registry = useAllContractList(address);
|
||||
|
||||
const isLoading =
|
||||
const showLoading =
|
||||
form.formState.isSubmitting ||
|
||||
addToDashboard.isPending ||
|
||||
addToDashboard.isSuccess ||
|
||||
@@ -205,13 +205,13 @@ function ImportForm() {
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" className="gap-2">
|
||||
{isLoading ? (
|
||||
{showLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<PlusIcon className="size-4" />
|
||||
)}
|
||||
|
||||
{isLoading
|
||||
{showLoading
|
||||
? addToDashboard.isSuccess || isRedirecting
|
||||
? "Redirecting"
|
||||
: "Importing contract"
|
||||
|
||||
+38
-40
@@ -14,7 +14,7 @@ export const AddressesModal: React.FC<AddressesModalProps> = ({
|
||||
buttonTitle,
|
||||
modalTitle,
|
||||
}) => {
|
||||
const { chainIdToChainRecord } = useAllChainsData();
|
||||
const { idToChain } = useAllChainsData();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
return (
|
||||
@@ -35,47 +35,45 @@ export const AddressesModal: React.FC<AddressesModalProps> = ({
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
{chainIdToChainRecord && (
|
||||
<Drawer
|
||||
size="lg"
|
||||
allowPinchZoom
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
header={{
|
||||
children: (
|
||||
<Heading as="h3" size="title.sm">
|
||||
{modalTitle}
|
||||
</Heading>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Flex flexDir="column" gap={{ base: 2, md: 1 }}>
|
||||
{Object.entries(chainAddressRecord).map(([chainId, address]) =>
|
||||
!address ? null : (
|
||||
<Flex
|
||||
key={chainId}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
alignItems={{ base: "auto", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
<Drawer
|
||||
size="lg"
|
||||
allowPinchZoom
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
header={{
|
||||
children: (
|
||||
<Heading as="h3" size="title.sm">
|
||||
{modalTitle}
|
||||
</Heading>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Flex flexDir="column" gap={{ base: 2, md: 1 }}>
|
||||
{Object.entries(chainAddressRecord).map(([chainId, address]) =>
|
||||
!address ? null : (
|
||||
<Flex
|
||||
key={chainId}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
alignItems={{ base: "auto", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text size="body.md">
|
||||
{idToChain.get(Number.parseInt(chainId))?.name}
|
||||
</Text>
|
||||
<Link
|
||||
href={`${THIRDWEB_DOMAIN}/${chainId}/${address}`}
|
||||
isExternal
|
||||
color="blue.500"
|
||||
>
|
||||
<Text size="body.md">
|
||||
{chainIdToChainRecord[Number.parseInt(chainId)]?.name}
|
||||
<Text color="inherit" fontFamily="mono">
|
||||
{address}
|
||||
</Text>
|
||||
<Link
|
||||
href={`${THIRDWEB_DOMAIN}/${chainId}/${address}`}
|
||||
isExternal
|
||||
color="blue.500"
|
||||
>
|
||||
<Text color="inherit" fontFamily="mono">
|
||||
{address}
|
||||
</Text>
|
||||
</Link>
|
||||
</Flex>
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
</Drawer>
|
||||
)}
|
||||
</Link>
|
||||
</Flex>
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ interface PublisherAvatarProps extends Omit<MaskedAvatarProps, "src"> {
|
||||
|
||||
export const PublisherAvatar: React.FC<PublisherAvatarProps> = ({
|
||||
address,
|
||||
isLoading,
|
||||
isPending,
|
||||
...restProps
|
||||
}) => {
|
||||
const client = useThirdwebClient();
|
||||
@@ -24,7 +24,7 @@ export const PublisherAvatar: React.FC<PublisherAvatarProps> = ({
|
||||
);
|
||||
return (
|
||||
<MaskedAvatar
|
||||
isLoading={isLoading || ensQuery.isLoading || publisherProfile.isLoading}
|
||||
isPending={isPending || ensQuery.isPending || publisherProfile.isPending}
|
||||
src={
|
||||
publisherProfile.data?.avatar
|
||||
? resolveScheme({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import type { Abi } from "abitype";
|
||||
import { Link, Text } from "tw-components";
|
||||
import type { SourceFile } from "../types";
|
||||
@@ -11,8 +10,8 @@ interface SourcesPanelProps {
|
||||
|
||||
export const SourcesPanel: React.FC<SourcesPanelProps> = ({ sources, abi }) => {
|
||||
return (
|
||||
<Flex flexDir="column" gap={8}>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sources && sources?.length > 0 ? (
|
||||
<SourcesAccordion sources={sources} abi={abi} />
|
||||
) : (
|
||||
@@ -27,7 +26,7 @@ export const SourcesPanel: React.FC<SourcesPanelProps> = ({ sources, abi }) => {
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,9 +26,6 @@ import {
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { NetworkSelectDropdown } from "components/selects/NetworkSelectDropdown";
|
||||
import type { BasicContract } from "contract-ui/types/types";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useChainSlug } from "hooks/chains/chainSlug";
|
||||
import { useSupportedChainsRecord } from "hooks/chains/configureChains";
|
||||
import {
|
||||
DownloadIcon,
|
||||
EllipsisVerticalIcon,
|
||||
@@ -45,6 +42,8 @@ import {
|
||||
usePagination,
|
||||
useTable,
|
||||
} from "react-table";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
import { useChainSlug } from "../../../hooks/chains/chainSlug";
|
||||
import { ImportModal } from "../import-contract/modal";
|
||||
import { AsyncContractNameCell, AsyncContractTypeCell } from "./cells";
|
||||
import { ShowMoreButton } from "./show-more-button";
|
||||
@@ -115,7 +114,7 @@ export const DeployedContracts: React.FC<DeployedContractsProps> = ({
|
||||
combinedList={contractListQuery.data}
|
||||
limit={limit}
|
||||
chainIdsWithDeployments={chainIdsWithDeployments}
|
||||
loading={contractListQuery.isLoading}
|
||||
loading={contractListQuery.isPending}
|
||||
/>
|
||||
|
||||
{contractListQuery.data.length === 0 && contractListQuery.isFetched && (
|
||||
@@ -198,8 +197,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
|
||||
chainIdsWithDeployments,
|
||||
loading,
|
||||
}) => {
|
||||
const { chainIdToChainRecord } = useAllChainsData();
|
||||
const configuredChains = useSupportedChainsRecord();
|
||||
const { idToChain } = useAllChainsData();
|
||||
|
||||
const columns: Column<(typeof combinedList)[number]>[] = useMemo(
|
||||
() => [
|
||||
@@ -231,9 +229,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
|
||||
filter: "equals",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: (cell: any) => {
|
||||
const data =
|
||||
configuredChains[cell.row.original.chainId] ||
|
||||
chainIdToChainRecord[cell.row.original.chainId];
|
||||
const data = idToChain.get(cell.row.original.chainId);
|
||||
const cleanedChainName =
|
||||
data?.name?.replace("Mainnet", "").trim() ||
|
||||
`Unknown Network (#${cell.row.original.chainId})`;
|
||||
@@ -296,7 +292,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
|
||||
},
|
||||
},
|
||||
],
|
||||
[configuredChains, chainIdsWithDeployments, chainIdToChainRecord],
|
||||
[chainIdsWithDeployments, idToChain],
|
||||
);
|
||||
|
||||
const defaultColumn = useMemo(
|
||||
|
||||
@@ -89,7 +89,7 @@ export const PublishedContracts: React.FC<PublishedContractsProps> = ({
|
||||
contractDetails={slicedData}
|
||||
hidePublisher
|
||||
>
|
||||
{publishedContractsQuery.isLoading && (
|
||||
{publishedContractsQuery.isPending && (
|
||||
<Center>
|
||||
<Flex py={4} direction="row" gap={4} align="center">
|
||||
<Spinner size="sm" />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { formatDistance } from "date-fns/formatDistance";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { Link } from "tw-components";
|
||||
@@ -17,17 +16,12 @@ interface ChangelogProps {
|
||||
|
||||
export const Changelog: React.FC<ChangelogProps> = ({ changelog }) => {
|
||||
return (
|
||||
<Flex
|
||||
flexDir="column"
|
||||
gap={6}
|
||||
position="relative"
|
||||
className="border-l border-border py-2"
|
||||
>
|
||||
<div className="border-l border-border py-2 flex flex-col gap-6 relative">
|
||||
{changelog.map((item) => (
|
||||
<Flex key={item.title} gap={4}>
|
||||
<div className="flex flex-row gap-4" key={item.title}>
|
||||
<div className="size-2.5 bg-border rounded-full shrink-0 -translate-x-1/2 translate-y-1/2" />
|
||||
|
||||
<Flex flexDir="column">
|
||||
<div className="flex flex-col">
|
||||
<Link
|
||||
isExternal
|
||||
href={`${item.url}?utm_source=thirdweb&utm_campaign=changelog`}
|
||||
@@ -43,8 +37,8 @@ export const Changelog: React.FC<ChangelogProps> = ({ changelog }) => {
|
||||
})}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
href="https://blog.thirdweb.com/changelog?utm_source=thirdweb&utm_campaign=changelog"
|
||||
@@ -53,6 +47,6 @@ export const Changelog: React.FC<ChangelogProps> = ({ changelog }) => {
|
||||
>
|
||||
View More <ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ export const InAppWalletUsersPageContent = (props: {
|
||||
title="active in-app wallets"
|
||||
data={itemsToShow}
|
||||
columns={columns}
|
||||
isLoading={walletsQuery.isLoading}
|
||||
isPending={walletsQuery.isPending}
|
||||
isFetched={walletsQuery.isFetched}
|
||||
/>
|
||||
|
||||
|
||||
@@ -46,14 +46,18 @@ const sidebarLinkMeta: Array<{ pathId: string; label: string }> = [
|
||||
pathId: "webhooks",
|
||||
label: "Webhooks",
|
||||
},
|
||||
{
|
||||
pathId: "configuration",
|
||||
label: "Configuration",
|
||||
},
|
||||
{
|
||||
pathId: "metrics",
|
||||
label: "Metrics",
|
||||
},
|
||||
{
|
||||
pathId: "alerts",
|
||||
label: "Alerts",
|
||||
},
|
||||
{
|
||||
pathId: "configuration",
|
||||
label: "Configuration",
|
||||
},
|
||||
];
|
||||
|
||||
const NEXT_PUBLIC_DEMO_ENGINE_URL = process.env.NEXT_PUBLIC_DEMO_ENGINE_URL;
|
||||
@@ -121,7 +125,7 @@ function QueryAndRenderInstanceHeader(props: {
|
||||
const instancesQuery = useEngineInstances();
|
||||
const instance = instancesQuery.data?.find((x) => x.id === props.engineId);
|
||||
|
||||
if (instancesQuery.isLoading) {
|
||||
if (instancesQuery.isPending) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
@@ -151,7 +155,7 @@ function EnsurePermissionAndRenderInstance(props: {
|
||||
instanceUrl: props.instance.url,
|
||||
});
|
||||
|
||||
if (permissionQuery.isLoading) {
|
||||
if (permissionQuery.isPending) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type EngineAlertRule,
|
||||
EngineNotificationChannelTypeConfig,
|
||||
} from "../../../@3rdweb-sdk/react/hooks/useEngine";
|
||||
|
||||
const alertFormSchema = z.object({
|
||||
subscriptionRoutes: z.array(z.string()),
|
||||
type: z.enum(
|
||||
Object.keys(EngineNotificationChannelTypeConfig) as [
|
||||
keyof typeof EngineNotificationChannelTypeConfig,
|
||||
],
|
||||
),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
type EngineAlertFormValues = z.infer<typeof alertFormSchema>;
|
||||
|
||||
export function EngineAlertDialogForm(props: {
|
||||
alertRules: EngineAlertRule[];
|
||||
title: string;
|
||||
values: EngineAlertFormValues | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
submitButton: {
|
||||
isLoading: boolean;
|
||||
label: string;
|
||||
onSubmit: (value: EngineAlertFormValues) => void;
|
||||
};
|
||||
}) {
|
||||
const form = useForm<z.infer<typeof alertFormSchema>>({
|
||||
resolver: zodResolver(alertFormSchema),
|
||||
defaultValues: {
|
||||
subscriptionRoutes: ["alert.*"],
|
||||
type: "slack",
|
||||
},
|
||||
values: props.values ?? undefined,
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof alertFormSchema>) {
|
||||
props.submitButton.onSubmit(values);
|
||||
}
|
||||
|
||||
const alertRuleOptions: EngineAlertRule[] = [
|
||||
{
|
||||
id: "_all_alerts",
|
||||
routingKey: "alert.*",
|
||||
title: "All alerts",
|
||||
description: "Get notified of all alerts.",
|
||||
createdAt: new Date(),
|
||||
pausedAt: null,
|
||||
},
|
||||
...props.alertRules,
|
||||
];
|
||||
|
||||
const selectedAlertRule = alertRuleOptions.find(
|
||||
(alertRule) =>
|
||||
alertRule.routingKey === form.getValues("subscriptionRoutes")[0],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
className="p-0 z-[10001]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight">
|
||||
{props.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Alert Rule */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subscriptionRoutes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alert Rule</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange([value])}
|
||||
value={field.value[0] ?? "alert.*"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectGroup>
|
||||
{alertRuleOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.routingKey}
|
||||
>
|
||||
{option.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
{selectedAlertRule?.description && (
|
||||
<FormDescription>
|
||||
{selectedAlertRule.description}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Notification Type */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notification Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select Notification Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectGroup>
|
||||
{Object.entries(
|
||||
EngineNotificationChannelTypeConfig,
|
||||
).map(([type, config]) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{config.display}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Value (webhook URL, Slack URL, email address, etc.) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{EngineNotificationChannelTypeConfig[
|
||||
form.getValues("type")
|
||||
].valueDisplay ?? "Value"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type={
|
||||
form.getValues("type") === "email"
|
||||
? "email"
|
||||
: "text"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-6 bg-muted/50 border-t border-border mt-4 gap-4 lg:gap-2 ">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
props.onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-w-28 gap-2"
|
||||
disabled={props.submitButton.isLoading}
|
||||
>
|
||||
{props.submitButton.isLoading && <Spinner className="size-4" />}
|
||||
{props.submitButton.label}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
type EngineInstance,
|
||||
useEngineAlertRules,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { ManageEngineAlertsSection } from "./ManageEngineAlerts";
|
||||
import { RecentEngineAlertsSection } from "./RecentEngineAlerts";
|
||||
|
||||
export function EngineAlertsPage(props: {
|
||||
instance: EngineInstance;
|
||||
}) {
|
||||
const alertRulesQuery = useEngineAlertRules(props.instance.id);
|
||||
const alertRules = alertRulesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ManageEngineAlertsSection
|
||||
alertRules={alertRules}
|
||||
engineId={props.instance.id}
|
||||
alertRulesIsLoading={alertRulesQuery.isLoading}
|
||||
/>
|
||||
<div className="h-8" />
|
||||
<RecentEngineAlertsSection
|
||||
alertRules={alertRules}
|
||||
engineId={props.instance.id}
|
||||
alertRulesIsLoading={alertRulesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
|
||||
export function EngineDeleteAlertModal(props: {
|
||||
onConfirm: () => void;
|
||||
isPending: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className="p-0">
|
||||
<DialogHeader className="mb-4 p-6">
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight">
|
||||
Delete Alert
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
You will no longer receive notifications from this webhook.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="p-6 bg-muted/50 border-t border-border gap-4 lg:gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
props.onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-28 gap-2"
|
||||
variant="destructive"
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
{props.isPending && <Spinner className="size-4" />}
|
||||
Delete Webhook
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
CreateNotificationChannelInput,
|
||||
EngineNotificationChannel,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import {
|
||||
createEngineAlertRuleStub,
|
||||
createEngineNotificationChannelStub,
|
||||
} from "../../../stories/stubs";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { ManageEngineAlertsSectionUI } from "./ManageEngineAlerts";
|
||||
|
||||
const meta = {
|
||||
title: "engine/alerts/manage",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
const createEngineAlertMutation = useMutation({
|
||||
mutationFn: async (values: CreateNotificationChannelInput) => {
|
||||
console.log("creating alert using input:", values);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const channelStub: EngineNotificationChannel = {
|
||||
createdAt: new Date(),
|
||||
id: "new-channel",
|
||||
pausedAt: null,
|
||||
subscriptionRoutes: values.subscriptionRoutes,
|
||||
value: values.value,
|
||||
type: values.type,
|
||||
};
|
||||
|
||||
console.log("created channel:", channelStub);
|
||||
return channelStub;
|
||||
},
|
||||
});
|
||||
|
||||
async function deleteAlert(alertId: string) {
|
||||
console.log("deleting alert:", alertId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 container max-w-[1154px] flex flex-col gap-14">
|
||||
<BadgeContainer label="2 Alerts">
|
||||
<ManageEngineAlertsSectionUI
|
||||
isLoading={false}
|
||||
onAlertsUpdated={() => {}}
|
||||
engineId="test-engine-id"
|
||||
createAlertMutation={createEngineAlertMutation}
|
||||
deleteAlert={deleteAlert}
|
||||
alertRules={[
|
||||
createEngineAlertRuleStub("foo"),
|
||||
createEngineAlertRuleStub("bar"),
|
||||
]}
|
||||
notificationChannels={[
|
||||
createEngineNotificationChannelStub("foo"),
|
||||
createEngineNotificationChannelStub("foo", {
|
||||
pausedAt: null,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No Alerts">
|
||||
<ManageEngineAlertsSectionUI
|
||||
alertRules={[
|
||||
createEngineAlertRuleStub("foo"),
|
||||
createEngineAlertRuleStub("bar"),
|
||||
]}
|
||||
engineId="test-engine-id"
|
||||
notificationChannels={[]}
|
||||
isLoading={false}
|
||||
onAlertsUpdated={() => {}}
|
||||
createAlertMutation={createEngineAlertMutation}
|
||||
deleteAlert={deleteAlert}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<ManageEngineAlertsSectionUI
|
||||
alertRules={[
|
||||
createEngineAlertRuleStub("foo"),
|
||||
createEngineAlertRuleStub("bar"),
|
||||
]}
|
||||
engineId="test-engine-id"
|
||||
notificationChannels={[]}
|
||||
isLoading={true}
|
||||
onAlertsUpdated={() => {}}
|
||||
createAlertMutation={createEngineAlertMutation}
|
||||
deleteAlert={deleteAlert}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No Alert Rules">
|
||||
<ManageEngineAlertsSectionUI
|
||||
alertRules={[]}
|
||||
engineId="test-engine-id"
|
||||
notificationChannels={[]}
|
||||
isLoading={false}
|
||||
onAlertsUpdated={() => {}}
|
||||
createAlertMutation={createEngineAlertMutation}
|
||||
deleteAlert={deleteAlert}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user