Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc0efff31 | ||
|
|
9799b0aae1 | ||
|
|
c3b90a6f1b | ||
|
|
323e9d7a37 | ||
|
|
80ae08bd5a | ||
|
|
0c0d837f23 | ||
|
|
879148f37e | ||
|
|
25b149d3a9 | ||
|
|
47c9c43a87 | ||
|
|
31dce55025 | ||
|
|
b535cc816f | ||
|
|
c3ded8a37c | ||
|
|
257d0bddbf | ||
|
|
5a9a76b7a2 | ||
|
|
e772581044 | ||
|
|
5625ff1f80 | ||
|
|
062504f463 | ||
|
|
853ed473c6 | ||
|
|
f5765819d7 | ||
|
|
f4ca73c4bf | ||
|
|
1f517fa10d | ||
|
|
8887e46a00 | ||
|
|
56301bc50e | ||
|
|
36b4ea5260 | ||
|
|
e1f73c8286 | ||
|
|
c4e7f3b344 | ||
|
|
6235fe76d3 | ||
|
|
d8e9245ecf |
@@ -1,49 +0,0 @@
|
||||
name: Linked Issue
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, ready_for_review]
|
||||
|
||||
env:
|
||||
VALID_ISSUE_PREFIXES: "CORE|TOOL|NEB|INFRA"
|
||||
|
||||
jobs:
|
||||
linear:
|
||||
name: Linear
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for linked issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
|
||||
// Check if contributor is external
|
||||
const isInternalContributor = ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(
|
||||
context.payload.pull_request.author_association
|
||||
);
|
||||
|
||||
// Automatically pass for external contributors
|
||||
if (!isInternalContributor) {
|
||||
console.log('External contributor detected - automatically passing check');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = pr.data.body || '';
|
||||
const branchName = pr.data.head.ref;
|
||||
const issueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES})-\\d+`, 'i');
|
||||
const branchIssueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES.toLowerCase()})-\\d+`, 'i');
|
||||
|
||||
if (!issueRegex.test(body) && !branchIssueRegex.test(branchName)) {
|
||||
core.setFailed(
|
||||
`No valid issue reference found. PR body or branch name must contain an issue ID with one of these prefixes: ${process.env.VALID_ISSUE_PREFIXES}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = body.match(issueRegex) || branchName.match(branchIssueRegex);
|
||||
console.log(`Found issue reference: ${matches[0]}`);
|
||||
@@ -13,6 +13,8 @@ module.exports = [
|
||||
"/connect/universal-bridge",
|
||||
"/connect/auth",
|
||||
"/connect/in-app-wallets",
|
||||
// -- storage
|
||||
"/storage",
|
||||
// -- nebula
|
||||
"/nebula",
|
||||
// --insight
|
||||
|
||||
@@ -12,7 +12,7 @@ const ContentSecurityPolicy = `
|
||||
style-src 'self' 'unsafe-inline' vercel.live;
|
||||
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
|
||||
frame-src * data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com static.reo.dev;
|
||||
connect-src * data: blob:;
|
||||
worker-src 'self' blob:;
|
||||
block-all-mixed-content;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"start": "next start",
|
||||
"format": "biome format ./src --write",
|
||||
@@ -63,11 +63,12 @@
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "12.4.7",
|
||||
"fuse.js": "7.1.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.5.0",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.476.0",
|
||||
"next": "15.1.7",
|
||||
"next": "15.2.0",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
@@ -104,8 +105,8 @@
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "3.2.4",
|
||||
"@next/bundle-analyzer": "15.1.7",
|
||||
"@next/eslint-plugin-next": "15.1.7",
|
||||
"@next/bundle-analyzer": "15.2.0",
|
||||
"@next/eslint-plugin-next": "15.2.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@storybook/addon-essentials": "8.6.0",
|
||||
"@storybook/addon-interactions": "8.6.0",
|
||||
|
||||
@@ -57,6 +57,23 @@ export async function getTeams() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getDefaultTeam() {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/teams/~`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
return (await res.json())?.result as Team;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type TeamNebulaWaitList = {
|
||||
onWaitlist: boolean;
|
||||
createdAt: null | string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer } from "../../../../stories/utils";
|
||||
import { getThirdwebClient } from "../../../constants/thirdweb.server";
|
||||
import { Button } from "../../ui/button";
|
||||
import { ProjectAvatar } from "./ProjectAvatar";
|
||||
|
||||
@@ -17,17 +18,19 @@ export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
const client = getThirdwebClient();
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 p-10">
|
||||
<p> All images below are set with size-6 className </p>
|
||||
|
||||
<BadgeContainer label="No Src - Skeleton">
|
||||
<ProjectAvatar src={undefined} className="size-6" />
|
||||
<ProjectAvatar src={undefined} className="size-6" client={client} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Invalid/Empty Src - BoxIcon Fallback">
|
||||
<ProjectAvatar src={""} className="size-6" />
|
||||
<ProjectAvatar src={""} className="size-6" client={client} />
|
||||
</BadgeContainer>
|
||||
|
||||
<ToggleTest />
|
||||
@@ -62,13 +65,14 @@ function ToggleTest() {
|
||||
<p> Src+Name is: {data ? "set" : "not set"} </p>
|
||||
|
||||
<BadgeContainer label="Valid Src">
|
||||
<ProjectAvatar src={data?.src} className="size-6" />
|
||||
<ProjectAvatar src={data?.src} className="size-6" client={client} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="invalid Src">
|
||||
<ProjectAvatar
|
||||
src={data ? "invalid-src" : undefined}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { BoxIcon } from "lucide-react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
export function ProjectAvatar(props: {
|
||||
src: string | undefined;
|
||||
className: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<Img
|
||||
src={props.src}
|
||||
className={cn("rounded-lg border border-border", props.className)}
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
uri: props.src,
|
||||
client: props.client,
|
||||
}) || ""
|
||||
}
|
||||
className={cn("rounded-full border border-border", props.className)}
|
||||
alt={""}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center bg-card">
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { DynamicHeight } from "../ui/DynamicHeight";
|
||||
|
||||
export function DangerSettingCard(props: {
|
||||
title: string;
|
||||
@@ -24,9 +26,14 @@ export function DangerSettingCard(props: {
|
||||
confirmationDialog: {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -50,7 +57,15 @@ export function DangerSettingCard(props: {
|
||||
props.footerClassName,
|
||||
)}
|
||||
>
|
||||
<Dialog>
|
||||
<Dialog
|
||||
open={isConfirmationDialogOpen}
|
||||
onOpenChange={(v) => {
|
||||
setIsConfirmationDialogOpen(v);
|
||||
if (!v) {
|
||||
props.confirmationDialog.onClose?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -62,21 +77,21 @@ export function DangerSettingCard(props: {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<div className="p-6">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DynamicHeight>
|
||||
<div className="p-6">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{props.confirmationDialog.children}
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
|
||||
<div className="flex justify-end gap-4 border-t bg-card p-6 lg:gap-2">
|
||||
<DialogClose asChild>
|
||||
|
||||
@@ -225,7 +225,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="z-[10001] p-0"
|
||||
className="p-0"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||
|
||||
@@ -137,7 +137,7 @@ export const SelectWithSearch = React.forwardRef<
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className={cn("z-[10001] p-0", popoverContentClassName)}
|
||||
className={cn("p-0", popoverContentClassName)}
|
||||
align={props.align || "center"}
|
||||
side={props.side}
|
||||
sideOffset={10}
|
||||
|
||||
@@ -47,13 +47,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const btnOnlyProps =
|
||||
Comp === "button" ? { type: "button" as const } : undefined;
|
||||
Comp === "button"
|
||||
? { type: props.type || ("button" as const) }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...btnOnlyProps}
|
||||
{...props}
|
||||
{...btnOnlyProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function TabLinks(props: {
|
||||
|
||||
export function TabButtons(props: {
|
||||
tabs: {
|
||||
name: string;
|
||||
name: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
isEnabled?: boolean;
|
||||
@@ -108,10 +108,11 @@ export function TabButtons(props: {
|
||||
className={cn("flex", props.tabContainerClassName)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{props.tabs.map((tab) => {
|
||||
{props.tabs.map((tab, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={tab.name}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: tabs don't change order, so index is stable
|
||||
key={index}
|
||||
variant="ghost"
|
||||
ref={tab.isActive ? activeTabRef : undefined}
|
||||
className={cn(
|
||||
|
||||
+2
@@ -74,6 +74,7 @@ export async function RouteListCard({
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
@@ -83,6 +84,7 @@ export async function RouteListCard({
|
||||
<div className="size-8 rounded-full bg-white/10" />
|
||||
)}
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function RouteListRow({
|
||||
<div className="flex items-center gap-1">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// For now we're using a normal img tag because the domain for these images is unknown
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
@@ -110,6 +111,7 @@ export async function RouteListRow({
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
|
||||
@@ -109,6 +109,7 @@ async function getTeamsAndProjectsIfLoggedIn() {
|
||||
projects: (await getProjects(team.slug)).map((x) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
image: x.image,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ export function ModuleCard(props: ModuleCardProps) {
|
||||
open={isUninstallModalOpen}
|
||||
onOpenChange={setIsUninstallModalOpen}
|
||||
>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
<DialogContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export const ProposalButton: React.FC<VoteButtonProps> = ({
|
||||
Create Proposal
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000] w-full sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
|
||||
<SheetContent className="w-full sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create new proposal</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export const TokenBurnButton: React.FC<TokenBurnButtonProps> = ({
|
||||
<Flame size={16} /> Burn
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000]">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Burn tokens</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export const TokenClaimButton: React.FC<TokenClaimButtonProps> = ({
|
||||
<GemIcon size={16} /> Claim
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000]">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Claim tokens</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const TokenMintButton: React.FC<TokenMintButtonProps> = ({
|
||||
<PlusIcon size={16} /> Mint
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000]">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">
|
||||
Mint additional tokens
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ export const TokenTransferButton: React.FC<TokenTransferButtonProps> = ({
|
||||
<SendIcon size={16} /> Transfer
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000]">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Transfer tokens</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function DeployFormForUri(props: DeployFormForUriProps) {
|
||||
projects: (await getProjects(team.slug)).map((x) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
image: x.image,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,11 @@ import { useCallback, useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { LazyCreateProjectDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import {
|
||||
getChangelogNotifications,
|
||||
getInboxNotifications,
|
||||
markNotificationAsRead,
|
||||
} from "../../team/components/NotificationButton/fetch-notifications";
|
||||
import {
|
||||
type AccountHeaderCompProps,
|
||||
AccountHeaderDesktopUI,
|
||||
@@ -54,6 +59,9 @@ export function AccountHeader(props: {
|
||||
account: props.account,
|
||||
client,
|
||||
accountAddress: props.accountAddress,
|
||||
getChangelogNotifications: getChangelogNotifications,
|
||||
getInboxNotifications: getInboxNotifications,
|
||||
markNotificationAsRead: markNotificationAsRead,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,6 +62,9 @@ function Variants(props: {
|
||||
email: "[email protected]",
|
||||
}}
|
||||
client={client}
|
||||
getChangelogNotifications={() => Promise.resolve([])}
|
||||
getInboxNotifications={() => Promise.resolve([])}
|
||||
markNotificationAsRead={() => Promise.resolve()}
|
||||
/>
|
||||
</div>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -8,6 +8,10 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import { SecondaryNav } from "../../components/Header/SecondaryNav/SecondaryNav";
|
||||
import { MobileBurgerMenuButton } from "../../components/MobileBurgerMenuButton";
|
||||
import { ThirdwebMiniLogo } from "../../components/ThirdwebMiniLogo";
|
||||
import {
|
||||
NotificationButtonUI,
|
||||
type NotificationMetadata,
|
||||
} from "../../team/components/NotificationButton/NotificationButton";
|
||||
import { TeamAndProjectSelectorPopoverButton } from "../../team/components/TeamHeader/TeamAndProjectSelectorPopoverButton";
|
||||
import { TeamSelectorMobileMenuButton } from "../../team/components/TeamHeader/TeamSelectorMobileMenuButton";
|
||||
|
||||
@@ -20,6 +24,9 @@ export type AccountHeaderCompProps = {
|
||||
account: Pick<Account, "email" | "id" | "image">;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
@@ -59,6 +66,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
focus="team-selection"
|
||||
createProject={props.createProject}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -70,6 +78,9 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
connectButton={props.connectButton}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
@@ -111,13 +122,21 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
type="loggedIn"
|
||||
email={props.account?.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
type="loggedIn"
|
||||
email={props.account?.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { redirectToBillingPortal } from "@/actions/billing";
|
||||
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { updateAccount } from "@/actions/updateAccount";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
|
||||
export function AccountSettingsPage(props: {
|
||||
account: Account;
|
||||
client: ThirdwebClient;
|
||||
defaultTeamSlug: string;
|
||||
defaultTeamName: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const activeWallet = useActiveWallet();
|
||||
const { disconnect } = useDisconnect();
|
||||
return (
|
||||
<div>
|
||||
<header className="border-border border-b py-10">
|
||||
@@ -25,8 +33,32 @@ export function AccountSettingsPage(props: {
|
||||
|
||||
<div className="container max-w-[950px] grow pt-8 pb-20">
|
||||
<AccountSettingsPageUI
|
||||
hideDeleteAccount
|
||||
client={props.client}
|
||||
defaultTeamSlug={props.defaultTeamSlug}
|
||||
defaultTeamName={props.defaultTeamName}
|
||||
onAccountDeleted={async () => {
|
||||
await doLogout();
|
||||
if (activeWallet) {
|
||||
disconnect(activeWallet);
|
||||
}
|
||||
router.replace("/login");
|
||||
}}
|
||||
deleteAccount={async () => {
|
||||
try {
|
||||
const res = await apiServerProxy({
|
||||
method: "DELETE",
|
||||
pathname: "/v1/account",
|
||||
});
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { status: 500 };
|
||||
}
|
||||
}}
|
||||
redirectToBillingPortal={redirectToBillingPortal}
|
||||
updateAccountAvatar={async (file) => {
|
||||
let uri: string | undefined = undefined;
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
@@ -40,6 +48,13 @@ function Variants() {
|
||||
const [isVerifiedEmail, setIsVerifiedEmail] = useState(true);
|
||||
const [sendEmailFails, setSendEmailFails] = useState(false);
|
||||
const [emailConfirmationFails, setEmailConfirmationFails] = useState(false);
|
||||
const [deleteAccountStatusResponse, setDeleteAccountStatusResponse] =
|
||||
useState<400 | 402 | 500 | 200>(200);
|
||||
|
||||
const deleteAccountStub = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { status: deleteAccountStatusResponse };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container flex max-w-[1132px] flex-col gap-10 py-10">
|
||||
@@ -67,9 +82,37 @@ function Variants() {
|
||||
/>
|
||||
Email Confirmation Fails
|
||||
</CheckboxWithLabel>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<Label>Delete Account Response</Label>
|
||||
<Select
|
||||
value={String(deleteAccountStatusResponse)}
|
||||
onValueChange={(value) =>
|
||||
setDeleteAccountStatusResponse(
|
||||
Number(value) as 400 | 402 | 500 | 200,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[320px]">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="200">200 - Success</SelectItem>
|
||||
<SelectItem value="400">400 - Active Subscriptions</SelectItem>
|
||||
<SelectItem value="402">402 - Unpaid Invoices</SelectItem>
|
||||
<SelectItem value="500">500 - Unknown Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountSettingsPageUI
|
||||
defaultTeamSlug="foo"
|
||||
defaultTeamName="Foo"
|
||||
redirectToBillingPortal={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { status: 200 };
|
||||
}}
|
||||
account={{
|
||||
name: "John Doe",
|
||||
email: "[email protected]",
|
||||
@@ -96,6 +139,10 @@ function Variants() {
|
||||
throw new Error("Email already exists");
|
||||
}
|
||||
}}
|
||||
deleteAccount={deleteAccountStub}
|
||||
onAccountDeleted={() => {
|
||||
console.log("Account deleted");
|
||||
}}
|
||||
/>
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { BillingBillingPortalAction } from "@/actions/billing";
|
||||
import { BillingPortalButton } from "@/components/billing";
|
||||
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
|
||||
import { SettingsCard } from "@/components/blocks/SettingsCard";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -29,14 +32,14 @@ import {
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { EllipsisIcon } from "lucide-react";
|
||||
import { EllipsisIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -51,12 +54,16 @@ type MinimalAccount = Pick<
|
||||
|
||||
export function AccountSettingsPageUI(props: {
|
||||
account: MinimalAccount;
|
||||
hideDeleteAccount?: boolean;
|
||||
sendEmail: (email: string) => Promise<void>;
|
||||
updateName: (name: string) => Promise<void>;
|
||||
updateEmailWithOTP: (otp: string) => Promise<void>;
|
||||
updateAccountAvatar: (avatar: File | undefined) => Promise<void>;
|
||||
client: ThirdwebClient;
|
||||
deleteAccount: DeleteAccount;
|
||||
onAccountDeleted: () => void;
|
||||
defaultTeamSlug: string;
|
||||
defaultTeamName: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -76,7 +83,13 @@ export function AccountSettingsPageUI(props: {
|
||||
updateEmailWithOTP={props.updateEmailWithOTP}
|
||||
/>
|
||||
|
||||
{!props.hideDeleteAccount && <DeleteAccountCard />}
|
||||
<DeleteAccountCard
|
||||
deleteAccount={props.deleteAccount}
|
||||
onAccountDeleted={props.onAccountDeleted}
|
||||
defaultTeamSlug={props.defaultTeamSlug}
|
||||
defaultTeamName={props.defaultTeamName}
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,30 +195,43 @@ function AccountNameFormControl(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountCard() {
|
||||
const router = useDashboardRouter();
|
||||
const title = "Delete Account";
|
||||
const description =
|
||||
"Permanently remove your Personal Account and all of its contents from the thirdweb platform. This action is not reversible, please continue with caution.";
|
||||
type DeleteAccount = () => Promise<{
|
||||
status: number;
|
||||
}>;
|
||||
|
||||
// TODO: when user can create multiple teams - the error status messaging needs to be updated
|
||||
|
||||
function DeleteAccountCard(props: {
|
||||
deleteAccount: DeleteAccount;
|
||||
onAccountDeleted: () => void;
|
||||
defaultTeamSlug: string;
|
||||
defaultTeamName: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
}) {
|
||||
const title = "Delete Account";
|
||||
const description = (
|
||||
<>
|
||||
Permanently delete your thirdweb account, the default team "
|
||||
{props.defaultTeamName}" and all associated data
|
||||
<br />
|
||||
This action is not reversible
|
||||
</>
|
||||
);
|
||||
|
||||
// TODO
|
||||
const deleteAccount = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Deleting account");
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push("/team");
|
||||
mutationFn: props.deleteAccount,
|
||||
onSuccess: (data) => {
|
||||
if (data.status === 200) {
|
||||
props.onAccountDeleted();
|
||||
toast.success("Account deleted successfully");
|
||||
} else {
|
||||
toast.error("Failed to delete account");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
const promises = deleteAccount.mutateAsync();
|
||||
toast.promise(promises, {
|
||||
success: "Account deleted successfully",
|
||||
error: "Failed to delete account",
|
||||
});
|
||||
deleteAccount.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -216,9 +242,77 @@ function DeleteAccountCard() {
|
||||
buttonOnClick={handleDelete}
|
||||
isPending={deleteAccount.isPending}
|
||||
confirmationDialog={{
|
||||
title: "Are you sure you want to delete your account?",
|
||||
description:
|
||||
"This action is not reversible and will delete all of your data.",
|
||||
title: "Delete Account",
|
||||
description: (
|
||||
<span className="block space-y-2">
|
||||
<span className="block">
|
||||
Permanently delete your thirdweb account, the default team "
|
||||
{props.defaultTeamName}" and all associated data
|
||||
</span>
|
||||
<span className="block font-medium">
|
||||
This action is not reversible
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
onClose: () => {
|
||||
deleteAccount.reset();
|
||||
},
|
||||
children: (
|
||||
<>
|
||||
{deleteAccount.data?.status === 400 && (
|
||||
<div className="mt-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to delete account</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span className="mb-1 block">
|
||||
Your default team "{props.defaultTeamName}" has active
|
||||
subscriptions. These subscriptions have to be cancelled
|
||||
first before deleting account
|
||||
</span>
|
||||
<span className="mb-4 block">
|
||||
Reach out to support to help you cancel them
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
asChild
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Link href="/support/create-ticket">
|
||||
Contact Support
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteAccount.data?.status === 402 && (
|
||||
<div className="mt-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to delete account</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span className="mb-4 block">
|
||||
Your default team "{props.defaultTeamName}" has unpaid
|
||||
invoices. These invoices have to be paid before deleting
|
||||
account
|
||||
</span>
|
||||
<BillingPortalButton
|
||||
size="sm"
|
||||
teamSlug={props.defaultTeamSlug}
|
||||
variant="default"
|
||||
redirectPath="/team"
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
>
|
||||
Manage Billing
|
||||
</BillingPortalButton>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDefaultTeam } from "@/api/team";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { getAuthToken } from "../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
@@ -6,14 +7,23 @@ import { getValidAccount } from "./getAccount";
|
||||
|
||||
export default async function Page() {
|
||||
const pagePath = "/account";
|
||||
const account = await getValidAccount(pagePath);
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
const [defaultTeam, account, token] = await Promise.all([
|
||||
getDefaultTeam(),
|
||||
getValidAccount(pagePath),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!token || !defaultTeam) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountSettingsPage account={account} client={getThirdwebClient(token)} />
|
||||
<AccountSettingsPage
|
||||
account={account}
|
||||
client={getThirdwebClient(token)}
|
||||
defaultTeamSlug={defaultTeam.slug}
|
||||
defaultTeamName={defaultTeam.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
NotificationButtonUI,
|
||||
type NotificationMetadata,
|
||||
} from "../../../team/components/NotificationButton/NotificationButton";
|
||||
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
|
||||
import { AccountButton } from "./account-button.client";
|
||||
|
||||
@@ -11,17 +15,27 @@ export function SecondaryNav(props: {
|
||||
connectButton: React.ReactNode;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogs: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<SecondaryNavLinks />
|
||||
<AccountButton
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogs}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
<AccountButton
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function AccountButton(props: {
|
||||
<Button
|
||||
size="icon"
|
||||
asChild
|
||||
className="size-10 cursor-pointer rounded-full hover:ring-2 hover:ring-offset-2"
|
||||
className="size-10 cursor-pointer rounded-full hover:ring-2 hover:ring-ring hover:ring-offset-1"
|
||||
variant="ghost"
|
||||
>
|
||||
{/* Don't remove the div */}
|
||||
|
||||
@@ -59,10 +59,10 @@ export function MobileBurgerMenuButton(
|
||||
/> */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!h-auto p-1"
|
||||
className="flex size-10 items-center justify-center rounded-full bg-background p-0"
|
||||
onClick={() => setIsMenuOpen(true)}
|
||||
>
|
||||
<MenuIcon className="size-6 text-muted-foreground" />
|
||||
<MenuIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
|
||||
{isMenuOpen && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import NextTopLoader from "nextjs-toploader";
|
||||
import { Suspense } from "react";
|
||||
import { OrganizeContractsToProjectsBanner } from "../components/notices/AnnouncementBanner";
|
||||
import { OpCreditsGrantedModalWrapperServer } from "../components/onboarding/OpCreditsGrantedModalWrapperServer";
|
||||
import { PosthogIdentifierServer } from "../components/wallets/PosthogIdentifierServer";
|
||||
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
|
||||
import { PostHogProvider } from "./components/root-providers";
|
||||
import { AppRouterProviders } from "./providers";
|
||||
@@ -80,6 +81,9 @@ export default function RootLayout({
|
||||
<Suspense fallback={null}>
|
||||
<EnsureValidConnectedWalletLoginServer />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<PosthogIdentifierServer />
|
||||
</Suspense>
|
||||
</AppRouterProviders>
|
||||
<DashboardRouterTopProgressBar />
|
||||
<NextTopLoader
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useConnectionManager,
|
||||
} from "thirdweb/react";
|
||||
import { CustomConnectWallet } from "../@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { PosthogIdentifier } from "../components/wallets/PosthogIdentifier";
|
||||
import { isSanctionedAddress } from "../data/eth-sanctioned-addresses";
|
||||
import { useAllChainsData } from "../hooks/chains/allChains";
|
||||
import { SyncChainStores } from "../stores/chainStores";
|
||||
@@ -27,7 +26,6 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
<ThirdwebProvider>
|
||||
<SyncChainDefinitionsToConnectionManager />
|
||||
<TWAutoConnect />
|
||||
<PosthogIdentifier />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getWalletConnections } from "@/api/analytics";
|
||||
import { type Project, getProjects } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { Changelog } from "components/dashboard/Changelog";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { subDays } from "date-fns";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import {
|
||||
type ProjectWithAnalytics,
|
||||
TeamProjectsPage,
|
||||
@@ -13,7 +15,14 @@ export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
const [team, authToken] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
@@ -23,16 +32,21 @@ export default async function Page(props: {
|
||||
const projectsWithTotalWallets = await getProjectsWithAnalytics(projects);
|
||||
|
||||
return (
|
||||
<div className="container flex grow flex-col gap-12 py-8 lg:flex-row">
|
||||
<div className="flex grow flex-col">
|
||||
<h1 className="mb-4 font-semibold text-2xl tracking-tight">Projects</h1>
|
||||
<TeamProjectsPage projects={projectsWithTotalWallets} team={team} />
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Team Overview
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 lg:w-[320px]">
|
||||
<h2 className="mb-4 font-semibold text-2xl tracking-tight">
|
||||
Changelog
|
||||
</h2>
|
||||
<Changelog />
|
||||
|
||||
<div className="container flex grow flex-col pt-8 pb-20">
|
||||
<TeamProjectsPage
|
||||
projects={projectsWithTotalWallets}
|
||||
team={team}
|
||||
client={getThirdwebClient(authToken)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export function UpdatePartnerModal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-2">
|
||||
<DialogTitle>Update {partner.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
+3
-9
@@ -132,10 +132,7 @@ const ChangeVersionModal = (props: {
|
||||
// Self-hosted modal: prompt to update manually.
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] max-w-[400px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-6 pr-4 font-semibold text-2xl tracking-tight">
|
||||
Update your self-hosted Engine
|
||||
@@ -182,10 +179,7 @@ const ChangeVersionModal = (props: {
|
||||
// For cloud-hosted, prompt the user to select a version to update to.
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] max-w-[400px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Engine version</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -198,7 +192,7 @@ const ChangeVersionModal = (props: {
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{serverVersions.map(({ name, createdAt }, idx) => {
|
||||
const isCurrentVersion = name === currentVersion;
|
||||
|
||||
+3
-6
@@ -91,10 +91,7 @@ export function EngineAlertDialogForm(props: {
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6">
|
||||
@@ -120,7 +117,7 @@ export function EngineAlertDialogForm(props: {
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{alertRuleOptions.map((option) => (
|
||||
<SelectItem
|
||||
@@ -161,7 +158,7 @@ export function EngineAlertDialogForm(props: {
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select Notification Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(
|
||||
EngineNotificationChannelTypeConfig,
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import { ChakraProviderSetup } from "../../../../../../../../@/components/ChakraProviderSetup";
|
||||
import { getValidAccount } from "../../../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../../login/loginRedirect";
|
||||
@@ -54,7 +54,7 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraProvider>
|
||||
<ChakraProviderSetup>
|
||||
<div className="flex grow flex-col">
|
||||
<EngineInstanceHeader
|
||||
instance={instance}
|
||||
@@ -77,7 +77,7 @@ export default async function Layout(props: {
|
||||
</EnsureEnginePermission>
|
||||
</EngineSidebarLayout>
|
||||
</div>
|
||||
</ChakraProvider>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ function EngineInstanceHeader(props: {
|
||||
: instance.url;
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="border-border border-b">
|
||||
<div className="container py-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
|
||||
+2
-5
@@ -129,10 +129,7 @@ export const CreateBackendWalletButton: React.FC<
|
||||
Create
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6">
|
||||
@@ -162,7 +159,7 @@ export const CreateBackendWalletButton: React.FC<
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{walletTypeOptions.map((option) => (
|
||||
<SelectItem key={option.key} value={option.key}>
|
||||
|
||||
+2
-5
@@ -147,10 +147,7 @@ export const ImportBackendWalletButton: React.FC<
|
||||
</Button>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent
|
||||
className="z-[10001] p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6">
|
||||
@@ -175,7 +172,7 @@ export const ImportBackendWalletButton: React.FC<
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{walletTypeOptions.map((option) => (
|
||||
<SelectItem key={option.key} value={option.key}>
|
||||
|
||||
+2
-5
@@ -67,10 +67,7 @@ export const CredentialForm = ({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
@@ -114,7 +111,7 @@ export const CredentialForm = ({
|
||||
<SelectTrigger className="bg-card">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{CREDENTIAL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
|
||||
@@ -6,33 +6,30 @@ import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
|
||||
import { PaginationButtons } from "@/components/pagination-buttons";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { formatDate } from "date-fns";
|
||||
import { PlusIcon, SearchIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
|
||||
type SortById = "name" | "createdAt" | "monthlyActiveUsers";
|
||||
|
||||
@@ -43,6 +40,7 @@ export type ProjectWithAnalytics = Project & {
|
||||
export function TeamProjectsPage(props: {
|
||||
projects: ProjectWithAnalytics[];
|
||||
team: Team;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { projects } = props;
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -80,7 +78,7 @@ export function TeamProjectsPage(props: {
|
||||
return _projectsToShow;
|
||||
}, [searchTerm, sortBy, projects]);
|
||||
|
||||
const pageSize = 8;
|
||||
const pageSize = 10;
|
||||
const [page, setPage] = useState(1);
|
||||
const paginatedProjects = sortedProjects.slice(
|
||||
(page - 1) * pageSize,
|
||||
@@ -92,78 +90,135 @@ export function TeamProjectsPage(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
{/* Filters + Add New */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onValueChange={(v) => {
|
||||
setSearchTerm(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<SelectBy
|
||||
value={sortBy}
|
||||
onChange={(v) => {
|
||||
setSortBy(v);
|
||||
<div className="relative flex flex-col gap-5 rounded-t-lg pb-4 lg:flex-row lg:items-center lg:justify-between lg:border lg:border-b-0 lg:bg-card lg:px-6 lg:py-6">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Projects</h2>
|
||||
|
||||
{/* Filters + Add New */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onValueChange={(v) => {
|
||||
setSearchTerm(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<AddNewButton
|
||||
createProject={() => setIsCreateProjectDialogOpen(true)}
|
||||
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<SelectBy
|
||||
value={sortBy}
|
||||
onChange={(v) => {
|
||||
setSortBy(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<AddNewButton
|
||||
createProject={() => setIsCreateProjectDialogOpen(true)}
|
||||
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Projects */}
|
||||
{/* Projects Table */}
|
||||
{paginatedProjects.length === 0 ? (
|
||||
<>
|
||||
{searchTerm !== "" ? (
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div className="flex min-h-[450px] grow items-center justify-center border border-border bg-card">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="mb-5 text-center">No projects found</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-b-lg border border-border bg-card">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="mb-5 text-center">No projects created</p>
|
||||
<p className="mb-5 text-center font-semibold text-lg">
|
||||
No projects created
|
||||
</p>
|
||||
<Button
|
||||
className="gap-2"
|
||||
className="gap-2 bg-background"
|
||||
onClick={() => setIsCreateProjectDialogOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Create a Project
|
||||
Create Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
{paginatedProjects.map((project) => {
|
||||
return (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
team_slug={props.team.slug}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<TableContainer
|
||||
className={cn(
|
||||
"lg:rounded-t-none",
|
||||
showPagination && "rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="max-w-[300px]">Project</TableHead>
|
||||
<TableHead>Monthly Active Users</TableHead>
|
||||
<TableHead className="w-[250px]">Client ID</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProjects.map((project) => (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
linkBox
|
||||
className="hover:bg-accent/50"
|
||||
>
|
||||
<TableCell>
|
||||
<Link
|
||||
className="flex items-center gap-3 before:absolute before:inset-0"
|
||||
href={`/team/${props.team.slug}/${project.slug}`}
|
||||
>
|
||||
<ProjectAvatar
|
||||
className="size-8 rounded-full"
|
||||
src={project.image || ""}
|
||||
client={props.client}
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{project.name}
|
||||
</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.monthlyActiveUsers === 0 ? (
|
||||
<span className="text-muted-foreground">No Users</span>
|
||||
) : (
|
||||
project.monthlyActiveUsers
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CopyTextButton
|
||||
textToCopy={project.publishableKey}
|
||||
textToShow={`${project.publishableKey.slice(0, 4)}...${project.publishableKey.slice(-4)}`}
|
||||
copyIconPosition="right"
|
||||
tooltip={undefined}
|
||||
variant="ghost"
|
||||
className="-translate-x-2 z-10 font-mono text-muted-foreground"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(new Date(project.createdAt), "MMM d, yyyy")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
{showPagination && (
|
||||
<div className="rounded-b-lg border border-t-0 bg-card p-4">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -184,64 +239,6 @@ export function TeamProjectsPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard(props: {
|
||||
project: ProjectWithAnalytics;
|
||||
team_slug: string;
|
||||
}) {
|
||||
const { project, team_slug } = props;
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className="relative flex items-center gap-4 rounded-lg border border-border bg-card p-5 transition-colors hover:border-active-border"
|
||||
>
|
||||
{/* TODO - set image */}
|
||||
<ProjectAvatar className="size-10 rounded-full" src="" />
|
||||
|
||||
<div className="flex flex-grow flex-col gap-1">
|
||||
<Link
|
||||
className="group static before:absolute before:top-0 before:right-0 before:bottom-0 before:left-0 before:z-0"
|
||||
// remove /connect when we have overview page
|
||||
href={`/team/${team_slug}/${project.slug}`}
|
||||
>
|
||||
<h2 className="font-medium text-base">{project.name}</h2>
|
||||
</Link>
|
||||
|
||||
<p className="flex items-center gap-1 text-muted-foreground text-sm">
|
||||
<span>{project.monthlyActiveUsers}</span>
|
||||
Monthly Active Users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="z-10 h-auto w-auto p-2" variant="ghost">
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-1">
|
||||
<CopyTextButton
|
||||
textToCopy={project.publishableKey}
|
||||
textToShow="Copy Client ID"
|
||||
copyIconPosition="right"
|
||||
tooltip={undefined}
|
||||
variant="ghost"
|
||||
className="flex h-10 w-full justify-between gap-3 rounded-md px-4 py-2"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/team/${team_slug}/${project.slug}/settings`}>
|
||||
Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchInput(props: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
@@ -252,7 +249,7 @@ function SearchInput(props: {
|
||||
placeholder="Search Projects by name or Client ID"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onValueChange(e.target.value)}
|
||||
className="bg-card pl-9"
|
||||
className="bg-background pl-9 lg:w-[400px]"
|
||||
/>
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -264,28 +261,16 @@ function AddNewButton(props: {
|
||||
teamMembersSettingsPath: string;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" className="gap-2">
|
||||
Add New
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[220px] p-2" sideOffset={12}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-between px-2"
|
||||
onClick={props.createProject}
|
||||
>
|
||||
Project
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-between px-2" asChild>
|
||||
<Link href={props.teamMembersSettingsPath}>Team Member</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="default"
|
||||
className="absolute top-0 right-0 gap-2 lg:static"
|
||||
onClick={props.createProject}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<span>
|
||||
<span className="hidden lg:inline">Create</span> Project
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -307,11 +292,9 @@ function SelectBy(props: {
|
||||
props.onChange(v as SortById);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-[200px] bg-card capitalize">
|
||||
<SelectTrigger className="min-w-[200px] bg-background capitalize">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="!hidden lg:!inline text-muted-foreground">
|
||||
Sort by
|
||||
</span>
|
||||
<span className="text-muted-foreground">Sort by</span>
|
||||
{valueToLabel[props.value]}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
||||
+1
-1
@@ -238,7 +238,7 @@ function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
|
||||
+24
-6
@@ -1,3 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileCode2Icon, MessageSquareQuoteIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ResponsiveSearchParamsProvider,
|
||||
ResponsiveSuspense,
|
||||
@@ -20,17 +23,32 @@ export function NebulaAnalyticsPage(props: {
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={props.searchParams}>
|
||||
<header className="border-b">
|
||||
<div className="container flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
|
||||
<div className="flex-1">
|
||||
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
|
||||
Nebula
|
||||
</h1>
|
||||
<div className="container flex flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" className="gap-2 bg-card" asChild>
|
||||
<Link href="https://nebula.thirdweb.com" target="_blank">
|
||||
<MessageSquareQuoteIcon className="size-4 text-muted-foreground" />
|
||||
Playground
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="gap-2 bg-card" asChild>
|
||||
<Link href="https://portal.thirdweb.com/nebula" target="_blank">
|
||||
<FileCode2Icon className="size-4 text-muted-foreground" />
|
||||
API Reference
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<NebulaAnalyticsFilter />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container pt-8 pb-20">
|
||||
<div className="mb-4 flex flex-col justify-between gap-3 md:flex-row md:items-end">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Analytics</h2>
|
||||
<NebulaAnalyticsFilter />
|
||||
</div>
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={<NebulaAnalyticsDashboardUI data={[]} isPending={true} />}
|
||||
|
||||
+4
@@ -36,6 +36,10 @@ function Story(props: {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-4 py-6">
|
||||
<ProjectGeneralSettingsPageUI
|
||||
updateProjectImage={async (file) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
console.log("updateProjectImage", file);
|
||||
}}
|
||||
isOwnerAccount={props.isOwnerAccount}
|
||||
transferProject={async (newTeam) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
+94
-1
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
|
||||
@@ -31,6 +32,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RotateSecretKeyAPIReturnType } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
type ServiceName,
|
||||
getServiceByName,
|
||||
} from "@thirdweb-dev/service-utils";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { format } from "date-fns";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import {
|
||||
@@ -60,11 +63,11 @@ import { type UseFormReturn, useForm } from "react-hook-form";
|
||||
import { type FieldArrayWithId, useFieldArray } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { RE_BUNDLE_ID } from "utils/regex";
|
||||
import { joinWithComma, toArrFromList } from "utils/string";
|
||||
import { validStrList } from "utils/validations";
|
||||
import { z } from "zod";
|
||||
import { apiServerProxy } from "../../../../../@/actions/proxies";
|
||||
import {
|
||||
HIDDEN_SERVICES,
|
||||
projectDomainsSchema,
|
||||
@@ -119,6 +122,29 @@ export function ProjectGeneralSettingsPage(props: {
|
||||
client={props.client}
|
||||
teamSlug={props.teamSlug}
|
||||
project={props.project}
|
||||
updateProjectImage={async (file) => {
|
||||
let uri: string | undefined = undefined;
|
||||
|
||||
if (file) {
|
||||
// upload to IPFS
|
||||
uri = await upload({
|
||||
client: props.client,
|
||||
files: [file],
|
||||
});
|
||||
}
|
||||
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
image: uri,
|
||||
},
|
||||
);
|
||||
|
||||
router.refresh();
|
||||
}}
|
||||
updateProject={async (projectValues) => {
|
||||
return updateProjectClient(
|
||||
{
|
||||
@@ -184,6 +210,7 @@ export function ProjectGeneralSettingsPageUI(props: {
|
||||
client: ThirdwebClient;
|
||||
transferProject: (newTeam: Team) => Promise<void>;
|
||||
isOwnerAccount: boolean;
|
||||
updateProjectImage: (file: File | undefined) => Promise<void>;
|
||||
}) {
|
||||
const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`;
|
||||
|
||||
@@ -320,6 +347,12 @@ export function ProjectGeneralSettingsPageUI(props: {
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<ProjectImageSetting
|
||||
updateProjectImage={props.updateProjectImage}
|
||||
avatar={project.image || null}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
<ProjectKeyDetails
|
||||
project={project}
|
||||
rotateSecretKey={props.rotateSecretKey}
|
||||
@@ -402,6 +435,66 @@ function ProjectNameSetting(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectImageSetting(props: {
|
||||
updateProjectImage: (file: File | undefined) => Promise<void>;
|
||||
avatar: string | null;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const projectAvatarUrl = resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: props.avatar || undefined,
|
||||
});
|
||||
|
||||
const [projectAvatar, setProjectAvatar] = useState<File | undefined>();
|
||||
|
||||
const updateProjectAvatarMutation = useMutation({
|
||||
mutationFn: async (_avatar: File | undefined) => {
|
||||
await props.updateProjectImage(_avatar);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const promise = updateProjectAvatarMutation.mutateAsync(projectAvatar);
|
||||
toast.promise(promise, {
|
||||
success: "Project avatar updated successfully",
|
||||
error: "Failed to update project avatar",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
bottomText="An avatar is optional but strongly recommended."
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: false,
|
||||
isPending: updateProjectAvatarMutation.isPending,
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<div className="flex flex-row gap-4 md:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
Project Avatar
|
||||
</h3>
|
||||
<p className="mt-1.5 mb-4 text-foreground text-sm leading-relaxed">
|
||||
This is your project's avatar. <br /> Click on the avatar to upload
|
||||
a custom one
|
||||
</p>
|
||||
</div>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
value={projectAvatar}
|
||||
setValue={setProjectAvatar}
|
||||
className="w-20 rounded-full lg:w-28"
|
||||
disableHelperText
|
||||
fileUrl={projectAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AllowedDomainsSetting(props: {
|
||||
form: UpdateAPIForm;
|
||||
isUpdatingProject: boolean;
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
NotificationButtonInner,
|
||||
type NotificationMetadata,
|
||||
} from "./NotificationButton";
|
||||
|
||||
const meta = {
|
||||
title: "headers/components/NotificationButton",
|
||||
component: NotificationButtonInner,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex justify-end px-6 py-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof NotificationButtonInner>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const titlesToPickFrom = [
|
||||
"Insight API Supports Block Queries",
|
||||
"Insight Adds Search Capability",
|
||||
"RPC Edge: Faster, Leaner, and Smarter",
|
||||
".NET/Unity - Insight Indexer and Engine Wallet Integration",
|
||||
"Invite and Manage Team Members on Dashboard",
|
||||
"Insight API ENS Support",
|
||||
"Insight Token Queries Just Got 10X Faster",
|
||||
"Insight indexer major version upgrade",
|
||||
"Introducing Universal Bridge",
|
||||
"Unreal Engine Plugin v2",
|
||||
"Cross-chain deterministic contract deployments",
|
||||
"Organize Contracts into Projects on Dashboard",
|
||||
".NET/Unity - Auth token login, timeout improvements, rotating secret keys.",
|
||||
"Mint fees for contract deployments update",
|
||||
"Engine v2.1.32: Circle Wallet and Secure Credential Management",
|
||||
"Nebula Update v0.0.7: Support for swapping and bridging, Offchain",
|
||||
"Insight - Automatic Event & Transaction Resolution",
|
||||
"Nebula Update v0.0.6: Upgraded Model, Speed & Accuracy Improvements",
|
||||
"Enhancing RPC Edge Support with Support IDs",
|
||||
"Blocks data retrieval with Insight [v0.2.2-beta]",
|
||||
"Insight reorg handling improvement",
|
||||
"Resilient Infrastructure & Smarter Insights",
|
||||
"Nebula Update v0.0.5: Advanced configurations, new endpoints, and simplified context filters",
|
||||
"Universal Smart Wallet Addresses",
|
||||
".NET/Unity - Nebula Integration for native apps and games!",
|
||||
];
|
||||
|
||||
function generateRandomNotification(isRead: boolean): NotificationMetadata {
|
||||
const index = Math.floor(Math.random() * titlesToPickFrom.length);
|
||||
const safeIndex = Math.min(index, titlesToPickFrom.length - 1);
|
||||
const title = titlesToPickFrom[safeIndex] || "";
|
||||
const randomTimeAgo = Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // Random time within last 7 days
|
||||
|
||||
return {
|
||||
title,
|
||||
href: "https://blog.thirdweb.com/changelog",
|
||||
isRead,
|
||||
createdAt: new Date(Date.now() - randomTimeAgo).toISOString(),
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
function randomNotifications(count: number): NotificationMetadata[] {
|
||||
return Array.from({ length: count }, () => generateRandomNotification(false));
|
||||
}
|
||||
|
||||
export const AllUnread: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(10),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: [],
|
||||
isPending: true,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: [],
|
||||
isPending: true,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const NoNotifications: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: [],
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: [],
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const AllRead: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(30).map((x) => ({
|
||||
...x,
|
||||
isRead: true,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30).map((x) => ({
|
||||
...x,
|
||||
isRead: true,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const MixedNotifications: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(10).map((x) => ({
|
||||
...x,
|
||||
isRead: Math.random() > 0.5,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30).map((x) => ({
|
||||
...x,
|
||||
isRead: Math.random() > 0.5,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const NoUnreadInboxNotifications: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(5).map((x) => ({
|
||||
...x,
|
||||
isRead: true,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30).map((x) => ({
|
||||
...x,
|
||||
isRead: Math.random() > 0.5,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { ActivityIcon, BellIcon, InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cleanupReadNotifications } from "./fetch-notifications";
|
||||
|
||||
export type NotificationMetadata = {
|
||||
title: string;
|
||||
id: string;
|
||||
href: string;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type NotificationTabs = {
|
||||
changelogs: {
|
||||
notifications: NotificationMetadata[];
|
||||
isPending: boolean;
|
||||
};
|
||||
inbox: {
|
||||
notifications: NotificationMetadata[];
|
||||
isPending: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function NotificationButtonUI(props: {
|
||||
getChangelogs: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const inboxNotificationsQuery = useQuery({
|
||||
queryKey: ["inbox-notifications"],
|
||||
queryFn: props.getInboxNotifications,
|
||||
});
|
||||
|
||||
const changelogsQuery = useQuery({
|
||||
queryKey: ["changelogs"],
|
||||
queryFn: props.getChangelogs,
|
||||
});
|
||||
|
||||
const [readNotifications, setReadNotifications] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const changelogNotifications = useMemo(() => {
|
||||
return (changelogsQuery.data || []).map((notification) => ({
|
||||
...notification,
|
||||
isRead: notification.isRead || readNotifications.has(notification.id),
|
||||
}));
|
||||
}, [changelogsQuery.data, readNotifications]);
|
||||
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return (inboxNotificationsQuery.data || []).map((notification) => ({
|
||||
...notification,
|
||||
isRead: notification.isRead || readNotifications.has(notification.id),
|
||||
}));
|
||||
}, [inboxNotificationsQuery.data, readNotifications]);
|
||||
|
||||
// Ensure we don't keep adding things to the IndexedDB storage by removing notification ids that are no longer being displayed
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (changelogNotifications.length > 0 && inboxNotifications.length > 0) {
|
||||
cleanupReadNotifications({
|
||||
changelogs: changelogNotifications,
|
||||
updates: inboxNotifications,
|
||||
});
|
||||
}
|
||||
}, [changelogNotifications, inboxNotifications]);
|
||||
|
||||
const markAsRead = useCallback(
|
||||
async (id: string) => {
|
||||
setReadNotifications((prev) => new Set([...prev, id]));
|
||||
await props.markNotificationAsRead(id);
|
||||
},
|
||||
[props.markNotificationAsRead],
|
||||
);
|
||||
|
||||
const notificationTabs: NotificationTabs = {
|
||||
inbox: {
|
||||
notifications: inboxNotifications,
|
||||
isPending: inboxNotificationsQuery.isPending,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: changelogNotifications,
|
||||
isPending: changelogsQuery.isPending,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationButtonInner
|
||||
notificationTabs={notificationTabs}
|
||||
markNotificationAsRead={markAsRead}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationButtonInner(props: {
|
||||
notificationTabs: NotificationTabs;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const hasUnreadInboxNotifications =
|
||||
props.notificationTabs.inbox.notifications.some((x) => !x.isRead);
|
||||
const [tab, setTab] = useState<keyof NotificationTabs>("inbox");
|
||||
|
||||
const unreadNotifications = {
|
||||
inbox: props.notificationTabs.inbox.notifications.filter((x) => !x.isRead),
|
||||
changelogs: props.notificationTabs.changelogs.notifications.filter(
|
||||
(x) => !x.isRead,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="relative size-10 rounded-full bg-background p-0"
|
||||
variant="outline"
|
||||
>
|
||||
<BellIcon className="size-4 text-muted-foreground" />
|
||||
{hasUnreadInboxNotifications && (
|
||||
<div className="absolute top-0 right-0 flex size-2.5 items-center justify-center rounded-full bg-red-500" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="!z-50 ml-4 w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-border p-0 shadow-xl lg:ml-0 lg:w-[500px]"
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
side="bottom"
|
||||
>
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabContainerClassName="px-2 pt-1.5"
|
||||
tabs={[
|
||||
{
|
||||
isActive: tab === "inbox",
|
||||
name: (
|
||||
<BadgeTab
|
||||
name="Inbox"
|
||||
unreadCount={unreadNotifications.inbox.length}
|
||||
/>
|
||||
),
|
||||
onClick: () => setTab("inbox"),
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
isActive: tab === "changelogs",
|
||||
name: (
|
||||
<BadgeTab
|
||||
name="Changelog"
|
||||
unreadCount={unreadNotifications.changelogs.length}
|
||||
/>
|
||||
),
|
||||
onClick: () => setTab("changelogs"),
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{tab === "inbox" && (
|
||||
<NotificationContent
|
||||
notifications={props.notificationTabs.inbox.notifications}
|
||||
isPending={props.notificationTabs.inbox.isPending}
|
||||
icon={InboxIcon}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "changelogs" && (
|
||||
<NotificationContent
|
||||
notifications={props.notificationTabs.changelogs.notifications}
|
||||
isPending={props.notificationTabs.changelogs.isPending}
|
||||
icon={ActivityIcon}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeTab(props: {
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{props.name}
|
||||
{props.unreadCount > 0 && (
|
||||
<span className="rounded-lg bg-accent px-1.5 py-1 text-xs">
|
||||
{props.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationContent(props: {
|
||||
notifications: NotificationMetadata[];
|
||||
isPending: boolean;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<ScrollShadow
|
||||
scrollableClassName="h-[70vh] overscroll-contain"
|
||||
shadowClassName="z-20"
|
||||
shadowColor="hsl(var(--background))"
|
||||
>
|
||||
{props.notifications?.map((notification) => (
|
||||
<NotificationLink
|
||||
notification={notification}
|
||||
key={notification.id}
|
||||
icon={props.icon}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!props.isPending && props.notifications.length === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
<div>
|
||||
<div className="mb-3 flex justify-center">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
|
||||
<props.icon className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
No Notifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationLink(props: {
|
||||
notification: NotificationMetadata;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const { notification } = props;
|
||||
|
||||
const link = new URL(notification.href);
|
||||
link.searchParams.append("utm_source", "notification");
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (!notification.isRead) {
|
||||
await props.markNotificationAsRead(notification.id);
|
||||
}
|
||||
}, [notification.id, notification.isRead, props.markNotificationAsRead]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
className="relative flex items-start gap-4 border-b px-4 py-4 hover:bg-accent/50"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
|
||||
<props.icon className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grow">
|
||||
<h3 className="font-medium text-foreground text-sm">
|
||||
{notification.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{formatDistance(new Date(notification.createdAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-6 items-center justify-center self-stretch">
|
||||
{!notification.isRead && (
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { get, set } from "idb-keyval";
|
||||
import type { NotificationMetadata } from "./NotificationButton";
|
||||
|
||||
type ChangelogItem = {
|
||||
published_at: string;
|
||||
title: string;
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
// Note: This saving read notifications in IndexedDB is temporary implementation - This will soon be replaced by a proper API setup
|
||||
|
||||
const READ_NOTIFICATIONS_KEY = "thirdweb:read-notifications";
|
||||
|
||||
// get set of notification ids marked as read
|
||||
async function getReadNotificationIds(): Promise<Set<string>> {
|
||||
const readIds = (await get<string[]>(READ_NOTIFICATIONS_KEY)) || [];
|
||||
return new Set(readIds);
|
||||
}
|
||||
|
||||
// fetches last 20 posts with tag "changelog" notifications
|
||||
async function fetchGhostPosts(tag: string) {
|
||||
const res = await fetch(
|
||||
`https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=id,title,url,published_at&filter=tag:${tag}&visibility:public&limit=20`,
|
||||
);
|
||||
const json = await res.json();
|
||||
return json.posts as ChangelogItem[];
|
||||
}
|
||||
|
||||
// Clean up the indexedDB storage of read notification IDs
|
||||
// This is to prevent the storage from growing indefinitely
|
||||
// Remove notification IDs from storage that are no longer shown to the user
|
||||
export async function cleanupReadNotifications(params: {
|
||||
changelogs: NotificationMetadata[];
|
||||
updates: NotificationMetadata[];
|
||||
}) {
|
||||
const { changelogs, updates } = params;
|
||||
const readIds = await getReadNotificationIds();
|
||||
|
||||
// Get all current notification IDs
|
||||
const currentIds = new Set([
|
||||
...changelogs.map((item) => item.id),
|
||||
...updates.map((item) => item.id),
|
||||
]);
|
||||
|
||||
// remove ids that are no longer being displayed
|
||||
const usedIds = new Set(
|
||||
Array.from(readIds).filter((id) => currentIds.has(id)),
|
||||
);
|
||||
|
||||
await set(READ_NOTIFICATIONS_KEY, Array.from(usedIds));
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(id: string) {
|
||||
const readIds = await getReadNotificationIds();
|
||||
readIds.add(id);
|
||||
|
||||
// Clean up old notification IDs periodically (every 10th notification marked as read)
|
||||
await set(READ_NOTIFICATIONS_KEY, Array.from(readIds));
|
||||
}
|
||||
|
||||
export async function getChangelogNotifications(): Promise<
|
||||
NotificationMetadata[]
|
||||
> {
|
||||
const [changelogs, readIds] = await Promise.all([
|
||||
fetchGhostPosts("changelog"),
|
||||
getReadNotificationIds(),
|
||||
]);
|
||||
|
||||
return changelogs.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
href: item.url,
|
||||
isRead: readIds.has(item.id),
|
||||
createdAt: item.published_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getInboxNotifications(): Promise<NotificationMetadata[]> {
|
||||
const [changelogs, readIds] = await Promise.all([
|
||||
fetchGhostPosts("update"),
|
||||
getReadNotificationIds(),
|
||||
]);
|
||||
|
||||
return changelogs.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
href: item.url,
|
||||
isRead: readIds.has(item.id),
|
||||
createdAt: item.published_at,
|
||||
}));
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { ProjectSelectorUI } from "./ProjectSelectorUI";
|
||||
|
||||
type ProjectSelectorMobileMenuButtonProps = {
|
||||
@@ -14,6 +15,7 @@ type ProjectSelectorMobileMenuButtonProps = {
|
||||
projects: Project[];
|
||||
team: Team;
|
||||
createProject: (team: Team) => void;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
export function ProjectSelectorMobileMenuButton(
|
||||
@@ -46,6 +48,7 @@ export function ProjectSelectorMobileMenuButton(
|
||||
<DynamicHeight>
|
||||
<ProjectSelectorUI
|
||||
currentProject={props.currentProject}
|
||||
client={props.client}
|
||||
projects={props.projects}
|
||||
team={props.team}
|
||||
createProject={() => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CirclePlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { SearchInput } from "./SearchInput";
|
||||
|
||||
export function ProjectSelectorUI(props: {
|
||||
@@ -17,6 +18,7 @@ export function ProjectSelectorUI(props: {
|
||||
currentProject: Project | undefined;
|
||||
team: Team;
|
||||
createProject: () => void;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { projects, currentProject, team } = props;
|
||||
const [searchProjectTerm, setSearchProjectTerm] = useState("");
|
||||
@@ -60,8 +62,11 @@ export function ProjectSelectorUI(props: {
|
||||
>
|
||||
<Link href={`/team/${team.slug}/${project.slug}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* TODO - set Image */}
|
||||
<ProjectAvatar src="" className="size-6" />
|
||||
<ProjectAvatar
|
||||
src={project.image || ""}
|
||||
className="size-6"
|
||||
client={props.client}
|
||||
/>
|
||||
<span className="truncate"> {project.name}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
|
||||
+4
-3
@@ -9,10 +9,10 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { ProjectSelectorUI } from "./ProjectSelectorUI";
|
||||
import { TeamSelectionUI } from "./TeamSelectionUI";
|
||||
|
||||
@@ -23,6 +23,7 @@ type TeamSwitcherProps = {
|
||||
focus: "project-selection" | "team-selection";
|
||||
createProject: (team: Team) => void;
|
||||
account: Pick<Account, "email" | "id"> | undefined;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
|
||||
@@ -31,7 +32,6 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
|
||||
const [hoveredTeam, setHoveredTeam] = useState<Team>();
|
||||
const projectsToShowOfTeam =
|
||||
hoveredTeam || currentTeam || teamsAndProjects[0]?.team;
|
||||
const client = useThirdwebClient();
|
||||
|
||||
// if we can't find a single team associated with this user - something is really wrong
|
||||
if (!projectsToShowOfTeam) {
|
||||
@@ -93,7 +93,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
|
||||
: undefined
|
||||
}
|
||||
account={props.account}
|
||||
client={client}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
{/* Right */}
|
||||
@@ -102,6 +102,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
|
||||
currentProject={props.currentProject}
|
||||
projects={projectsToShow}
|
||||
team={projectsToShowOfTeam}
|
||||
client={props.client}
|
||||
createProject={() => {
|
||||
setOpen(false);
|
||||
props.createProject(projectsToShowOfTeam);
|
||||
|
||||
@@ -52,6 +52,11 @@ function Variants(props: {
|
||||
if (!team1 || !team2 || !team3 || !team3Project) {
|
||||
return <div> failed to get team and project stubs </div>;
|
||||
}
|
||||
|
||||
const getChangelogsStub = () => Promise.resolve([]);
|
||||
const getInboxNotificationsStub = () => Promise.resolve([]);
|
||||
const markNotificationAsReadStub = () => Promise.resolve();
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="flex min-h-dvh flex-col gap-6 bg-background py-10">
|
||||
@@ -70,6 +75,9 @@ function Variants(props: {
|
||||
id: "1",
|
||||
}}
|
||||
client={client}
|
||||
getChangelogNotifications={getChangelogsStub}
|
||||
getInboxNotifications={getInboxNotificationsStub}
|
||||
markNotificationAsRead={markNotificationAsReadStub}
|
||||
/>
|
||||
</div>
|
||||
</BadgeContainer>
|
||||
@@ -89,6 +97,9 @@ function Variants(props: {
|
||||
connectButton={<ConnectButtonStub />}
|
||||
createProject={() => {}}
|
||||
client={client}
|
||||
getChangelogNotifications={getChangelogsStub}
|
||||
getInboxNotifications={getInboxNotificationsStub}
|
||||
markNotificationAsRead={markNotificationAsReadStub}
|
||||
/>
|
||||
</div>
|
||||
</BadgeContainer>
|
||||
@@ -108,6 +119,9 @@ function Variants(props: {
|
||||
connectButton={<ConnectButtonStub />}
|
||||
createProject={() => {}}
|
||||
client={client}
|
||||
getChangelogNotifications={getChangelogsStub}
|
||||
getInboxNotifications={getInboxNotificationsStub}
|
||||
markNotificationAsRead={markNotificationAsReadStub}
|
||||
/>
|
||||
</div>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -10,6 +10,10 @@ import { SecondaryNav } from "../../../components/Header/SecondaryNav/SecondaryN
|
||||
import { MobileBurgerMenuButton } from "../../../components/MobileBurgerMenuButton";
|
||||
import { TeamPlanBadge } from "../../../components/TeamPlanBadge";
|
||||
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
|
||||
import {
|
||||
NotificationButtonUI,
|
||||
type NotificationMetadata,
|
||||
} from "../NotificationButton/NotificationButton";
|
||||
import { ProjectSelectorMobileMenuButton } from "./ProjectSelectorMobileMenuButton";
|
||||
import { TeamAndProjectSelectorPopoverButton } from "./TeamAndProjectSelectorPopoverButton";
|
||||
import { TeamSelectorMobileMenuButton } from "./TeamSelectorMobileMenuButton";
|
||||
@@ -26,6 +30,9 @@ export type TeamHeaderCompProps = {
|
||||
createProject: (team: Team) => void;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
@@ -68,6 +75,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
focus="team-selection"
|
||||
createProject={props.createProject}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -79,8 +87,11 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
href={`/team/${props.currentTeam.slug}/${props.currentProject.slug}`}
|
||||
className="flex flex-row items-center gap-2 font-semibold text-sm"
|
||||
>
|
||||
{/* TODO - set project avatar image */}
|
||||
<ProjectAvatar src="" className="size-6" />
|
||||
<ProjectAvatar
|
||||
src={props.currentProject.image || ""}
|
||||
className="size-6"
|
||||
client={props.client}
|
||||
/>
|
||||
{props.currentProject.name}
|
||||
</Link>
|
||||
|
||||
@@ -91,6 +102,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
focus="project-selection"
|
||||
createProject={props.createProject}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -103,6 +115,9 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
connectButton={props.connectButton}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
@@ -171,19 +186,28 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
|
||||
projects={projects}
|
||||
team={props.currentTeam}
|
||||
createProject={props.createProject}
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
type="loggedIn"
|
||||
email={props.account?.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
type="loggedIn"
|
||||
email={props.account.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import { useCallback, useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { LazyCreateProjectDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { doLogout } from "../../../login/auth-actions";
|
||||
import {
|
||||
getChangelogNotifications,
|
||||
getInboxNotifications,
|
||||
markNotificationAsRead,
|
||||
} from "../NotificationButton/fetch-notifications";
|
||||
import {
|
||||
type TeamHeaderCompProps,
|
||||
TeamHeaderDesktopUI,
|
||||
@@ -58,6 +63,9 @@ export function TeamHeaderLoggedIn(props: {
|
||||
},
|
||||
client: getThirdwebClient(),
|
||||
accountAddress: props.accountAddress,
|
||||
getChangelogNotifications: getChangelogNotifications,
|
||||
getInboxNotifications: getInboxNotifications,
|
||||
markNotificationAsRead: markNotificationAsRead,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getDefaultTeam } from "@/api/team";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
export default async function TeamRootPage() {
|
||||
// get all teams, then go to the first team
|
||||
const teams = await getTeams();
|
||||
|
||||
const firstTeam = teams?.[0];
|
||||
const firstTeam = await getDefaultTeam();
|
||||
if (!firstTeam) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -214,11 +214,10 @@ export const MismatchButton = forwardRef<
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"z-[10001] gap-0 p-0",
|
||||
"gap-0 p-0",
|
||||
dialog === "no-funds" && "max-w-[480px]",
|
||||
dialog === "pay" && "max-w-[360px] border-none bg-transparent",
|
||||
)}
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
dialogCloseClassName="focus:ring-0"
|
||||
>
|
||||
<DynamicHeight>
|
||||
|
||||
@@ -190,10 +190,7 @@ export const CmdKSearchModal = (props: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="z-[10000001] gap-0 p-0"
|
||||
dialogOverlayClassName="z-[10000000]"
|
||||
>
|
||||
<DialogContent className="gap-0 p-0">
|
||||
{/* Title */}
|
||||
<DynamicHeight>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -21,10 +21,7 @@ export const ConfigureNetworkModal: React.FC<ConfigureNetworkModalProps> = (
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] max-w-[480px] p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="max-w-[480px] p-0">
|
||||
<ConfigureNetworks
|
||||
onNetworkAdded={(chain) => {
|
||||
addRecentlyUsedChainId(chain.chainId);
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ function teamsAndProjectsStub(teamCount: number, projectCount: number) {
|
||||
projects.push({
|
||||
id: `project_${i + 1}_${j + 1}`,
|
||||
name: `Project ${i + 1}_${j + 1}`,
|
||||
image: `https://picsum.photos/200?random=${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -20,7 +20,7 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import { Fieldset } from "./common";
|
||||
|
||||
export type MinimalTeam = Pick<Team, "id" | "name" | "image" | "slug">;
|
||||
export type MinimalProject = Pick<Project, "id" | "name">; // TODO: add image when project has image
|
||||
export type MinimalProject = Pick<Project, "id" | "name" | "image">;
|
||||
|
||||
export type TeamAndProjectSelection = {
|
||||
team: MinimalTeam | undefined;
|
||||
@@ -206,7 +206,11 @@ export function AddToProjectSelector(props: {
|
||||
className="py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProjectAvatar src="" className="size-5" />
|
||||
<ProjectAvatar
|
||||
src={project.image || ""}
|
||||
className="size-5"
|
||||
client={props.client}
|
||||
/>
|
||||
<span>{project.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
+1
-2
@@ -77,8 +77,7 @@ export function DeployStatusModal(props: {
|
||||
<Dialog open={isModalOpen}>
|
||||
<DialogContent
|
||||
dialogCloseClassName="hidden"
|
||||
className="z-[10001] gap-0 p-0 md:max-w-[480px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
className="gap-0 p-0 md:max-w-[480px]"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -49,10 +49,7 @@ export const ImportModal: React.FC<ImportModalProps> = (props) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
dialogOverlayClassName="z-[9000] rounded-lg"
|
||||
className="z-[9001]"
|
||||
>
|
||||
<DialogContent dialogOverlayClassName="rounded-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-semibold text-2xl tracking-tight">
|
||||
Import Contract
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { formatDistance } from "date-fns";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
type ChangelogItem = {
|
||||
published_at: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function Changelog() {
|
||||
const changelog = await getChangelog();
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-6 border-border border-l py-2">
|
||||
{changelog.map((item) => (
|
||||
<div className="flex flex-row gap-4" key={item.title}>
|
||||
<div className="-translate-x-1/2 size-2.5 shrink-0 translate-y-1/2 rounded-full bg-border" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`${item.url}?utm_source=thirdweb&utm_campaign=changelog`}
|
||||
role="group"
|
||||
className="line-clamp-2 text-foreground text-sm hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
{formatDistance(new Date(item.published_at), Date.now(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
href="https://blog.thirdweb.com/changelog?utm_source=thirdweb&utm_campaign=changelog"
|
||||
target="_blank"
|
||||
className="!text-foreground flex items-center gap-2 pl-7 text-sm"
|
||||
>
|
||||
View More <ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function getChangelog() {
|
||||
const res = await fetch(
|
||||
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=7",
|
||||
);
|
||||
const json = await res.json();
|
||||
return json.posts as ChangelogItem[];
|
||||
}
|
||||
+2
-5
@@ -42,10 +42,7 @@ export function ShareFreeWalletsModal(props: {
|
||||
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] p-0 md:max-w-[380px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="p-0 md:max-w-[380px]">
|
||||
<DynamicHeight>
|
||||
{screen === "base" && (
|
||||
<div className="fade-in-0 animate-in duration-300">
|
||||
@@ -163,7 +160,7 @@ function SendEmailScreen(props: {
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Email App" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="default">
|
||||
Default Email App
|
||||
|
||||
@@ -106,8 +106,7 @@ export const CreateProjectDialogUI = (props: {
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
className="overflow-hidden p-0"
|
||||
dialogCloseClassName={screen.id === "api-details" ? "hidden" : ""}
|
||||
>
|
||||
<DynamicHeight>
|
||||
@@ -274,7 +273,7 @@ function CreateProjectForm(props: {
|
||||
<form onSubmit={handleSubmit} autoComplete="off">
|
||||
<div className="p-6">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle className="text-2xl">Create a Project</DialogTitle>
|
||||
<DialogTitle className="text-2xl">Create Project</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
@@ -19,7 +19,10 @@ const walletIdToPHName: Record<string, string> = {
|
||||
injected: "Injected",
|
||||
};
|
||||
|
||||
export const PosthogIdentifier: React.FC = () => {
|
||||
export const PosthogIdentifierClient: React.FC<{
|
||||
accountId: string | undefined;
|
||||
accountAddress: string | undefined;
|
||||
}> = ({ accountId, accountAddress }) => {
|
||||
const client = useThirdwebClient();
|
||||
const account = useActiveAccount();
|
||||
const chain = useActiveWalletChain();
|
||||
@@ -43,10 +46,17 @@ export const PosthogIdentifier: React.FC = () => {
|
||||
// legitimate use-case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (account?.address) {
|
||||
posthog.identify(account.address);
|
||||
if (accountAddress) {
|
||||
posthog.identify(accountAddress);
|
||||
}
|
||||
}, [account?.address]);
|
||||
}, [accountAddress]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
posthog.identify(accountId);
|
||||
}
|
||||
}, [accountId]);
|
||||
|
||||
// legitimate use-case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getRawAccount } from "../../app/account/settings/getAccount";
|
||||
import { getAuthTokenWalletAddress } from "../../app/api/lib/getAuthToken";
|
||||
import { PosthogIdentifierClient } from "./PosthogIdentifier";
|
||||
|
||||
export async function PosthogIdentifierServer() {
|
||||
const [account, accountAddress] = await Promise.all([
|
||||
getRawAccount(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PosthogIdentifierClient
|
||||
accountId={account?.id}
|
||||
accountAddress={accountAddress || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -77,10 +77,7 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="z-[10001] max-w-[480px] gap-0 p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="max-w-[480px] gap-0 p-0">
|
||||
{/* min-w-0 is actually required here */}
|
||||
<div className="flex min-w-0 flex-col gap-6 p-6">
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getDefaultTeam } from "@/api/team";
|
||||
import { isLoginRequired } from "@/constants/auth";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
@@ -165,9 +165,7 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
// redirect /team/~/... to /team/<first_team_slug>/...
|
||||
if (paths[0] === "team" && paths[1] === "~") {
|
||||
// TODO - need an API to get the first team to avoid fetching all teams
|
||||
const teams = await getTeams();
|
||||
const firstTeam = teams?.[0];
|
||||
const firstTeam = await getDefaultTeam();
|
||||
if (firstTeam) {
|
||||
const modifiedPaths = [...paths];
|
||||
modifiedPaths[1] = firstTeam.slug;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "6.0.7",
|
||||
"lucide-react": "0.476.0",
|
||||
"next": "15.1.7",
|
||||
"next": "15.2.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.7",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"postcss": "8.5.3",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.7.3"
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "6.0.7",
|
||||
"lucide-react": "0.476.0",
|
||||
"next": "15.1.7",
|
||||
"next": "15.2.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "^12.1.3",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "15.1.7",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221",
|
||||
"postcss": "8.5.3",
|
||||
"tailwindcss": "3.4.17",
|
||||
|
||||
@@ -39,6 +39,8 @@ next-env.d.ts
|
||||
|
||||
# generated files
|
||||
searchIndex.json
|
||||
public/llms.txt
|
||||
public/llms-full.txt
|
||||
|
||||
.env
|
||||
public/sitemap*.xml
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@dirtycajunrice/klee": "^1.0.6",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@next/mdx": "15.1.7",
|
||||
"@next/mdx": "15.2.0",
|
||||
"@radix-ui/react-dialog": "1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
@@ -34,9 +34,11 @@
|
||||
"date-fns": "4.1.0",
|
||||
"flexsearch": "^0.7.43",
|
||||
"github-slugger": "^2.0.0",
|
||||
"he": "^1.2.0",
|
||||
"lucide-react": "0.476.0",
|
||||
"next": "15.1.7",
|
||||
"next": "15.2.0",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"node-html-markdown": "^1.3.0",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"posthog-js": "1.67.1",
|
||||
"prettier": "3.3.3",
|
||||
@@ -53,8 +55,9 @@
|
||||
"typedoc-better-json": "0.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "15.1.7",
|
||||
"@next/eslint-plugin-next": "15.2.0",
|
||||
"@types/flexsearch": "^0.7.6",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "22.13.5",
|
||||
"@types/react": "19.0.10",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { extractSearchData } from "../src/app/api/search/extraction";
|
||||
import { extractContent } from "../src/app/api/search/extraction";
|
||||
|
||||
async function main() {
|
||||
const rootDir = process.cwd();
|
||||
const websiteData = await extractSearchData(rootDir);
|
||||
writeFileSync("./searchIndex.json", JSON.stringify(websiteData, null, 2));
|
||||
const { searchData, llmContent, llmFullContent } =
|
||||
await extractContent(rootDir);
|
||||
writeFileSync("./searchIndex.json", JSON.stringify(searchData, null, 2));
|
||||
writeFileSync("./public/llms.txt", llmContent);
|
||||
writeFileSync("./public/llms-full.txt", llmFullContent);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { sidebar } from "./sidebar";
|
||||
|
||||
export default async function Layout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DocLayout sideBar={sidebar} editPageButton={true}>
|
||||
<DocLayout sideBar={sidebar} editPageButton={true} noLLM={true}>
|
||||
{props.children}
|
||||
</DocLayout>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import he from "he";
|
||||
import { NodeHtmlMarkdown } from "node-html-markdown";
|
||||
import {
|
||||
CommentNode as X_CommentNode,
|
||||
HTMLElement as X_HTMLElement,
|
||||
@@ -11,11 +13,35 @@ import { getFilesRecursive } from "./getFilesRecursive";
|
||||
import { ignoreHeadings } from "./settings";
|
||||
import { trimExtraSpace } from "./trimExtraSpace";
|
||||
|
||||
export async function extractSearchData(rootDir: string): Promise<PageData[]> {
|
||||
type ExtractedContent = {
|
||||
searchData: PageData[];
|
||||
llmContent: string;
|
||||
llmFullContent: string;
|
||||
};
|
||||
|
||||
const llmsContentHeader = `\
|
||||
# thirdweb
|
||||
|
||||
> Frontend, Backend, and Onchain tools to build complete web3 apps — on every EVM chain.
|
||||
|
||||
## Docs
|
||||
`;
|
||||
|
||||
const llmsFullContentHeader = `\
|
||||
# thirdweb
|
||||
|
||||
> Frontend, Backend, and Onchain tools to build complete web3 apps — on every EVM chain.
|
||||
`;
|
||||
|
||||
export async function extractContent(
|
||||
rootDir: string,
|
||||
): Promise<ExtractedContent> {
|
||||
const nextOutputDir = `${rootDir}/.next/server/app`;
|
||||
const htmlFiles = getFilesRecursive(nextOutputDir, "html");
|
||||
|
||||
const pages: PageData[] = [];
|
||||
let llmContent = "";
|
||||
let llmFullContent = "";
|
||||
|
||||
const noMainFound: string[] = [];
|
||||
const noH1Found: string[] = [];
|
||||
@@ -26,7 +52,7 @@ export async function extractSearchData(rootDir: string): Promise<PageData[]> {
|
||||
const mainEl = parse(htmlContent, {
|
||||
comment: false,
|
||||
blockTextElements: {
|
||||
pre: false, // parse text inside <pre> elements instead of treating it as text
|
||||
pre: true,
|
||||
},
|
||||
}).querySelector("main");
|
||||
|
||||
@@ -37,25 +63,38 @@ export async function extractSearchData(rootDir: string): Promise<PageData[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
const noIndex = mainEl.getAttribute("data-noindex");
|
||||
|
||||
if (noIndex) {
|
||||
if (mainEl.getAttribute("data-noindex") === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageTitle = mainEl.querySelector("h1")?.text;
|
||||
|
||||
if (!pageTitle) {
|
||||
noH1Found.push(
|
||||
filePath.split(".next/server/app")[1]?.replace(".html", "") || "",
|
||||
);
|
||||
}
|
||||
|
||||
pages.push({
|
||||
href: filePath.replace(nextOutputDir, "").replace(".html", ""),
|
||||
title: pageTitle ? trimExtraSpace(pageTitle) : "",
|
||||
sections: getPageSections(mainEl),
|
||||
});
|
||||
// Important: do the search index collection first - we will modify the main element in the next step
|
||||
// Extract search data
|
||||
const pageData = extractPageSearchData(
|
||||
mainEl,
|
||||
filePath,
|
||||
nextOutputDir,
|
||||
pageTitle,
|
||||
);
|
||||
if (pageData) {
|
||||
pages.push(pageData);
|
||||
}
|
||||
|
||||
// Extract LLM content
|
||||
const { links, full } = extractPageLLMContent(
|
||||
mainEl,
|
||||
pageTitle,
|
||||
filePath,
|
||||
nextOutputDir,
|
||||
);
|
||||
llmContent += links ? `${links}\n` : "";
|
||||
llmFullContent += full ? `${full}\n` : "";
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -77,13 +116,147 @@ export async function extractSearchData(rootDir: string): Promise<PageData[]> {
|
||||
console.warn("\n");
|
||||
}
|
||||
|
||||
return pages;
|
||||
return {
|
||||
searchData: pages,
|
||||
llmContent: `${llmsContentHeader}\n${llmContent}`,
|
||||
llmFullContent: `${llmsFullContentHeader}\n${llmFullContent}`,
|
||||
};
|
||||
}
|
||||
|
||||
function getPageSections(main: X_HTMLElement): PageSectionData[] {
|
||||
function extractPageSearchData(
|
||||
main: X_HTMLElement,
|
||||
filePath: string,
|
||||
nextOutputDir: string,
|
||||
pageTitle: string | undefined,
|
||||
): PageData | null {
|
||||
if (main.getAttribute("data-noindex") === "true") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
href: filePath.replace(nextOutputDir, "").replace(".html", ""),
|
||||
title: pageTitle ? trimExtraSpace(pageTitle) : "",
|
||||
sections: getPageSectionsForSearchIndex(main),
|
||||
};
|
||||
}
|
||||
|
||||
function extractPageLLMContent(
|
||||
main: X_HTMLElement,
|
||||
pageTitle: string | undefined,
|
||||
filePath: string,
|
||||
nextOutputDir: string,
|
||||
): { links: string; full: string } {
|
||||
if (
|
||||
main.getAttribute("data-noindex") === "true" ||
|
||||
main.getAttribute("data-no-llm") === "true"
|
||||
) {
|
||||
return { links: "", full: "" };
|
||||
}
|
||||
|
||||
const htmlToMarkdown = new NodeHtmlMarkdown({
|
||||
keepDataImages: false,
|
||||
});
|
||||
|
||||
let linksContent = "";
|
||||
let fullContent = "";
|
||||
|
||||
const pageUrl = filePath.replace(nextOutputDir, "").replace(".html", "");
|
||||
|
||||
// Get first non-empty paragraph for description
|
||||
const paragraphs = main.querySelectorAll("p");
|
||||
let description = "";
|
||||
for (const p of paragraphs) {
|
||||
// skip noindex or no-llm paragraphs
|
||||
if (
|
||||
p.getAttribute("data-noindex") === "true" ||
|
||||
p.getAttribute("data-no-llm") === "true"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
description = trimExtraSpace(htmlToMarkdown.translate(p.toString()));
|
||||
if (description) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
linksContent += `* [${pageTitle}](${pageUrl}): ${description}`;
|
||||
|
||||
// Remove noindex and no-llm elements
|
||||
const contentElements = main.querySelectorAll("*");
|
||||
for (const element of contentElements) {
|
||||
if (
|
||||
element.getAttribute("data-noindex") === "true" ||
|
||||
element.getAttribute("data-no-llm") === "true"
|
||||
) {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Shift all heading elements to 1 step down (h1 > h2, h2 > h3, etc.)
|
||||
const headings = main.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
||||
for (const heading of headings) {
|
||||
const headingLevel = Number.parseInt(heading.tagName.replace("H", ""));
|
||||
const newLevel = Math.min(headingLevel + 1, 6);
|
||||
heading.tagName = `H${newLevel}`;
|
||||
}
|
||||
|
||||
// prefix all the relative links with the `https://portal.thirdweb.com`
|
||||
const links = main.querySelectorAll("a");
|
||||
for (const link of links) {
|
||||
const [path, hash] = link.getAttribute("href")?.split("#") || [];
|
||||
if (path?.startsWith("/")) {
|
||||
link.setAttribute(
|
||||
"href",
|
||||
`https://portal.thirdweb.com${path}${hash ? `#${hash}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// for code blocks inside pre tags -> make them direct descendants of the pre tag
|
||||
// so they are parsed as blocks by node-html-markdown + add language class
|
||||
const preTags = main.querySelectorAll("pre");
|
||||
for (const preTag of preTags) {
|
||||
const codeBlock = parse(preTag.innerHTML.toString(), {
|
||||
comment: false,
|
||||
blockTextElements: {
|
||||
pre: true,
|
||||
},
|
||||
}).querySelector("code");
|
||||
|
||||
if (codeBlock) {
|
||||
const code = codeBlock
|
||||
.querySelectorAll("div > div > div > div")
|
||||
.map((x) => x.textContent)
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
const lang = codeBlock.getAttribute("lang");
|
||||
codeBlock.textContent = code;
|
||||
|
||||
const newCodePreBlock = parse(
|
||||
`<pre><code class=${lang ? `language-${lang}` : ""}>${he.encode(code)}</code></pre>`,
|
||||
);
|
||||
|
||||
preTag.replaceWith(newCodePreBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the cleaned HTML to markdown
|
||||
fullContent += `${htmlToMarkdown.translate(main.toString())}`;
|
||||
|
||||
return {
|
||||
links: linksContent,
|
||||
full: fullContent,
|
||||
};
|
||||
}
|
||||
|
||||
function getPageSectionsForSearchIndex(main: X_HTMLElement): PageSectionData[] {
|
||||
const sectionData: PageSectionData[] = [];
|
||||
|
||||
const ignoreTags = new Set(["code", "nav"].map((t) => t.toUpperCase()));
|
||||
const ignoreTags = new Set(
|
||||
["code", "nav", "pre"].map((t) => t.toUpperCase()),
|
||||
);
|
||||
|
||||
function collector(node: X_Node) {
|
||||
if (node instanceof X_CommentNode) {
|
||||
@@ -94,9 +267,7 @@ function getPageSections(main: X_HTMLElement): PageSectionData[] {
|
||||
return;
|
||||
}
|
||||
|
||||
const noIndexAttribute = node.getAttribute("data-noindex");
|
||||
|
||||
if (noIndexAttribute === "true") {
|
||||
if (node.getAttribute("data-noindex") === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { sidebar } from "./sidebar";
|
||||
|
||||
export default async function Layout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DocLayout sideBar={sidebar} editPageButton={true}>
|
||||
<DocLayout sideBar={sidebar} editPageButton={true} noLLM={true}>
|
||||
{props.children}
|
||||
</DocLayout>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ Contracts is an end-to-end development suite which provides tools to develop, de
|
||||
|
||||
### With Contracts, you can:
|
||||
|
||||
- Easily deploy custom or pre-built contracts to over [2,000+ available EVM networks](https://thirdweb.com/chainlist)
|
||||
- Easily deploy custom or pre-built contracts to [any EVM network](https://thirdweb.com/chainlist)
|
||||
- Create and publish contracts for your team or community to utilize with an easy-to-deploy interface
|
||||
- Create reusable contract components or modules to add to modular contracts to upgrade functionality
|
||||
- Manage contract functionality, upload NFTs, set distribution mechanisms, and more directly from the dashboard
|
||||
|
||||
@@ -18,7 +18,7 @@ Remote Procedure Call (RPC) Edge provides reliable access to querying data and i
|
||||
|
||||
<DocImage src={rpcDiagramImage} alt="RPC Diagram" />
|
||||
|
||||
By default, we provide publicly available RPCs for over 900+ EVM Networks. [View the default RPC endpoints](https://thirdweb.com/chainlist) for each supported chain.
|
||||
By default, we provide publicly available RPCs to every EVM chain. [View the default RPC endpoints](https://thirdweb.com/chainlist) for each supported chain.
|
||||
|
||||
<Callout variant="info" title="Accessing your RPC URL">
|
||||
To use RPC Edge with other tools like Anvil, take a chain's [public RPC endpoint](https://thirdweb.com/chainlist) and attach your app's client ID from Project > Settings page in [dashboard](https://thirdweb.com/team) `https://<chainId>.rpc.thirdweb.com/<clientId>`.
|
||||
|
||||
@@ -74,18 +74,24 @@ async function getEvents() {
|
||||
|
||||
### Chain IDs
|
||||
|
||||
The API supports chain IDs in the following format:
|
||||
The API supports chain IDs in the following formats:
|
||||
|
||||
- As a subdomain:
|
||||
```typescript
|
||||
interface ChainConfig {
|
||||
chainId: number; // Chain ID of the network you want to query
|
||||
}
|
||||
|
||||
// Example: Using a specific chain
|
||||
const chainId = "{{chainId}}"; // Replace with your desired chain ID
|
||||
// Example
|
||||
const baseUrl = `https://{{chainId}}.insight.thirdweb.com`;
|
||||
```
|
||||
|
||||
- As a query parameter (this is useful if you want to query multiple chains):
|
||||
```typescript
|
||||
// Example for a single chain
|
||||
const url = `https://insight.thirdweb.com/v1/events?chain={{chainId}}`;
|
||||
// Example for multiple chains
|
||||
const url = `https://insight.thirdweb.com/v1/events?chain=1&chain=137`;
|
||||
```
|
||||
|
||||
We won't duplicate multichain examples for each endpoint, but you can pass multiple chains in the query parameters pretty much everywhere!
|
||||
|
||||
### Base Response Structure
|
||||
|
||||
```typescript
|
||||
@@ -265,6 +271,12 @@ async function safeApiCall() {
|
||||
GET https://{{chainId}}.insight.thirdweb.com/v1/events
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```typescript
|
||||
GET https://insight.thirdweb.com/v1/events?chainId={{chainId1}}&chainId={{chainId2}}
|
||||
```
|
||||
|
||||
2. **Get Contract Events**
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -63,14 +63,22 @@ async function getEvents() {
|
||||
|
||||
### Chain IDs
|
||||
|
||||
- As a subdomain:
|
||||
```typescript
|
||||
type ChainId = number; // Chain ID of the network you want to query (e.g., 1 for Ethereum mainnet)
|
||||
|
||||
// Example: Using a specific chain
|
||||
const chainId = 1; // Ethereum mainnet
|
||||
const baseUrl = `https://${chainId}.insight.thirdweb.com`;
|
||||
// Example
|
||||
const baseUrl = `https://{{chainId}}.insight.thirdweb.com`;
|
||||
```
|
||||
|
||||
- As a query parameter (this is useful if you want to query multiple chains):
|
||||
```typescript
|
||||
// Example for a single chain
|
||||
const url = `https://insight.thirdweb.com/v1/events?chain={{chainId}}`;
|
||||
// Example for multiple chains
|
||||
const url = `https://insight.thirdweb.com/v1/events?chain=1&chain=137`;
|
||||
```
|
||||
|
||||
We won't duplicate multichain examples for each endpoint, but you can pass multiple chains in the query parameters pretty much everywhere!
|
||||
|
||||
### Base Response Structure
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -14,6 +14,8 @@ export const metadata = createMetadata({
|
||||
|
||||
A blueprint is an API that provides access to on-chain data in a user-friendly format. There's no need for ABIs, decoding, RPC, or web3 knowledge to fetch blockchain data. Every chain exposes the default blueprints below
|
||||
|
||||
All blueprints support [multichain queries](/insight/multichain-queries)!
|
||||
|
||||
## Events Blueprint
|
||||
|
||||
Blockchain events offers developers a powerful way to track and analyze blockchain events emitted by smart contracts. This endpoint provides access to detailed event information, such as the address of the contract that generated the event, the transaction that triggered it, and when and where the event occurred on the blockchain. By offering data points like event topics, block numbers, and transaction hashes, developers can easily filter and search for specific event types, such as token transfers or contract executions. With precise timestamps and block references, the Events Blueprint ensures that developers have real-time access to critical on-chain activity, enabling them to build responsive, high-performance applications that can monitor, analyze, and react to blockchain events seamlessly. Whether working on DeFi, NFTs, or general dApps, this endpoint helps unlock the full potential of blockchain data in any project.
|
||||
@@ -22,14 +24,14 @@ Blockchain events offers developers a powerful way to track and analyze blockcha
|
||||
|
||||
| Name | Description | Example |
|
||||
| ----------- | ----------- | ----------- |
|
||||
| Address | The `address` is the identifier of the smart contract or account that emitted the event. It tells you who or what generated the event. | If the event is related to an ERC-20 token transfer, the address would be the smart contract address of the token. For instance, if you’re tracking events for the USDT token, the address would be the contract address of USDT on Ethereum. |
|
||||
| Chain ID | The `chain_id` is a unique identifier for the blockchain network where the event occurred. Different blockchain networks (e.g., Ethereum, Binance Smart Chain, Polygon) have their own unique chain IDs. | Ethereum’s mainnet has a chain_id of 1, while Binance Smart Chain has a chain_id of 56. This field ensures you know which network the event is coming from, which is crucial when interacting with multiple EVM chains.
|
||||
| Data | The `data` field contains additional information specific to the event. In many cases, it’s the raw output of the smart contract event. | In an ERC-20 Transfer event, the data might include the number of tokens transferred in raw hexadecimal format. For instance, if 1000 USDT were transferred, this value would be encoded in the event’s data field.
|
||||
| Address | The `address` is the identifier of the smart contract or account that emitted the event. It tells you who or what generated the event. | If the event is related to an ERC-20 token transfer, the address would be the smart contract address of the token. For instance, if you're tracking events for the USDT token, the address would be the contract address of USDT on Ethereum. |
|
||||
| Chain ID | The `chain_id` is a unique identifier for the blockchain network where the event occurred. Different blockchain networks (e.g., Ethereum, Binance Smart Chain, Polygon) have their own unique chain IDs. | Ethereum's mainnet has a chain_id of 1, while Binance Smart Chain has a chain_id of 56. This field ensures you know which network the event is coming from, which is crucial when interacting with multiple EVM chains.
|
||||
| Data | The `data` field contains additional information specific to the event. In many cases, it's the raw output of the smart contract event. | In an ERC-20 Transfer event, the data might include the number of tokens transferred in raw hexadecimal format. For instance, if 1000 USDT were transferred, this value would be encoded in the event's data field.
|
||||
| Log Index | The `log_index` is the order of the log (event) in the list of all events within the block. It helps you pinpoint exactly where this event is located in the block. | If multiple events occurred within the same block (e.g., multiple token transfers), the log_index tells you the sequence of this particular event. For example, it might be the 3rd event in the block.
|
||||
| Topics | The `topics` array contains indexed parameters of the event, often used for filtering or categorizing events. The first topic contains the event signature (the function name), while the remaining topics contain indexed event arguments. | In an ERC-20 Transfer event, the first topic would be the hash of the event signature `Transfer(address,address,uint256)`. The second topic would be the sender’s address, and the third would be the receiver’s address. This allows filtering to track all Transfer events for a specific address.
|
||||
| Topics | The `topics` array contains indexed parameters of the event, often used for filtering or categorizing events. The first topic contains the event signature (the function name), while the remaining topics contain indexed event arguments. | In an ERC-20 Transfer event, the first topic would be the hash of the event signature `Transfer(address,address,uint256)`. The second topic would be the sender's address, and the third would be the receiver's address. This allows filtering to track all Transfer events for a specific address.
|
||||
| Transaction Hash | The `transaction_hash` is the unique identifier for the transaction that triggered the event. It helps you link the event to the specific transaction that caused it. | If you transfer 1 ETH to another wallet, the transaction hash might be 0x5c7b6f... which uniquely identifies that transaction. Using this hash, you can look up the transaction in any block explorer (e.g., Etherscan) to see all the details.
|
||||
| Transaction Index | The `transaction_index` tells you the position of the transaction within the block. This helps you identify where in the block this particular transaction was placed relative to others. | If multiple transactions occurred within the same block (e.g., multiple token transfers), the transaction_index tells you the sequence of this particular transaction. For example, it might be the 3rd transaction in the block. | If a block contains 100 transactions, the transaction_index might indicate that this event was triggered by the 45th transaction in the block. This is useful for debugging or tracing the order of execution.
|
||||
| Block Hash | A `block_hash` is the unique identifier of a block, generated after the block is confirmed. It’s like a tamper-proof record of all the transactions and events in that block. | If you wanted to verify that a particular transaction or event was part of a block, you could use the block hash to check its integrity. Think of it as a digital signature for the entire block, such as 0x91d… representing all transactions within that specific block.
|
||||
| Block Hash | A `block_hash` is the unique identifier of a block, generated after the block is confirmed. It's like a tamper-proof record of all the transactions and events in that block. | If you wanted to verify that a particular transaction or event was part of a block, you could use the block hash to check its integrity. Think of it as a digital signature for the entire block, such as 0x91d... representing all transactions within that specific block.
|
||||
| Block Number | The `block_number` indicates the position of the block in the blockchain. It tells you when (in terms of blockchain sequence) the event occurred. | Block number 12,345,678 on Ethereum might contain transactions from a particular moment, such as the transfer of 10 ETH between two accounts. You can think of block numbers like page numbers in a ledger.
|
||||
| Block Timestamp | The `block_timestamp` is the exact time when the block was mined and added to the blockchain. | If block 12,345,678 was mined on July 1, 2023, at 12:30 PM UTC, the timestamp will reflect this exact moment. This helps you pinpoint when an event, such as a token transfer, actually happened in real time.
|
||||
|
||||
@@ -41,7 +43,7 @@ Transaction data equips developers with the tools they need to interact with blo
|
||||
|
||||
| Name | Description | Example |
|
||||
| ----------- | ----------- | ----------- |
|
||||
| From Address | This is the address that initiated the transaction. It represents the sender who paid for the gas to execute the transaction. | If a user sends 1 ETH from their wallet, the `from_address` will be the sender’s Ethereum address, such as `0xabc123...`.
|
||||
| From Address | This is the address that initiated the transaction. It represents the sender who paid for the gas to execute the transaction. | If a user sends 1 ETH from their wallet, the `from_address` will be the sender's Ethereum address, such as `0xabc123...`.
|
||||
| To Address | This is the recipient of the transaction. It could be another wallet or a smart contract. | If sending ETH to a friend, their wallet address, like `0xdef456...`, would be the `to_address`. In the case of interacting with a DeFi platform, the address of the smart contract being called would be the `to_address`.
|
||||
| Hash | The unique identifier (hash) of the transaction. This hash can be used to look up and verify the transaction on the blockchain. | A transaction might have a hash like `0x5c7b6f...`. You can use this hash to check the transaction status on a block explorer like Etherscan.
|
||||
| Value | This is the amount of cryptocurrency (in wei) being transferred in the transaction. | If transferring 1 ETH, the `value` would be 1,000,000,000,000,000,000 wei (1 ETH = 10^18 wei).
|
||||
@@ -50,10 +52,10 @@ Transaction data equips developers with the tools they need to interact with blo
|
||||
| Max Fee Per Gas | This is the maximum amount of gas fees (in wei) the sender is willing to pay for each gas unit. It was introduced in EIP-1559 to provide a cap on gas costs. | If `max_fee_per_gas` is set to 200 gwei, the sender is ensuring that they will never pay more than this for each gas unit, even during periods of high network congestion.
|
||||
| Max Priority Fee Per Gas | This is the maximum additional fee the sender is willing to pay to incentivize miners to prioritize their transaction. Also introduced in EIP-1559. | A transaction might specify a `max_priority_fee_per_gas` of 2 gwei, which acts as a "tip" for miners to get their transaction included faster in a block.
|
||||
| Data | The `data` field contains additional information required by a transaction, such as the input parameters for interacting with a smart contract. | In a call to a DeFi contract's `swap` function, the `data` field will include the encoded function call and arguments (e.g., token amount, recipient address).
|
||||
| Nonce | The `nonce` is the number of transactions sent from the sender’s address. It ensures that transactions are processed in the correct order and prevents double-spending. | If the sender has sent 5 transactions before, the nonce will be 5. This helps in tracking the sequence of transactions and ensuring that each one is processed correctly. | If the `nonce` is 12, it means this is the 13th transaction (starting from 0) sent from the `from_address`
|
||||
| Nonce | The `nonce` is the number of transactions sent from the sender's address. It ensures that transactions are processed in the correct order and prevents double-spending. | If the sender has sent 5 transactions before, the nonce will be 5. This helps in tracking the sequence of transactions and ensuring that each one is processed correctly. | If the `nonce` is 12, it means this is the 13th transaction (starting from 0) sent from the `from_address`
|
||||
| Transaction Index | The position of the transaction within the block. It indicates the order in which the transaction was included relative to other transactions | If the `transaction_index` is 5, this was the 6th transaction included in the block.
|
||||
| Transaction Type | The type of transaction, typically either legacy (type `0x0` ) or one of the newer types introduced in EIP-1559 (e.g., type `0x2` for transactions that use the new gas fee mechanism). | A `transaction_type` of `0x2` indicates the transaction follows the new EIP-1559 rules for gas fees.
|
||||
| Access List | The `access_list` is an optional field introduced in EIP-2930 that specifies which addresses and storage slots the transaction intends to interact with. It’s used to optimize gas costs by pre-declaring the data access needed. | When a transaction interacts with a smart contract, the `access_list` may declare the contract address and storage slots the transaction intends to read from or write to. For example, interacting with a DeFi smart contract could include its contract address and the storage slots holding user balances.
|
||||
| Access List | The `access_list` is an optional field introduced in EIP-2930 that specifies which addresses and storage slots the transaction intends to interact with. It's used to optimize gas costs by pre-declaring the data access needed. | When a transaction interacts with a smart contract, the `access_list` may declare the contract address and storage slots the transaction intends to read from or write to. For example, interacting with a DeFi smart contract could include its contract address and the storage slots holding user balances.
|
||||
| Chain ID | The `chain_id` identifies the blockchain network on which the transaction was conducted. It prevents replay attacks across different networks. | On Ethereum mainnet, the chain ID is `1`, while on Base Mainnet, the chain ID is `8453` . This field tells you which network the transaction belongs to.
|
||||
| Block Hash | This is the unique identifier (or "fingerprint") of the block that includes this transaction. The hash ensures that the block hasn't been altered. | If the transaction is included in block `12,345,678`, the block's hash might look like `0xabc123...`, and this hash can be used to reference that particular block on the blockchain.
|
||||
| Block Number | The `block_number` indicates the specific position of the block in the blockchain that contains the transaction. | If a transaction is included in block `12,345,678`, this number can be used to quickly locate the block and all the transactions it contains.
|
||||
@@ -67,7 +69,7 @@ Tokens on blockchain can be of different standards, but ones of the most widely
|
||||
- ERC-20 for fungible tokens
|
||||
- ERC-721 and ERC-1155 for NFTs
|
||||
|
||||
This blueprint provides access to such tokens’ balances information for a given owner address.
|
||||
This blueprint provides access to such tokens' balances information for a given owner address.
|
||||
|
||||
### Blueprint details
|
||||
|
||||
|
||||
@@ -138,3 +138,60 @@ Once you execute the query above you will receive a result similar to this:
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Multichain Queries
|
||||
|
||||
Insight also supports querying multiple chains in a single API call. This is useful when you need to retrieve data across different networks without making separate requests.
|
||||
|
||||
```typescript
|
||||
const getMultichainTransfers = async () => {
|
||||
try {
|
||||
// Query USDT transfers on Ethereum (1) and Polygon (137)
|
||||
const response = await fetch('https://insight.thirdweb.com/v1/events?chain=1&chain=137&limit=10', {
|
||||
headers: {
|
||||
'x-client-id': <YOUR_THIRDWEB_CLIENT_ID>
|
||||
}
|
||||
});
|
||||
const transfersInfo = await response.json();
|
||||
return transfersInfo
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
When querying multiple chains, the response includes a `chain_ids` array in the metadata and each item in the data array includes its `chain_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"chain_ids": [1, 137],
|
||||
"total_items": 10,
|
||||
"limit_per_chain": 5,
|
||||
"page": 0,
|
||||
"limit": 10,
|
||||
"total_pages": 1
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"chain_id": 1,
|
||||
"block_number": 14705663,
|
||||
// ... other Ethereum event data
|
||||
},
|
||||
{
|
||||
"chain_id": 137,
|
||||
"block_number": 25631042,
|
||||
// ... other Polygon event data
|
||||
}
|
||||
// ... more events from both chains
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key points about multichain queries:
|
||||
- Use the base URL `https://insight.thirdweb.com` instead of the chain-specific subdomain
|
||||
- Add multiple `chain` query parameters (e.g., `chain=1&chain=137`)
|
||||
- The response includes a `chain_ids` array and `limit_per_chain` in the metadata
|
||||
- Each item in the data array includes its `chain_id` for identification
|
||||
|
||||
For more detailed information and advanced use cases, check out our [Multichain Queries](/insight/multichain-queries) documentation.
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { createMetadata } from "@doc";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "Multichain Queries | thirdweb Insight",
|
||||
description:
|
||||
"Learn how to query multiple blockchains simultaneously with thirdweb Insight",
|
||||
image: {
|
||||
title: "Insight",
|
||||
icon: "insight",
|
||||
},
|
||||
});
|
||||
|
||||
# Multichain Queries
|
||||
|
||||
Insight now supports querying multiple blockchain networks in a single API call. This powerful feature allows you to retrieve and analyze data across different chains without making separate requests for each network.
|
||||
|
||||
## Why Use Multichain Queries?
|
||||
|
||||
- **Efficiency**: Reduce the number of API calls needed to fetch data from multiple chains
|
||||
- **Simplicity**: Consolidate cross-chain data in a single request and response
|
||||
- **Performance**: Minimize latency by avoiding sequential requests to different chains
|
||||
- **Consistency**: Process data from different networks using a unified format
|
||||
|
||||
## How to Use Multichain Queries
|
||||
|
||||
Instead of using the chain-specific subdomain approach, multichain queries use the base URL with multiple `chain` query parameters:
|
||||
|
||||
```typescript
|
||||
// Traditional single-chain query (using subdomain)
|
||||
const singleChainUrl = `https://1.insight.thirdweb.com/v1/events`;
|
||||
|
||||
// Multichain query (using base URL with chain parameters)
|
||||
const multiChainUrl = `https://insight.thirdweb.com/v1/events?chain=1&chain=137`;
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Authentication works the same way as with single-chain queries:
|
||||
|
||||
```typescript
|
||||
// Using client ID in header
|
||||
const headers = {
|
||||
"x-client-id": "{{clientId}}"
|
||||
};
|
||||
|
||||
// Or as a query parameter
|
||||
const url = `https://insight.thirdweb.com/v1/events?chain=1&chain=137&clientId={{clientId}}`;
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
Responses for multichain queries include additional metadata to help you identify which data belongs to which chain:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"chain_ids": [1, 137], // List of queried chain IDs
|
||||
"total_items": 113, // Total count across all chains
|
||||
"limit_per_chain": 100, // Per-chain limit derived from the request
|
||||
"page": 0,
|
||||
"limit": 200,
|
||||
"total_pages": 1
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"chain_id": 1, // Each item includes its chain ID
|
||||
"block_number": "17859301",
|
||||
"transaction_hash": "0x123...",
|
||||
// ... other fields
|
||||
},
|
||||
{
|
||||
"chain_id": 137,
|
||||
"block_number": "48392021",
|
||||
// ... other fields
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key differences in the response format:
|
||||
- The `meta` object includes a `chain_ids` array listing all queried chains
|
||||
- A `limit_per_chain` field indicates how many items were requested per chain
|
||||
- Each item in the `data` array includes a `chain_id` field to identify its network
|
||||
|
||||
## Pagination and Limits
|
||||
|
||||
When using multichain queries, the `limit` parameter applies to each chain individually:
|
||||
|
||||
```typescript
|
||||
// This will return up to 20 items from each chain (potentially 40 total items)
|
||||
const url = `https://insight.thirdweb.com/v1/events?chain=1&chain=137&limit=20`;
|
||||
```
|
||||
|
||||
The `limit_per_chain` in the response metadata shows how many items were requested per chain, while the overall `limit` represents the maximum total items across all chains.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Query Events Across Multiple Chains
|
||||
|
||||
```typescript
|
||||
const getMultichainEvents = async () => {
|
||||
try {
|
||||
// Query events on Ethereum (1) and Polygon (137)
|
||||
const response = await fetch(
|
||||
'https://insight.thirdweb.com/v1/events?chain=1&chain=137&limit=10',
|
||||
{
|
||||
headers: {
|
||||
'x-client-id': '<YOUR_THIRDWEB_CLIENT_ID>'
|
||||
}
|
||||
}
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Example 2: Track Token Balances Across Networks
|
||||
|
||||
```typescript
|
||||
const getMultichainTokenBalances = async (ownerAddress) => {
|
||||
try {
|
||||
// Get ERC-20 balances on Ethereum, Polygon, and Arbitrum
|
||||
const response = await fetch(
|
||||
`https://insight.thirdweb.com/v1/tokens/erc20/${ownerAddress}?chain=1&chain=137&chain=42161`,
|
||||
{
|
||||
headers: {
|
||||
'x-client-id': '<YOUR_THIRDWEB_CLIENT_ID>'
|
||||
}
|
||||
}
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Example 3: Monitor NFT Collections Across Chains
|
||||
|
||||
```typescript
|
||||
const getMultichainNFTs = async (ownerAddress) => {
|
||||
try {
|
||||
// Get NFTs on Ethereum and Base
|
||||
const response = await fetch(
|
||||
`https://insight.thirdweb.com/v1/tokens/erc721/${ownerAddress}?chain=1&chain=8453`,
|
||||
{
|
||||
headers: {
|
||||
'x-client-id': '<YOUR_THIRDWEB_CLIENT_ID>'
|
||||
}
|
||||
}
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Supported Endpoints
|
||||
|
||||
All Insight blueprints support multichain queries:
|
||||
|
||||
- **Events Blueprint**: `/v1/events` - Returns events from specified chains
|
||||
- **Transactions Blueprint**: `/v1/transactions` - Provides transactions from multiple chains
|
||||
- **Tokens Blueprint**:
|
||||
- `/v1/tokens/erc20/:ownerAddress` - Consolidates ERC-20 token balances across chains
|
||||
- `/v1/tokens/erc721/:ownerAddress` - Consolidates NFT holdings across chains
|
||||
- `/v1/tokens/erc1155/:ownerAddress` - Consolidates ERC-1155 token balances across chains
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Limit Chain Count**: While you can query multiple chains, it's best to limit the number of chains in a single request to avoid timeouts.
|
||||
|
||||
2. **Use Appropriate Limits**: Set reasonable `limit` values to control response size and processing time.
|
||||
|
||||
3. **Handle Chain-Specific Errors**: Some chains might return errors while others succeed. Your code should handle partial successes.
|
||||
|
||||
4. **Process Data by Chain**: When analyzing the response, group or filter data by `chain_id` for chain-specific analysis.
|
||||
|
||||
5. **Consider Rate Limits**: Multichain queries count against your rate limits for each chain queried.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Cross-Chain Portfolio Tracking
|
||||
|
||||
Track a user's assets across multiple networks to provide a comprehensive view of their holdings:
|
||||
|
||||
```typescript
|
||||
const getPortfolio = async (address) => {
|
||||
// Get ERC-20 tokens across major chains
|
||||
const erc20Response = await fetch(
|
||||
`https://insight.thirdweb.com/v1/tokens/erc20/${address}?chain=1&chain=137&chain=42161&chain=10&chain=8453`,
|
||||
{ headers: { 'x-client-id': '<YOUR_CLIENT_ID>' } }
|
||||
);
|
||||
|
||||
// Get NFTs across the same chains
|
||||
const nftResponse = await fetch(
|
||||
`https://insight.thirdweb.com/v1/tokens/erc721/${address}?chain=1&chain=137&chain=42161&chain=10&chain=8453`,
|
||||
{ headers: { 'x-client-id': '<YOUR_CLIENT_ID>' } }
|
||||
);
|
||||
|
||||
return {
|
||||
tokens: await erc20Response.json(),
|
||||
nfts: await nftResponse.json()
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Cross-Chain Activity Monitoring
|
||||
|
||||
Monitor transactions or events across multiple chains for a specific address:
|
||||
|
||||
```typescript
|
||||
const getRecentActivity = async (address) => {
|
||||
const response = await fetch(
|
||||
`https://insight.thirdweb.com/v1/transactions?filter_from_address=${address}&chain=1&chain=137&chain=42161&sort_by=block_timestamp&sort_order=desc&limit=10`,
|
||||
{ headers: { 'x-client-id': '<YOUR_CLIENT_ID>' } }
|
||||
);
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
```
|
||||
|
||||
### Cross-Chain Protocol Analysis
|
||||
|
||||
Analyze protocol activity across different deployments on multiple chains:
|
||||
|
||||
```typescript
|
||||
const getProtocolActivity = async (protocolAddresses) => {
|
||||
// protocolAddresses = { 1: "0x123...", 137: "0x456...", 42161: "0x789..." }
|
||||
|
||||
const queryParams = Object.entries(protocolAddresses)
|
||||
.map(([chainId, address]) => `chain=${chainId}&filter_address=${address}`)
|
||||
.join('&');
|
||||
|
||||
const response = await fetch(
|
||||
`https://insight.thirdweb.com/v1/events?${queryParams}&limit=50`,
|
||||
{ headers: { 'x-client-id': '<YOUR_CLIENT_ID>' } }
|
||||
);
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
```
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Brain,
|
||||
ExternalLink,
|
||||
MessageCircleQuestionIcon,
|
||||
Network,
|
||||
Rocket,
|
||||
StickyNote,
|
||||
Wrench,
|
||||
@@ -41,6 +42,11 @@ export const sidebar: SideBar = {
|
||||
href: `${insightSlug}/blueprints`,
|
||||
icon: <Album />,
|
||||
},
|
||||
{
|
||||
name: "Multichain Queries",
|
||||
href: `${insightSlug}/multichain-queries`,
|
||||
icon: <Network />,
|
||||
},
|
||||
{
|
||||
name: "Agents & LLMs",
|
||||
href: `${insightSlug}/agents-and-llms`,
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
import { Details } from "@doc";
|
||||
|
||||
# Nebula FAQs
|
||||
|
||||
### I am still waiting on access to Nebula. How can I be approved?
|
||||
|
||||
<Details summary="I am still waiting on access to Nebula. How can I be approved?">
|
||||
We are rolling out Nebula approval in batches daily - stay tuned!
|
||||
</Details>
|
||||
|
||||
### What underlying model does Nebula use?
|
||||
<Details summary="What underlying model does Nebula use?">
|
||||
During Alpha, we are primarily testing the t0 model while conducting limited trials of the upcoming t1 model.
|
||||
The t0 model utilizes a mixture-of-agents architecture. We are targeting an early Q2 launch for t1, and will
|
||||
provide additional details around launch.
|
||||
</Details>
|
||||
|
||||
During Alpha, we are primarily testing the t0 model while conducting limited trials of the upcoming t1 model. The t0 model utilizes a mixture-of-agents architecture. We are targeting an early Q2 launch for t1, and will provide additional details around launch.
|
||||
<Details summary="Does Nebula support ________ chain?">
|
||||
Nebula supports reading and writing capabilities on any EVM-compatible chain. [View the chainlist](https://thirdweb.com/chainlist) for all supported chains.
|
||||
</Details>
|
||||
|
||||
### Which contracts does Nebula support?
|
||||
Nebula supports the read and write context of any verified contract or any contract on the thirdweb dashboard. For deploying through Nebula, supported contracts include [Token - ERC20](https://thirdweb.com/thirdweb.eth/TokenERC20), [NFT Collection - ERC721](https://thirdweb.com/thirdweb.eth/TokenERC721), [Edition - ERC1155](https://thirdweb.com/thirdweb.eth/TokenERC1155), and [Split](https://thirdweb.com/thirdweb.eth/Split) contracts.
|
||||
<Details summary="Does Nebula support multiple languages?">
|
||||
Yes, you may ask Nebula questions in any spoken language.
|
||||
</Details>
|
||||
|
||||
<Details summary="How can I improve upon Nebula's responses?">
|
||||
We recommend using [context filters](/nebula/key-concepts/context-filters) to improve relevant responses. You may also modify hyperparameters such as temperature, top-p,
|
||||
and more using the OpenAI API. Learn more about our [OpenAI API integration](/nebula/plugins/openai).
|
||||
</Details>
|
||||
|
||||
<Details summary="Which contracts does Nebula support?">
|
||||
Nebula supports the read and write context of any verified contract or any contract on the thirdweb dashboard.
|
||||
For deploying through Nebula, supported contracts include [Token - ERC20](https://thirdweb.com/thirdweb.eth/TokenERC20),
|
||||
[NFT Collection - ERC721](https://thirdweb.com/thirdweb.eth/TokenERC721), [Edition - ERC1155](https://thirdweb.com/thirdweb.eth/TokenERC1155),
|
||||
and [Split](https://thirdweb.com/thirdweb.eth/Split) contracts.
|
||||
|
||||
If you have a published contract you would like to enable for deployment through Nebula, please [contact us](https://thirdweb.com/contact-us).
|
||||
</Details>
|
||||
|
||||
### Does Nebula have memory from past conversations?
|
||||
<Details summary="Does Nebula have memory from past conversations?">
|
||||
Nebula retains memory within the confines of a session. [Learn more about sessions](/nebula/key-concepts/sessions).
|
||||
</Details>
|
||||
|
||||
### What is the context size of Nebula?
|
||||
The context size or window is 128k tokens.
|
||||
|
||||
### Will Nebula be open source?
|
||||
<Details summary="What is the context size of Nebula?">
|
||||
The context size or window is 128k tokens or approximately 96,000 words which may vary depending on the specific language and text characteristics.
|
||||
</Details>
|
||||
|
||||
<Details summary="Will Nebula be open source?">
|
||||
Nebula is not currently open source. We are exploring open sourcing Nebula in the future.
|
||||
</Details>
|
||||
|
||||
### When will pricing be available for Nebula?
|
||||
|
||||
Pricing options will be available in beta. Nebula is still being tested in alpha.
|
||||
<Details summary="When will pricing be available for Nebula?">
|
||||
Pricing options will be available in beta. Nebula is still being tested in alpha.
|
||||
</Details>
|
||||
|
||||
@@ -28,7 +28,7 @@ Nebula is currently available in Alpha. [Join the waitlist.](https://thirdweb.co
|
||||
>
|
||||
<FeatureCard
|
||||
title="Proprietary Blockchain Model"
|
||||
description="Trained on over 2,500+ EVM networks and 1M+ contracts"
|
||||
description="Trained on every EVM network and 1M+ contracts"
|
||||
iconUrl={<File />}
|
||||
/>
|
||||
|
||||
|
||||
@@ -68,21 +68,7 @@ export const sidebar: SideBar = {
|
||||
icon: <Braces />,
|
||||
links: [
|
||||
{
|
||||
name: "GET",
|
||||
expanded: true,
|
||||
links: [
|
||||
{
|
||||
name: "List Sessions",
|
||||
href: "/nebula/api-reference/list-session",
|
||||
},
|
||||
{
|
||||
name: "Get Session",
|
||||
href: "/nebula/api-reference/get-session",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "POST",
|
||||
name: "Chat",
|
||||
expanded: true,
|
||||
links: [
|
||||
{
|
||||
@@ -93,30 +79,32 @@ export const sidebar: SideBar = {
|
||||
name: "Execute Action",
|
||||
href: "/nebula/api-reference/execute",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Session",
|
||||
expanded: true,
|
||||
links: [
|
||||
{
|
||||
name: "List Sessions",
|
||||
href: "/nebula/api-reference/list-session",
|
||||
},
|
||||
{
|
||||
name: "Get Session",
|
||||
href: "/nebula/api-reference/get-session",
|
||||
},
|
||||
{
|
||||
name: "Create Session",
|
||||
href: "/nebula/api-reference/create-session",
|
||||
},
|
||||
{
|
||||
name: "Clear Session",
|
||||
href: "/nebula/api-reference/clear-session",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "PUT",
|
||||
expanded: true,
|
||||
links: [
|
||||
{
|
||||
name: "Update Session",
|
||||
href: "/nebula/api-reference/update-session",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "DELETE",
|
||||
expanded: true,
|
||||
links: [
|
||||
{
|
||||
name: "Clear Session",
|
||||
href: "/nebula/api-reference/clear-session",
|
||||
},
|
||||
{
|
||||
name: "Delete Session",
|
||||
href: "/nebula/api-reference/delete-session",
|
||||
|
||||
@@ -6,7 +6,7 @@ import DocsHeroLight from "./_images/docs-hero-light.png";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="container max-w-[900px] grow pb-20">
|
||||
<main className="container max-w-[900px] grow pb-20" data-noindex>
|
||||
<Hero />
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<FrontendSection />
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function Layout(props: { children: React.ReactNode }) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-noindex>{props.children}</div>
|
||||
<div>{props.children}</div>
|
||||
</DocLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function Layout(props: { children: React.ReactNode }) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-noindex>{props.children}</div>
|
||||
<div>{props.children}</div>
|
||||
</DocLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function Layout(props: { children: React.ReactNode }) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-noindex>{props.children}</div>
|
||||
<div>{props.children}</div>
|
||||
</DocLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function ArticleCard(props: {
|
||||
const isExternal = props.href.startsWith("http");
|
||||
return (
|
||||
<Link
|
||||
data-noindex
|
||||
href={props.href}
|
||||
className="flex cursor-default bg-card"
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
@@ -38,6 +39,7 @@ export function ArticleIconCard(props: {
|
||||
const isExternal = props.href.startsWith("http");
|
||||
return (
|
||||
<Link
|
||||
data-noindex
|
||||
href={props.href}
|
||||
className={cn(
|
||||
"flex items-center gap-4 rounded-lg border bg-card p-4 transition-colors hover:border-active-border",
|
||||
|
||||
@@ -121,60 +121,58 @@ async function RenderCode(props: {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre>
|
||||
{tokens.map((line, i) => {
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
|
||||
<div key={i}>
|
||||
{line.map((token, i) => {
|
||||
const { lightColor, darkColor } = getThemeColors(token);
|
||||
{tokens.map((line, i) => {
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
|
||||
<div key={i}>
|
||||
{line.map((token, i) => {
|
||||
const { lightColor, darkColor } = getThemeColors(token);
|
||||
|
||||
const style = {
|
||||
"--code-light-color": lightColor,
|
||||
"--code-dark-color": darkColor,
|
||||
} as React.CSSProperties;
|
||||
const style = {
|
||||
"--code-light-color": lightColor,
|
||||
"--code-dark-color": darkColor,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const href = props.tokenLinks?.has(token.content)
|
||||
? props.tokenLinks.get(token.content)
|
||||
: undefined;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
|
||||
key={i}
|
||||
href={href || "#"}
|
||||
className="group/codelink relative py-0.5"
|
||||
style={style}
|
||||
>
|
||||
{/* Token */}
|
||||
<span className="relative z-codeToken transition-colors duration-200 group-hover/codelink:text-background">
|
||||
{token.content}
|
||||
</span>
|
||||
{/* Line */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 left-0 z-codeTokenHighlight inline-block h-[3px] translate-y-[2px] scale-105",
|
||||
"rounded-sm bg-current opacity-20",
|
||||
"transition-all duration-200 group-hover/codelink:h-full group-hover/codelink:translate-y-0 group-hover/codelink:opacity-100",
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
const href = props.tokenLinks?.has(token.content)
|
||||
? props.tokenLinks.get(token.content)
|
||||
: undefined;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
|
||||
<span key={i} style={style} data-token={token.content}>
|
||||
{token.content}
|
||||
</span>
|
||||
<Link
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
|
||||
key={i}
|
||||
href={href || "#"}
|
||||
className="group/codelink relative py-0.5"
|
||||
style={style}
|
||||
>
|
||||
{/* Token */}
|
||||
<span className="relative z-codeToken transition-colors duration-200 group-hover/codelink:text-background">
|
||||
{token.content}
|
||||
</span>
|
||||
{/* Line */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 left-0 z-codeTokenHighlight inline-block h-[3px] translate-y-[2px] scale-105",
|
||||
"rounded-sm bg-current opacity-20",
|
||||
"transition-all duration-200 group-hover/codelink:h-full group-hover/codelink:translate-y-0 group-hover/codelink:opacity-100",
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{line.length === 0 && i !== tokens.length - 1 && " "}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is the identity here
|
||||
<span key={i} style={style} data-token={token.content}>
|
||||
{token.content}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{line.length === 0 && i !== tokens.length - 1 && " "}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type DocLayoutProps = {
|
||||
showTableOfContents?: boolean;
|
||||
sidebarHeader?: React.ReactNode;
|
||||
noIndex?: boolean;
|
||||
noLLM?: boolean;
|
||||
};
|
||||
|
||||
export function DocLayout(props: DocLayoutProps) {
|
||||
@@ -43,6 +44,7 @@ export function DocLayout(props: DocLayoutProps) {
|
||||
<main
|
||||
className="relative flex w-full flex-col overflow-hidden"
|
||||
data-noindex={props.noIndex}
|
||||
data-no-llm={props.noLLM}
|
||||
>
|
||||
<div className="grow xl:mt-6">{props.children}</div>
|
||||
<div className="mt-16 xl:mt-20">
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"lucide-react": "0.476.0",
|
||||
"next": "15.1.7",
|
||||
"next": "15.2.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "15.1.7",
|
||||
"@next/eslint-plugin-next": "15.2.0",
|
||||
"@types/node": "22.13.5",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @thirdweb-dev/service-utils
|
||||
|
||||
## 0.8.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#6382](https://github.com/thirdweb-dev/js/pull/6382) [`47c9c43`](https://github.com/thirdweb-dev/js/commit/47c9c43a875a81aa357ebf3c741f256c317c5cfd) Thanks [@arcoraven](https://github.com/arcoraven)! - [service-utils] trim project ID prefix
|
||||
|
||||
- [#6387](https://github.com/thirdweb-dev/js/pull/6387) [`0c0d837`](https://github.com/thirdweb-dev/js/commit/0c0d837f2355c4aa43c3f1ceef52b450623be76f) Thanks [@arcoraven](https://github.com/arcoraven)! - [service-utils] Add connect promise so it only connects once
|
||||
|
||||
- [#6384](https://github.com/thirdweb-dev/js/pull/6384) [`25b149d`](https://github.com/thirdweb-dev/js/commit/25b149d3a98f99984792874ecb4e3144d2932a08) Thanks [@arcoraven](https://github.com/arcoraven)! - [service-utils] Provide CF req to forward origin header
|
||||
|
||||
## 0.8.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user