Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -15,14 +15,15 @@ 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 { LazyConfigureNetworkModal } from "../../../../components/configure-networks/LazyConfigureNetworkModal";
|
||||
import type { StoredChain } from "../../../../contexts/configured-chains";
|
||||
import { useFavoriteChains } from "../../hooks/useFavoriteChains";
|
||||
import { useLoggedInUser } from "../../hooks/useLoggedInUser";
|
||||
import { popularChains } from "../popularChains";
|
||||
@@ -38,12 +39,10 @@ export const CustomConnectWallet = (props: {
|
||||
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]);
|
||||
@@ -52,6 +51,12 @@ export const CustomConnectWallet = (props: {
|
||||
return popularChains.map((x) => chainsRecord[x.id] || x);
|
||||
}, [chainsRecord]);
|
||||
|
||||
const [isNetworkConfigModalOpen, setIsNetworkConfigModalOpen] =
|
||||
useState(false);
|
||||
const [editChain, setEditChain] = useState<StoredChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const chainSections = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -70,10 +75,10 @@ export const CustomConnectWallet = (props: {
|
||||
}, [recentChainsv4, favChainsQuery.data, popularChainsWithMeta]);
|
||||
|
||||
// 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">
|
||||
@@ -102,50 +107,69 @@ export const CustomConnectWallet = (props: {
|
||||
}
|
||||
|
||||
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={allChains}
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useFavoriteChains() {
|
||||
}, [favChainsQuery.data, allChains]);
|
||||
|
||||
return {
|
||||
isLoading: favChainsQuery.isLoading,
|
||||
isPending: favChainsQuery.isPending,
|
||||
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 }
|
||||
|
||||
+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" />
|
||||
|
||||
@@ -92,7 +92,7 @@ export function StarButton(props: {
|
||||
disabled={
|
||||
!loggedInUser.user?.address ||
|
||||
mutation.isPending ||
|
||||
favChainsQuery.isLoading
|
||||
favChainsQuery.isPending
|
||||
}
|
||||
>
|
||||
<ToolTipLabel label={label}>
|
||||
|
||||
+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>
|
||||
|
||||
+5
-5
@@ -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) => (
|
||||
@@ -53,7 +53,7 @@ export function PartnersTable({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...partners].reverse().map((partner: Partner) => (
|
||||
<EcosystemRow
|
||||
<PartnerRow
|
||||
key={partner.id}
|
||||
partner={partner}
|
||||
ecosystem={ecosystem}
|
||||
@@ -65,7 +65,7 @@ export function PartnersTable({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EcosystemRow(props: {
|
||||
function PartnerRow(props: {
|
||||
partner: Partner;
|
||||
ecosystem: Ecosystem;
|
||||
}) {
|
||||
@@ -114,7 +114,7 @@ function EcosystemRow(props: {
|
||||
: "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}
|
||||
|
||||
+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[],
|
||||
};
|
||||
}
|
||||
|
||||
+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[],
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
},
|
||||
});
|
||||
|
||||
if (webhooksQuery.isLoading) {
|
||||
if (webhooksQuery.isPending) {
|
||||
return <Spinner className="size-8 mx-auto" />;
|
||||
}
|
||||
|
||||
|
||||
+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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+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,7 @@
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useAccount, useAccountUsage } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { Flex, HStack } from "@chakra-ui/react";
|
||||
import { 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 +12,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,8 +21,8 @@ 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"
|
||||
@@ -32,9 +32,9 @@ export const SettingsUsagePage = () => {
|
||||
<BillingPlan account={account} />
|
||||
<BillingPeriod account={account} usage={usageQuery.data} />
|
||||
</HStack>
|
||||
</Flex>
|
||||
</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,14 +4,13 @@ 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";
|
||||
@@ -20,11 +19,6 @@ 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";
|
||||
@@ -62,7 +56,6 @@ export const AppLayout: ComponentWithChildren<AppLayoutProps> = (props) => {
|
||||
<DashboardThirdwebProvider>
|
||||
<SanctionedAddressesChecker>
|
||||
<PosthogIdentifier />
|
||||
<ConfigModal />
|
||||
|
||||
<OnboardingModal />
|
||||
<OpCreditsGrantedModalWrapper />
|
||||
@@ -96,31 +89,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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,42 +1,43 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import type { StoredChain } from "contexts/configured-chains";
|
||||
import { useSetEditChain } from "hooks/networkConfigModal";
|
||||
import { useAddRecentlyUsedChainId } from "../../hooks/chains/recentlyUsedChains";
|
||||
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 addRecentlyUsedChains = useAddRecentlyUsedChainId();
|
||||
|
||||
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) => {
|
||||
props.onNetworkAdded?.(chain);
|
||||
onModalClose();
|
||||
}}
|
||||
onNetworkConfigured={onModalClose}
|
||||
/>
|
||||
{/* TODO - remove after moving ConfigureNetworks to shadcn */}
|
||||
<ChakraProviderSetup>
|
||||
<ConfigureNetworks
|
||||
onNetworkAdded={(chain) => {
|
||||
addRecentlyUsedChains(chain.chainId);
|
||||
props.onNetworkAdded?.(chain);
|
||||
onModalClose();
|
||||
}}
|
||||
onNetworkConfigured={onModalClose}
|
||||
editChain={props.editChain}
|
||||
/>
|
||||
</ChakraProviderSetup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 { ConfigureNetworkForm } from "./ConfigureNetworkForm";
|
||||
|
||||
@@ -22,13 +21,14 @@ 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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+2
-3
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -115,7 +115,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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type CreateNotificationChannelInput,
|
||||
type EngineAlertRule,
|
||||
type EngineNotificationChannel,
|
||||
useEngineCreateNotificationChannel,
|
||||
useEngineDeleteNotificationChannel,
|
||||
useEngineNotificationChannels,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
|
||||
import { formatDate } from "date-fns";
|
||||
import { EllipsisVerticalIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { EngineAlertDialogForm } from "./EngineAlertDialogForm";
|
||||
import { EngineDeleteAlertModal } from "./EngineDeleteAlertModal";
|
||||
|
||||
type CreateAlertMutation = UseMutationResult<
|
||||
EngineNotificationChannel,
|
||||
unknown,
|
||||
CreateNotificationChannelInput,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export function ManageEngineAlertsSection(props: {
|
||||
alertRules: EngineAlertRule[];
|
||||
alertRulesIsLoading: boolean;
|
||||
engineId: string;
|
||||
}) {
|
||||
const notificationChannelsQuery = useEngineNotificationChannels(
|
||||
props.engineId,
|
||||
);
|
||||
const deleteAlertMutation = useEngineDeleteNotificationChannel(
|
||||
props.engineId,
|
||||
);
|
||||
|
||||
const createAlertMutation = useEngineCreateNotificationChannel(
|
||||
props.engineId,
|
||||
);
|
||||
|
||||
// not passing the mutation to avoid multiple rows sharing the same mutation state, we create the new mutation for each row instead in each component instead
|
||||
async function deleteAlert(notificationChannelId: string) {
|
||||
return deleteAlertMutation.mutate(notificationChannelId);
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageEngineAlertsSectionUI
|
||||
alertRules={props.alertRules}
|
||||
engineId={props.engineId}
|
||||
notificationChannels={notificationChannelsQuery.data ?? []}
|
||||
isLoading={
|
||||
notificationChannelsQuery.isLoading || props.alertRulesIsLoading
|
||||
}
|
||||
onAlertsUpdated={() => {
|
||||
notificationChannelsQuery.refetch();
|
||||
}}
|
||||
createAlertMutation={createAlertMutation}
|
||||
deleteAlert={deleteAlert}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManageEngineAlertsSectionUI(props: {
|
||||
alertRules: EngineAlertRule[];
|
||||
engineId: string;
|
||||
notificationChannels: EngineNotificationChannel[];
|
||||
isLoading: boolean;
|
||||
onAlertsUpdated: () => void;
|
||||
createAlertMutation: CreateAlertMutation;
|
||||
deleteAlert: (notificationChannelId: string) => Promise<void>;
|
||||
}) {
|
||||
const {
|
||||
engineId,
|
||||
notificationChannels,
|
||||
isLoading,
|
||||
onAlertsUpdated,
|
||||
createAlertMutation,
|
||||
deleteAlert,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex gap-4 lg:gap-6 flex-col lg:flex-row justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-1">
|
||||
Manage Alerts
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Get notified when alerts are triggered on your Engine instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CreateAlertButton
|
||||
engineId={engineId}
|
||||
alertRules={props.alertRules}
|
||||
onSuccess={onAlertsUpdated}
|
||||
createAlertMutation={createAlertMutation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
{notificationChannels.length === 0 || isLoading ? (
|
||||
<div className="flex justify-center items-center min-h-[200px] w-full border border-border rounded-lg">
|
||||
{isLoading ? <Spinner className="size-8" /> : "No alerts set up yet."}
|
||||
</div>
|
||||
) : (
|
||||
<EngineAlertsTableUI
|
||||
engineId={engineId}
|
||||
alertRules={props.alertRules}
|
||||
notificationChannels={notificationChannels}
|
||||
onAlertsUpdated={props.onAlertsUpdated}
|
||||
deleteAlert={deleteAlert}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineAlertsTableUI(props: {
|
||||
engineId: string;
|
||||
alertRules: EngineAlertRule[];
|
||||
notificationChannels: EngineNotificationChannel[];
|
||||
onAlertsUpdated: () => void;
|
||||
deleteAlert: (notificationChannelId: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Alert Rule</TableHead>
|
||||
<TableHead>Notification Type</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.notificationChannels.map((notificationChannel) => {
|
||||
const isPaused = notificationChannel.pausedAt !== null;
|
||||
|
||||
return (
|
||||
<TableRow key={notificationChannel.id}>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={isPaused ? "warning" : "default"}
|
||||
className="text-sm"
|
||||
>
|
||||
{isPaused ? "Paused" : "Active"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{getMatchingAlertRules(
|
||||
notificationChannel.subscriptionRoutes,
|
||||
props.alertRules,
|
||||
).map((alertRule) => (
|
||||
<p key={alertRule.routingKey}>{alertRule.title}</p>
|
||||
))}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-sm capitalize">
|
||||
{notificationChannel.type}
|
||||
</Badge>
|
||||
|
||||
<CopyTextButton
|
||||
textToCopy={notificationChannel.value}
|
||||
textToShow={truncateValue(notificationChannel.value)}
|
||||
copyIconPosition="left"
|
||||
tooltip="Copy"
|
||||
variant="ghost"
|
||||
iconClassName="size-3"
|
||||
className="-translate-x-2 max-w-[250px] text-muted-foreground"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(
|
||||
new Date(notificationChannel.createdAt),
|
||||
"MMM dd, yyyy",
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
<AlertOptionsButton
|
||||
alertRules={props.alertRules}
|
||||
engineId={props.engineId}
|
||||
notificationChannel={notificationChannel}
|
||||
onDeleteSuccess={props.onAlertsUpdated}
|
||||
deleteAlert={props.deleteAlert}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateAlertButton(props: {
|
||||
engineId: string;
|
||||
alertRules: EngineAlertRule[];
|
||||
onSuccess: () => void;
|
||||
createAlertMutation: CreateAlertMutation;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { createAlertMutation } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolTipLabel
|
||||
label={
|
||||
props.alertRules.length === 0
|
||||
? "This feature is only available for cloud-hosted Engines."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
disabled={props.alertRules.length === 0}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Alert
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
<EngineAlertDialogForm
|
||||
alertRules={props.alertRules}
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title="Add Alert"
|
||||
submitButton={{
|
||||
label: "Add Alert",
|
||||
isLoading: createAlertMutation.isPending,
|
||||
onSubmit: (values) => {
|
||||
const addPromise = createAlertMutation.mutateAsync(values);
|
||||
|
||||
toast.promise(addPromise, {
|
||||
success: "Alert added successfully",
|
||||
error: "Failed to add alert",
|
||||
});
|
||||
|
||||
addPromise.then(() => {
|
||||
setIsModalOpen(false);
|
||||
props.onSuccess();
|
||||
});
|
||||
},
|
||||
}}
|
||||
values={null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertOptionsButton(props: {
|
||||
alertRules: EngineAlertRule[];
|
||||
engineId: string;
|
||||
notificationChannel: EngineNotificationChannel;
|
||||
onDeleteSuccess: () => void;
|
||||
deleteAlert: (notificationChannelId: string) => Promise<void>;
|
||||
}) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: props.deleteAlert,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-36">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive-text hover:!bg-destructive"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<EngineDeleteAlertModal
|
||||
isPending={deleteMutation.isPending}
|
||||
onConfirm={() => {
|
||||
const deletePromise = deleteMutation.mutateAsync(
|
||||
props.notificationChannel.id,
|
||||
);
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
success: "Alert Deleted Successfully",
|
||||
error: "Failed To Delete Alert",
|
||||
});
|
||||
deletePromise.then(() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
props.onDeleteSuccess();
|
||||
});
|
||||
}}
|
||||
open={isDeleteModalOpen}
|
||||
onOpenChange={setIsDeleteModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getMatchingAlertRules = (
|
||||
subscriptionRoutes: string[],
|
||||
alertRules: EngineAlertRule[],
|
||||
): EngineAlertRule[] => {
|
||||
const results: EngineAlertRule[] = [];
|
||||
|
||||
for (const alertRule of alertRules) {
|
||||
for (const subscriptionRoute of subscriptionRoutes) {
|
||||
if (isRoutingKeyMatch(subscriptionRoute, alertRule.routingKey)) {
|
||||
results.push(alertRule);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if routingKey matches subscriptionRoute.
|
||||
*
|
||||
* Example: exact match
|
||||
* `alert.stuck-nonce` matches `alert.stuck-nonce`
|
||||
*
|
||||
* Example: wildcard
|
||||
* `alert.*` matches `alert.stuck-nonce`
|
||||
*/
|
||||
const isRoutingKeyMatch = (
|
||||
subscriptionRoute: string,
|
||||
alertRoutingKey: string,
|
||||
): boolean => {
|
||||
if (!subscriptionRoute.includes("*")) {
|
||||
// Check exact match if no wildcard.
|
||||
return subscriptionRoute === alertRoutingKey;
|
||||
}
|
||||
|
||||
const [prefix, suffix] = subscriptionRoute.split("*");
|
||||
if (prefix && !subscriptionRoute.startsWith(prefix)) return false;
|
||||
if (suffix && !subscriptionRoute.endsWith(suffix)) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const truncateValue = (str: string): string => {
|
||||
if (str.length <= 16) return str;
|
||||
return `${str.slice(0, 12)}...${str.slice(-4)}`;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Toaster } from "sonner";
|
||||
import {
|
||||
createEngineAlertRuleStub,
|
||||
createEngineAlertStub,
|
||||
} from "../../../stories/stubs";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { RecentEngineAlertsSectionUI } from "./RecentEngineAlerts";
|
||||
|
||||
const meta = {
|
||||
title: "engine/alerts/recent",
|
||||
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() {
|
||||
return (
|
||||
<div className="py-6 container max-w-[1154px] flex flex-col gap-14">
|
||||
<BadgeContainer label="3 Alerts">
|
||||
<RecentEngineAlertsSectionUI
|
||||
isLoading={false}
|
||||
onAlertsUpdated={() => {}}
|
||||
alertRules={[createEngineAlertRuleStub("foo")]}
|
||||
alerts={[
|
||||
createEngineAlertStub("foo"),
|
||||
createEngineAlertStub("foo", {
|
||||
status: "firing",
|
||||
}),
|
||||
createEngineAlertStub("foo", {
|
||||
status: "resolved",
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No Alerts">
|
||||
<RecentEngineAlertsSectionUI
|
||||
isLoading={false}
|
||||
alertRules={[]}
|
||||
alerts={[]}
|
||||
onAlertsUpdated={() => {}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<RecentEngineAlertsSectionUI
|
||||
isLoading={true}
|
||||
alertRules={[]}
|
||||
alerts={[]}
|
||||
onAlertsUpdated={() => {}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
type EngineAlert,
|
||||
type EngineAlertRule,
|
||||
useEngineAlerts,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function RecentEngineAlertsSection(props: {
|
||||
alertRules: EngineAlertRule[];
|
||||
alertRulesIsLoading: boolean;
|
||||
engineId: string;
|
||||
}) {
|
||||
// TODO - pagination
|
||||
// required : return the total number of alerts in response from API
|
||||
const alertsQuery = useEngineAlerts(props.engineId, 100, 0);
|
||||
const alerts = alertsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<RecentEngineAlertsSectionUI
|
||||
alertRules={props.alertRules}
|
||||
alerts={alerts}
|
||||
isLoading={alertsQuery.isLoading || props.alertRulesIsLoading}
|
||||
onAlertsUpdated={() => {
|
||||
alertsQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentEngineAlertsSectionUI(props: {
|
||||
alertRules: EngineAlertRule[];
|
||||
alerts: EngineAlert[];
|
||||
isLoading: boolean;
|
||||
onAlertsUpdated: () => void;
|
||||
}) {
|
||||
const { alerts, isLoading } = props;
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-1">
|
||||
Recent Alerts
|
||||
</h2>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
{alerts.length === 0 || isLoading ? (
|
||||
<div className="flex justify-center items-center min-h-[200px] w-full border border-border rounded-lg">
|
||||
{isLoading ? <Spinner className="size-8" /> : "No alerts triggered."}
|
||||
</div>
|
||||
) : (
|
||||
<RecentEngineAlertsTableUI
|
||||
alertRules={props.alertRules}
|
||||
alerts={alerts}
|
||||
onAlertsUpdated={props.onAlertsUpdated}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentEngineAlertsTableUI(props: {
|
||||
alertRules: EngineAlertRule[];
|
||||
alerts: EngineAlert[];
|
||||
onAlertsUpdated: () => void;
|
||||
}) {
|
||||
const alertRulesMap = useMemo(() => {
|
||||
const map: Record<string, EngineAlertRule> = {};
|
||||
for (const alertRule of props.alertRules) {
|
||||
map[alertRule.id] = alertRule;
|
||||
}
|
||||
return map;
|
||||
}, [props.alertRules]);
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Alert Trigger</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.alerts.map((alert) => {
|
||||
let variant: BadgeProps["variant"];
|
||||
let status: string;
|
||||
if (alert.status === "pending") {
|
||||
variant = "warning";
|
||||
status = "Pending";
|
||||
} else if (alert.status === "firing") {
|
||||
variant = "destructive";
|
||||
status = "Triggered";
|
||||
} else if (alert.status === "resolved") {
|
||||
variant = "success";
|
||||
status = "Resolved";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={alert.id}>
|
||||
<TableCell>{alertRulesMap[alert.alertRuleId]?.title}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Badge variant={variant} className="text-sm">
|
||||
{status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDistance(
|
||||
new Date(alert.endsAt ?? alert.startsAt),
|
||||
new Date(),
|
||||
{ addSuffix: true },
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { EngineCorsConfig } from "./cors";
|
||||
import { EngineWalletConfig } from "./engine-wallet-config";
|
||||
import { EngineIpAllowlistConfig } from "./ip-allowlist";
|
||||
@@ -15,11 +14,11 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
|
||||
instance,
|
||||
}) => {
|
||||
return (
|
||||
<Flex flexDir="column" gap={12}>
|
||||
<div className="flex flex-col gap-12">
|
||||
<EngineWalletConfig instanceUrl={instance.url} />
|
||||
<EngineCorsConfig instanceUrl={instance.url} />
|
||||
<EngineIpAllowlistConfig instanceUrl={instance.url} />
|
||||
<EngineSystem instance={instance} />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEngineWebhooks } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { Heading, Link, Text } from "tw-components";
|
||||
import { AddWebhookButton } from "./add-webhook-button";
|
||||
import { WebhooksTable } from "./webhooks-table";
|
||||
@@ -16,8 +15,8 @@ export const EngineWebhooks: React.FC<EngineWebhooksProps> = ({
|
||||
const webhooks = useEngineWebhooks(instanceUrl);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="title.md">Webhooks</Heading>
|
||||
<Text>
|
||||
Notify your app backend when transaction and backend wallet events
|
||||
@@ -31,14 +30,14 @@ export const EngineWebhooks: React.FC<EngineWebhooksProps> = ({
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
<WebhooksTable
|
||||
instanceUrl={instanceUrl}
|
||||
webhooks={webhooks.data || []}
|
||||
isLoading={webhooks.isLoading}
|
||||
isPending={webhooks.isPending}
|
||||
isFetched={webhooks.isFetched}
|
||||
/>
|
||||
<AddWebhookButton instanceUrl={instanceUrl} />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export function beautifyString(str: string): string {
|
||||
interface WebhooksTableProps {
|
||||
instanceUrl: string;
|
||||
webhooks: EngineWebhook[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ const columns = [
|
||||
export const WebhooksTable: React.FC<WebhooksTableProps> = ({
|
||||
instanceUrl,
|
||||
webhooks,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
}) => {
|
||||
const [webhookToRevoke, setWebhookToRevoke] = useState<EngineWebhook>();
|
||||
@@ -209,7 +209,7 @@ export const WebhooksTable: React.FC<WebhooksTableProps> = ({
|
||||
title="webhooks"
|
||||
data={activeWebhooks}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
onMenuClick={[
|
||||
{
|
||||
|
||||
+1
-1
@@ -530,7 +530,7 @@ const FilterSelector = ({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{abiQuery.isLoading ? (
|
||||
{abiQuery.isPending ? (
|
||||
<Spinner size="sm" />
|
||||
) : filterNames.length === 0 ? (
|
||||
<Text>
|
||||
|
||||
+3
-3
@@ -52,7 +52,7 @@ function shortenAddress(address: string) {
|
||||
interface ContractSubscriptionTableProps {
|
||||
instanceUrl: string;
|
||||
contractSubscriptions: EngineContractSubscription[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
autoUpdate: boolean;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export const ContractSubscriptionTable: React.FC<
|
||||
> = ({
|
||||
instanceUrl,
|
||||
contractSubscriptions,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
autoUpdate,
|
||||
}) => {
|
||||
@@ -218,7 +218,7 @@ export const ContractSubscriptionTable: React.FC<
|
||||
title="contract subscriptions"
|
||||
data={contractSubscriptions}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
onMenuClick={[
|
||||
{
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ export const EngineContractSubscriptions: React.FC<
|
||||
<ContractSubscriptionTable
|
||||
instanceUrl={instanceUrl}
|
||||
contractSubscriptions={contractSubscriptionsQuery.data ?? []}
|
||||
isLoading={contractSubscriptionsQuery.isLoading}
|
||||
isPending={contractSubscriptionsQuery.isPending}
|
||||
isFetched={contractSubscriptionsQuery.isFetched}
|
||||
autoUpdate={autoUpdate}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,7 @@ import { FormLabel } from "tw-components";
|
||||
|
||||
interface EngineInstancesTableProps {
|
||||
instances: EngineInstance[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
refetch: ReturnType<typeof useEngineInstances>["refetch"];
|
||||
engineLinkPrefix: string;
|
||||
@@ -50,7 +50,7 @@ interface EngineInstancesTableProps {
|
||||
|
||||
export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
|
||||
instances,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
refetch,
|
||||
engineLinkPrefix,
|
||||
@@ -141,7 +141,7 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
|
||||
data={instances}
|
||||
columns={columns}
|
||||
isFetched={isFetched}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
onMenuClick={[
|
||||
{
|
||||
icon: BiPencil,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const EngineInstancesList = (props: {
|
||||
const instances = instancesQuery.data ?? [];
|
||||
const trackEvent = useTrack();
|
||||
|
||||
if (instancesQuery.isLoading) {
|
||||
if (instancesQuery.isPending) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-[300px]">
|
||||
<Spinner className="size-14" />
|
||||
@@ -122,7 +122,7 @@ export const EngineInstancesList = (props: {
|
||||
<EngineInstancesTable
|
||||
instances={instances}
|
||||
isFetched={instancesQuery.isFetched}
|
||||
isLoading={instancesQuery.isLoading}
|
||||
isPending={instancesQuery.isPending}
|
||||
refetch={instancesQuery.refetch}
|
||||
engineLinkPrefix={props.engineLinkPrefix}
|
||||
/>
|
||||
|
||||
@@ -51,7 +51,7 @@ import { prettyPrintCurrency } from "../utils";
|
||||
interface BackendWalletsTableProps {
|
||||
wallets: BackendWallet[];
|
||||
instanceUrl: string;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
|
||||
export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
|
||||
wallets,
|
||||
instanceUrl,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
}) => {
|
||||
const editDisclosure = useDisclosure();
|
||||
@@ -162,7 +162,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
|
||||
title="backend wallets"
|
||||
data={wallets}
|
||||
columns={columns as ColumnDef<BackendWallet, string>[]}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
onMenuClick={[
|
||||
{
|
||||
|
||||
@@ -90,7 +90,7 @@ export const EngineOverview: React.FC<EngineOverviewProps> = ({
|
||||
<BackendWalletsTable
|
||||
instanceUrl={instanceUrl}
|
||||
wallets={backendWallets.data ?? []}
|
||||
isLoading={backendWallets.isLoading}
|
||||
isPending={backendWallets.isPending}
|
||||
isFetched={backendWallets.isFetched}
|
||||
/>
|
||||
</Flex>
|
||||
@@ -119,7 +119,7 @@ export const EngineOverview: React.FC<EngineOverviewProps> = ({
|
||||
</Flex>
|
||||
<TransactionsTable
|
||||
transactions={transactionsQuery.data?.transactions ?? []}
|
||||
isLoading={transactionsQuery.isLoading}
|
||||
isPending={transactionsQuery.isPending}
|
||||
isFetched={transactionsQuery.isFetched}
|
||||
instanceUrl={instanceUrl}
|
||||
/>
|
||||
|
||||
@@ -37,7 +37,7 @@ import { TransactionTimeline } from "./transaction-timeline";
|
||||
|
||||
interface TransactionsTableProps {
|
||||
transactions: Transaction[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
instanceUrl: string;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ const columnHelper = createColumnHelper<Transaction>();
|
||||
|
||||
export const TransactionsTable: React.FC<TransactionsTableProps> = ({
|
||||
transactions,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
instanceUrl,
|
||||
}) => {
|
||||
@@ -264,7 +264,7 @@ export const TransactionsTable: React.FC<TransactionsTableProps> = ({
|
||||
title="transactions"
|
||||
data={transactions}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
onRowClick={(row) => {
|
||||
setSelectedTransaction(row);
|
||||
|
||||
@@ -32,7 +32,7 @@ import { toDateTimeLocal } from "utils/date-utils";
|
||||
interface AccessTokensTableProps {
|
||||
instanceUrl: string;
|
||||
accessTokens: AccessToken[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ const columns = [
|
||||
export const AccessTokensTable: React.FC<AccessTokensTableProps> = ({
|
||||
instanceUrl,
|
||||
accessTokens,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
}) => {
|
||||
const editDisclosure = useDisclosure();
|
||||
@@ -93,7 +93,7 @@ export const AccessTokensTable: React.FC<AccessTokensTableProps> = ({
|
||||
title="access tokens"
|
||||
data={accessTokens}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
onMenuClick={[
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Button, FormLabel, Text } from "tw-components";
|
||||
interface AdminsTableProps {
|
||||
instanceUrl: string;
|
||||
admins: EngineAdmin[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ const columns = [
|
||||
export const AdminsTable: React.FC<AdminsTableProps> = ({
|
||||
instanceUrl,
|
||||
admins,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
}) => {
|
||||
const editDisclosure = useDisclosure();
|
||||
@@ -80,7 +80,7 @@ export const AdminsTable: React.FC<AdminsTableProps> = ({
|
||||
title="admins"
|
||||
data={admins}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
onMenuClick={[
|
||||
{
|
||||
|
||||
@@ -96,7 +96,7 @@ const StandardAccessTokensPanel = ({
|
||||
<AccessTokensTable
|
||||
instanceUrl={instanceUrl}
|
||||
accessTokens={accessTokens.data ?? []}
|
||||
isLoading={accessTokens.isLoading}
|
||||
isPending={accessTokens.isPending}
|
||||
isFetched={accessTokens.isFetched}
|
||||
/>
|
||||
<AddAccessTokenButton instanceUrl={instanceUrl} />
|
||||
@@ -146,7 +146,7 @@ const KeypairAuthenticationPanel = ({
|
||||
<KeypairsTable
|
||||
instanceUrl={instanceUrl}
|
||||
keypairs={keypairs.data || []}
|
||||
isLoading={keypairs.isLoading}
|
||||
isPending={keypairs.isPending}
|
||||
isFetched={keypairs.isFetched}
|
||||
/>
|
||||
<AddKeypairButton instanceUrl={instanceUrl} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEnginePermissions } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { Heading, Link, Text } from "tw-components";
|
||||
import { AddAdminButton } from "./add-admin-button";
|
||||
import { AdminsTable } from "./admins-table";
|
||||
@@ -14,8 +13,8 @@ export const EngineAdmins: React.FC<EngineAdminsProps> = ({ instanceUrl }) => {
|
||||
const admins = useEnginePermissions(instanceUrl);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="title.md">Admins</Heading>
|
||||
<Text>
|
||||
Admins are allowed to manage your Engine instance from the dashboard.{" "}
|
||||
@@ -29,14 +28,14 @@ export const EngineAdmins: React.FC<EngineAdminsProps> = ({ instanceUrl }) => {
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
<AdminsTable
|
||||
instanceUrl={instanceUrl}
|
||||
admins={admins.data || []}
|
||||
isLoading={admins.isLoading}
|
||||
isPending={admins.isPending}
|
||||
isFetched={admins.isFetched}
|
||||
/>
|
||||
<AddAdminButton instanceUrl={instanceUrl} />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import {
|
||||
type Keypair,
|
||||
useEngineRemoveKeypair,
|
||||
@@ -28,7 +29,7 @@ import { toDateTimeLocal } from "utils/date-utils";
|
||||
interface KeypairsTableProps {
|
||||
instanceUrl: string;
|
||||
keypairs: Keypair[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,14 @@ const columns = [
|
||||
return <Text>{cell.getValue()}</Text>;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("hash", {
|
||||
header: "Key ID",
|
||||
cell: (cell) => {
|
||||
return (
|
||||
<CopyAddressButton address={cell.getValue()} copyIconPosition="right" />
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("publicKey", {
|
||||
header: "Public Key",
|
||||
cell: (cell) => {
|
||||
@@ -64,7 +73,7 @@ const columns = [
|
||||
export const KeypairsTable: React.FC<KeypairsTableProps> = ({
|
||||
instanceUrl,
|
||||
keypairs,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
}) => {
|
||||
const removeDisclosure = useDisclosure();
|
||||
@@ -76,7 +85,7 @@ export const KeypairsTable: React.FC<KeypairsTableProps> = ({
|
||||
title="public keys"
|
||||
data={keypairs}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
onMenuClick={[
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ export const EngineRelayer: React.FC<EngineRelayerProps> = ({
|
||||
<RelayersTable
|
||||
instanceUrl={instanceUrl}
|
||||
relayers={relayers.data || []}
|
||||
isLoading={relayers.isLoading}
|
||||
isPending={relayers.isPending}
|
||||
isFetched={relayers.isFetched}
|
||||
/>
|
||||
<AddRelayerButton instanceUrl={instanceUrl} />
|
||||
|
||||
@@ -47,7 +47,7 @@ import { type AddModalInput, parseAddressListRaw } from "./add-relayer-button";
|
||||
interface RelayersTableProps {
|
||||
instanceUrl: string;
|
||||
relayers: EngineRelayer[];
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ const columnHelper = createColumnHelper<EngineRelayer>();
|
||||
export const RelayersTable: React.FC<RelayersTableProps> = ({
|
||||
instanceUrl,
|
||||
relayers,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFetched,
|
||||
}) => {
|
||||
const editDisclosure = useDisclosure();
|
||||
@@ -156,7 +156,7 @@ export const RelayersTable: React.FC<RelayersTableProps> = ({
|
||||
title="relayers"
|
||||
data={relayers}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
onMenuClick={[
|
||||
{
|
||||
|
||||
@@ -70,37 +70,48 @@ export const EngineSystemMetrics: React.FC<EngineStatusProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
let queueMetricsPanel = <Spinner className="h-4 w-4" />;
|
||||
if (!queueMetricsQuery.data || queueMetricsQuery.isError) {
|
||||
queueMetricsPanel = <p>N/A</p>;
|
||||
} else {
|
||||
let queueMetricsPanel = (
|
||||
<div className="border border-border rounded-lg min-h-[200px] flex items-center justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (
|
||||
!queueMetricsQuery.isPending &&
|
||||
(!queueMetricsQuery.data || queueMetricsQuery.isError)
|
||||
) {
|
||||
queueMetricsPanel = (
|
||||
<div className="border border-border rounded-lg min-h-[200px] flex items-center justify-center">
|
||||
No Data Available
|
||||
</div>
|
||||
);
|
||||
} else if (queueMetricsQuery.data) {
|
||||
const numQueued = queueMetricsQuery.data.result.queued;
|
||||
const numPending = queueMetricsQuery.data.result.pending;
|
||||
const msToSend = queueMetricsQuery.data.result.latency?.msToSend;
|
||||
const msToMine = queueMetricsQuery.data.result.latency?.msToMine;
|
||||
|
||||
queueMetricsPanel = (
|
||||
<Card p={16}>
|
||||
<Stack spacing={4}>
|
||||
<Card p={8}>
|
||||
<Stack spacing={6}>
|
||||
<Flex gap={2} align="center">
|
||||
<Icon as={FaChartArea} />
|
||||
<Heading size="title.md">Queue Metrics</Heading>
|
||||
</Flex>
|
||||
|
||||
<div className="flex gap-x-24">
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-12">
|
||||
<div className="flex-col gap-y-4">
|
||||
<h2 className="font-semibold"># queued</h2>
|
||||
<p>{numQueued}</p>
|
||||
<h2 className="font-semibold"> Queued</h2>
|
||||
<p className="text-muted-foreground">{numQueued}</p>
|
||||
</div>
|
||||
<div className="flex-col gap-y-4">
|
||||
<h2 className="font-semibold"># pending</h2>
|
||||
<p>{numPending}</p>
|
||||
<h2 className="font-semibold">Pending</h2>
|
||||
<p className="text-muted-foreground">{numPending}</p>
|
||||
</div>
|
||||
|
||||
{msToSend && (
|
||||
<div className="flex-col gap-y-4">
|
||||
<h2 className="font-semibold">Time to send</h2>
|
||||
<p>
|
||||
<p className="text-muted-foreground">
|
||||
p50 {(msToSend.p50 / 1000).toFixed(2)}s, p90{" "}
|
||||
{(msToSend.p90 / 1000).toFixed(2)}s
|
||||
</p>
|
||||
@@ -109,8 +120,10 @@ export const EngineSystemMetrics: React.FC<EngineStatusProps> = ({
|
||||
{msToMine && (
|
||||
<div className="flex-col gap-y-4">
|
||||
<h2 className="font-semibold">Time to mine</h2>
|
||||
p50 {(msToMine.p50 / 1000).toFixed(2)}s, p90{" "}
|
||||
{(msToMine.p90 / 1000).toFixed(2)}s
|
||||
<p className="text-muted-foreground">
|
||||
p50 {(msToMine.p50 / 1000).toFixed(2)}s, p90{" "}
|
||||
{(msToMine.p90 / 1000).toFixed(2)}s
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
`${publisher}/${contractId}/${version}`,
|
||||
);
|
||||
|
||||
const showSkeleton = publishedContractResult.isLoading;
|
||||
const showSkeleton = publishedContractResult.isPending;
|
||||
|
||||
return (
|
||||
<article
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ContractPublisher: React.FC<ContractPublisherProps> = ({
|
||||
)}
|
||||
>
|
||||
<PublisherAvatar
|
||||
isLoading={showSkeleton}
|
||||
isPending={showSkeleton}
|
||||
boxSize={5}
|
||||
address={addressOrEns || ""}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "tw-components";
|
||||
|
||||
@@ -58,9 +57,9 @@ const Timer = ({ dateStr }: TimerProps) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex gap="2">
|
||||
<div className="flex flex-row gap-2">
|
||||
{items.map(({ label, value }) => (
|
||||
<Flex flexDir="column" key={label} align="center">
|
||||
<div className="flex flex-col items-center" key={label}>
|
||||
<Text
|
||||
fontSize={{ base: "36px", md: "48px" }}
|
||||
bg="#FFFFFF14"
|
||||
@@ -72,9 +71,9 @@ const Timer = ({ dateStr }: TimerProps) => {
|
||||
{value < 10 ? `0${value}` : value}
|
||||
</Text>
|
||||
<Text color="white">{value === 1 ? label : `${label}s`}</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { LandingGridSection } from "components/landing-pages/grid-section";
|
||||
import { LandingIconSectionItem } from "components/landing-pages/icon-section-item";
|
||||
import { Heading } from "tw-components";
|
||||
|
||||
const DesireSection = () => {
|
||||
return (
|
||||
<Flex
|
||||
w="full"
|
||||
flexDir={{ base: "column", xl: "row" }}
|
||||
alignItems={{ base: "center", xl: "flex-start" }}
|
||||
gap="60px"
|
||||
>
|
||||
<div className="w-full flex flex-col xl:flex-row items-center xl:items-start gap-[60px]">
|
||||
<Heading
|
||||
size="title.lg"
|
||||
fontWeight="semibold"
|
||||
@@ -19,7 +13,7 @@ const DesireSection = () => {
|
||||
>
|
||||
thirdweb will...
|
||||
</Heading>
|
||||
<Flex maxW="2xl">
|
||||
<div className="max-w-[96rem] flex flex-row">
|
||||
<LandingGridSection desktopColumns={2}>
|
||||
<LandingIconSectionItem
|
||||
shouldShowNoBorder
|
||||
@@ -53,8 +47,8 @@ const DesireSection = () => {
|
||||
description="There is infinite upside to building in open. Open-sourcing all of our tools increases transparency and security for the whole industry. Anybody can contribute to thirdweb by proposing new features, identifying bugs and optimising gas."
|
||||
/>
|
||||
</LandingGridSection>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { LandingDesktopMobileImage } from "components/landing-pages/desktop-mobile-image";
|
||||
import { Heading } from "tw-components";
|
||||
|
||||
const HeroSection = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<Flex flexDir="column" alignItems="center" mt={{ base: 20, md: 140 }}>
|
||||
<div className="flex flex-col items-center mt-5 md:mt-[140px]">
|
||||
<LandingDesktopMobileImage
|
||||
image={require("../../../public/assets/landingpage/desktop/xl-logo.png")}
|
||||
mobileImage={require("../../../public/assets/landingpage/mobile/xl-logo.png")}
|
||||
@@ -20,7 +19,7 @@ const HeroSection = ({ text }: { text: string }) => {
|
||||
>
|
||||
{text}
|
||||
</Heading>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { ChakraNextImage } from "components/Image";
|
||||
import { LandingGridSection } from "components/landing-pages/grid-section";
|
||||
import { Heading, Text } from "tw-components";
|
||||
|
||||
const MajorSection = () => {
|
||||
return (
|
||||
<Flex
|
||||
w="full"
|
||||
flexDir={{ base: "column", xl: "row" }}
|
||||
alignItems={{ base: "center", xl: "flex-start" }}
|
||||
gap="60px"
|
||||
>
|
||||
<div className="w-full flex flex-col xl:flex-row items-center xl:items-start gap-[60px]">
|
||||
<Heading
|
||||
size="title.lg"
|
||||
fontWeight="semibold"
|
||||
@@ -19,29 +13,29 @@ const MajorSection = () => {
|
||||
>
|
||||
However, there are two major obstacles to mass adoption.
|
||||
</Heading>
|
||||
<Flex maxW="2xl">
|
||||
<div className="flex flex-row max-w-[96rem]">
|
||||
<LandingGridSection desktopColumns={2}>
|
||||
<Flex flexDir="column" gap={6}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ChakraNextImage
|
||||
src={require("../../../public/assets/landingpage/mobile/complexity.png")}
|
||||
alt="img-one"
|
||||
/>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text size="body.xl" color="white" fontWeight="bold">
|
||||
Developer complexity
|
||||
</Text>
|
||||
To build a web3 app, developers need to piece together 10+
|
||||
different tools that don't natively talk to each other —
|
||||
creating a messy, fragmented DX that stifles innovation.
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Flex flexDir="column" gap={6}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ChakraNextImage
|
||||
src={require("../../../public/assets/landingpage/mobile/user-experience.png")}
|
||||
alt="img-one"
|
||||
/>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text size="body.xl" color="white" fontWeight="bold">
|
||||
User experience
|
||||
</Text>
|
||||
@@ -49,11 +43,11 @@ const MajorSection = () => {
|
||||
their private keys, purchase & transfer crypto, pay gas, and sign
|
||||
a transaction for every action — creating a daunting onboarding
|
||||
process that stifles adoption.
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</LandingGridSection>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { Button, TrackedLink } from "tw-components";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
@@ -87,8 +86,8 @@ const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
|
||||
}
|
||||
/>
|
||||
<form>
|
||||
<Flex gap={8} flexDir="column" w="full">
|
||||
<Flex flexDir="column" gap={3}>
|
||||
<div className="flex flex-col w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
w="full"
|
||||
size="lg"
|
||||
@@ -111,8 +110,8 @@ const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
|
||||
>
|
||||
Use another email
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user