Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c2ac63a9 | ||
|
|
170f14fb54 | ||
|
|
da2a2a0e86 | ||
|
|
e62fd3fe40 | ||
|
|
99a7cce624 | ||
|
|
61152dd098 | ||
|
|
05519fcdde | ||
|
|
7257747520 | ||
|
|
e71e7d70a6 | ||
|
|
8d47864967 | ||
|
|
4a8adc143f | ||
|
|
db538473cf | ||
|
|
2057546934 | ||
|
|
ba52e68eab | ||
|
|
0eff64824b | ||
|
|
ae7407dca4 | ||
|
|
a443b309dc | ||
|
|
ae2ff743c0 | ||
|
|
dfa8603593 | ||
|
|
9f1ca54c26 | ||
|
|
7df81b7323 | ||
|
|
ab74fcdaf8 | ||
|
|
1191d5dad9 | ||
|
|
20b5ba943a | ||
|
|
f0f8da8fab | ||
|
|
da3fc99c0b | ||
|
|
fb4c190eb3 | ||
|
|
1c31a1e4df | ||
|
|
04c10f82fd | ||
|
|
ab173886ac | ||
|
|
df06bec699 | ||
|
|
6000dacfbb | ||
|
|
fc6535069b | ||
|
|
b15c3c0747 | ||
|
|
1985de89aa | ||
|
|
2b87b7bb75 | ||
|
|
d99a985ab4 | ||
|
|
e74574ccf5 | ||
|
|
98d05d6fb6 | ||
|
|
f4ac3c2a3a | ||
|
|
a3a4bf4361 | ||
|
|
e8ec54e6e2 | ||
|
|
58e343c10c | ||
|
|
bad9db9760 |
@@ -14,7 +14,7 @@ We use [Turborepo](https://turbo.build/repo/docs) to manage the repository, and
|
||||
|
||||
We use [pnpm](https://pnpm.io) for package management across the repo. `pnpm` is similar to `npm` or `yarn` but with more efficient disk space usage.
|
||||
|
||||
**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](./packages/thirdweb). You can still find the legacy packages at [/legacy_packages](./legacy_packages).**
|
||||
**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](../packages/thirdweb). You can still find the legacy packages at [/legacy_packages](../legacy_packages).**
|
||||
|
||||
This single package provides a performant & lightweight SDK to interact with any EVM chain across Node, React, and React Native. Learn more about how to use the thirdweb SDK in our [documentation](https://portal.thirdweb.com/typescript/v5).
|
||||
|
||||
|
||||
@@ -48,15 +48,15 @@ async function fetchAnalytics(
|
||||
);
|
||||
}
|
||||
// client id DEBUG OVERRIDE
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("projectId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("teamId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
// analyticsServiceUrl.searchParams.delete("projectId");
|
||||
// analyticsServiceUrl.searchParams.delete("teamId");
|
||||
// analyticsServiceUrl.searchParams.append(
|
||||
// "teamId",
|
||||
// "team_clmb33q9w00gn1x0u2ri8z0k0",
|
||||
// "team_cm0lde33r02344w129k5hm2xz",
|
||||
// );
|
||||
// ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
// analyticsServiceUrl.searchParams.append(
|
||||
// "projectId",
|
||||
// "prj_clyqwud5y00u1na7nzxnzlz7o",
|
||||
// "prj_cm4rqwx9b002qrnsnr37wqpo6",
|
||||
// );
|
||||
|
||||
return fetch(analyticsServiceUrl, {
|
||||
@@ -377,7 +377,7 @@ export async function getEcosystemWalletUsage(args: {
|
||||
|
||||
export async function getUniversalBridgeUsage(args: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
@@ -395,11 +395,10 @@ export async function getUniversalBridgeUsage(args: {
|
||||
console.error(
|
||||
`Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.data as UniversalBridgeStats[];
|
||||
}
|
||||
|
||||
@@ -430,6 +429,5 @@ export async function getUniversalBridgeWalletUsage(args: {
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.data as UniversalBridgeWalletStats[];
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function MultiNetworkSelector(props: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number.parseInt(searchValue))) {
|
||||
if (Number.isInteger(Number(searchValue))) {
|
||||
return String(chain.chainId).startsWith(searchValue);
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
@@ -181,7 +181,7 @@ export function SingleNetworkSelector(props: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number.parseInt(searchValue))) {
|
||||
if (Number.isInteger(Number(searchValue))) {
|
||||
return String(chain.chainId).startsWith(searchValue);
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
|
||||
@@ -137,7 +137,7 @@ function RenderSidebarGroup(props: {
|
||||
}
|
||||
|
||||
if ("separator" in link) {
|
||||
return <SidebarSeparator className="my-1" />;
|
||||
return <SidebarSeparator key={Math.random()} className="my-1" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,7 @@ export function TokenSelector(props: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number.parseInt(searchValue))) {
|
||||
if (Number.isInteger(Number(searchValue))) {
|
||||
return String(token.chainId).startsWith(searchValue);
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useLocalStorage } from "../../../hooks/useLocalStorage";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function DismissibleAlert(props: {
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
localStorageId: string;
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useLocalStorage(
|
||||
props.localStorageId,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-card p-4">
|
||||
<Button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
|
||||
aria-label="Close alert"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="mb-0.5 font-semibold">{props.title} </h2>
|
||||
<div className="text-muted-foreground text-sm">{props.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
isCurrency?: boolean;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ImageUploadProps {
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
className?: string;
|
||||
multiple?: boolean;
|
||||
accept: string;
|
||||
}
|
||||
|
||||
export function ImageUploadButton(props: ImageUploadProps) {
|
||||
@@ -34,7 +35,7 @@ export function ImageUploadButton(props: ImageUploadProps) {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={props.multiple}
|
||||
accept="image/*"
|
||||
accept={props.accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
aria-label="Upload image"
|
||||
|
||||
@@ -54,11 +54,11 @@ export function ToolTipLabel(props: {
|
||||
align={props.align}
|
||||
sideOffset={10}
|
||||
className={cn(
|
||||
"max-w-[400px] whitespace-normal leading-relaxed",
|
||||
"max-w-[400px] whitespace-normal p-0 leading-relaxed",
|
||||
props.contentClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 p-2 text-sm">
|
||||
<div className="flex items-center gap-1.5 p-4 text-sm">
|
||||
{props.leftIcon}
|
||||
{props.label}
|
||||
{props.rightIcon}
|
||||
|
||||
@@ -39,9 +39,10 @@ export function getFiltersFromSearchParams(params: {
|
||||
};
|
||||
|
||||
const defaultInterval =
|
||||
differenceInCalendarDays(range.to, range.from) > 30
|
||||
? "week"
|
||||
: ("day" as const);
|
||||
params.interval ??
|
||||
(differenceInCalendarDays(range.to, range.from) > 30
|
||||
? ("week" as const)
|
||||
: ("day" as const));
|
||||
|
||||
return {
|
||||
range,
|
||||
@@ -50,6 +51,6 @@ export function getFiltersFromSearchParams(params: {
|
||||
? ("day" as const)
|
||||
: params.interval === "week"
|
||||
? ("week" as const)
|
||||
: defaultInterval,
|
||||
: (defaultInterval as "day" | "week"),
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export function ConfigureCustomChain(props: {
|
||||
chainSlug: string;
|
||||
}) {
|
||||
const { chainSlug } = props;
|
||||
const isSlugNumber = Number.isInteger(Number.parseInt(chainSlug));
|
||||
const isSlugNumber = Number.isInteger(Number(chainSlug));
|
||||
const [isConfigured, setIsConfigured] = useState(false);
|
||||
|
||||
if (isConfigured) {
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export function SingleNetworkSelector(props: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number.parseInt(searchValue))) {
|
||||
if (Number.isInteger(Number(searchValue))) {
|
||||
return String(chain.chainId).startsWith(searchValue);
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -41,10 +41,14 @@ import creatorCTA from "./temp-assets/creatorCTA.png";
|
||||
import lumiaBanner from "./temp-assets/lumiaBanner.png";
|
||||
import somniaBanner from "./temp-assets/somniaBanner.png";
|
||||
|
||||
import NeoXBanner from "./temp-assets/NeoXBanner.jpg";
|
||||
import NeoXCTA from "./temp-assets/NeoXCTA.jpg";
|
||||
import flowBanner from "./temp-assets/flowBanner.png";
|
||||
import flowCTA from "./temp-assets/flowCTA.png";
|
||||
import mantleBanner from "./temp-assets/mantle.png";
|
||||
import metalBanner from "./temp-assets/metalBanner.png";
|
||||
import onyxBanner from "./temp-assets/onyxBanner.png";
|
||||
import onyxCTA from "./temp-assets/onyxCTA.png";
|
||||
import plumeBanner from "./temp-assets/plumeBanner.png";
|
||||
|
||||
import plumeCTA from "./temp-assets/plumeCTA.png";
|
||||
@@ -73,6 +77,7 @@ import worldChainCTA from "./temp-assets/worldChainCTA.jpg";
|
||||
import xaiBanner from "./temp-assets/xai-banner.jpg";
|
||||
import zetachainBanner from "./temp-assets/zetachainBanner.png";
|
||||
import zetachainCTA from "./temp-assets/zetachainCTA.png";
|
||||
import zkCandyBanner from "./temp-assets/zkCandyBanner.jpg";
|
||||
import zytronBanner from "./temp-assets/zytronBanner.png";
|
||||
import zytronCTA from "./temp-assets/zytronCTA.jpg";
|
||||
// END TEMPORARY
|
||||
@@ -542,6 +547,30 @@ const chainMetaRecord = {
|
||||
about:
|
||||
"Metal L2 is a banking-focused Layer 2 blockchain built on the Optimism Superchain, designed to enable direct on-chain fiat deposits through its connection to The Digital Banking Network—an open-source blockchain banking protocol developed by Metallicus",
|
||||
},
|
||||
//NEO X
|
||||
47763: {
|
||||
headerImgUrl: NeoXBanner.src,
|
||||
about:
|
||||
"Neo X is an EVM-compatible sidechain built by Neo, incorporating Neo's distinctive dBFT consensus mechanism. Serving as a bridge between Neo N3 and the widely used EVM network, Neo X will play a crucial role in expanding the Neo ecosystem and offering developers more opportunities for innovation.",
|
||||
cta: {
|
||||
backgroundImageUrl: NeoXCTA.src,
|
||||
title: "Learn more about Neo X",
|
||||
buttonLink: "https://x.neo.org/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//NEO X Testnet
|
||||
12227332: {
|
||||
headerImgUrl: NeoXBanner.src,
|
||||
about:
|
||||
"Neo X is an EVM-compatible sidechain built by Neo, incorporating Neo's distinctive dBFT consensus mechanism. Serving as a bridge between Neo N3 and the widely used EVM network, Neo X will play a crucial role in expanding the Neo ecosystem and offering developers more opportunities for innovation.",
|
||||
cta: {
|
||||
backgroundImageUrl: NeoXCTA.src,
|
||||
title: "Learn more about Neo X",
|
||||
buttonLink: "https://x.neo.org/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//0G-Newton-Testnet
|
||||
16600: {
|
||||
headerImgUrl: zeroGBanner.src,
|
||||
@@ -554,6 +583,18 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
},
|
||||
//Onyx Mainnet
|
||||
80888: {
|
||||
headerImgUrl: onyxBanner.src,
|
||||
about:
|
||||
"Onyx is a layer 3 blockchain secured by Ethereum and Base providing developers and institutions a secure financial-purposed decentralized blockchain for banking and securities based applications. Powered by XCN.",
|
||||
cta: {
|
||||
backgroundImageUrl: onyxCTA.src,
|
||||
title: "Unlock the potential of your application with Onyx Connect",
|
||||
buttonLink: "https://onyx.org/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//Plume Testnet
|
||||
98864: {
|
||||
headerImgUrl: plumeBanner.src,
|
||||
@@ -718,7 +759,7 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//zkCandy
|
||||
//zkCandy Testnet
|
||||
302: {
|
||||
headerImgUrl: thirdwebBanner.src,
|
||||
about:
|
||||
@@ -730,6 +771,19 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
},
|
||||
|
||||
//zkCandy Mainnet
|
||||
320: {
|
||||
headerImgUrl: zkCandyBanner.src,
|
||||
about:
|
||||
"ZKcandy is an AI-powered Layer-2 aiming to redefine what’s possible in gaming and entertainment. As a ZK Chain built on ZKsync’s Elastic Network, it focuses on building immersive gaming ecosystems where players make the most of today’s AI capabilities. Emerged from a groundbreaking collaboration between ZKsync and the award-winning game studio iCandy Interactive, It's the first L2 ZK chain for gaming in the ZKsync ecosystem.",
|
||||
cta: {
|
||||
backgroundImageUrl: zkCandyBanner.src,
|
||||
title: " ",
|
||||
buttonLink: "https://litepaper.zkcandy.io/dev-guide/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//zkSync
|
||||
324: {
|
||||
headerImgUrl: thirdwebBanner.src,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useCallback, useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import {
|
||||
getChangelogNotifications,
|
||||
getInboxNotifications,
|
||||
markNotificationAsRead,
|
||||
} from "../../team/components/NotificationButton/fetch-notifications";
|
||||
@@ -59,7 +58,6 @@ export function AccountHeader(props: {
|
||||
account: props.account,
|
||||
client,
|
||||
accountAddress: props.accountAddress,
|
||||
getChangelogNotifications: getChangelogNotifications,
|
||||
getInboxNotifications: getInboxNotifications,
|
||||
markNotificationAsRead: markNotificationAsRead,
|
||||
};
|
||||
|
||||
@@ -64,7 +64,6 @@ function Variants(props: {
|
||||
email: "[email protected]",
|
||||
}}
|
||||
client={storybookThirdwebClient}
|
||||
getChangelogNotifications={() => Promise.resolve([])}
|
||||
getInboxNotifications={() => Promise.resolve([])}
|
||||
markNotificationAsRead={() => Promise.resolve()}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,6 @@ export type AccountHeaderCompProps = {
|
||||
account: Pick<Account, "email" | "id" | "image">;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
};
|
||||
@@ -78,7 +77,6 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
connectButton={props.connectButton}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
@@ -126,7 +124,6 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,6 @@ export function SecondaryNav(props: {
|
||||
connectButton: React.ReactNode;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogs: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
@@ -24,7 +23,6 @@ export function SecondaryNav(props: {
|
||||
<SecondaryNavLinks />
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogs}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
|
||||
@@ -8,8 +8,8 @@ const teamPlanToBadgeVariant: Record<
|
||||
> = {
|
||||
// gray
|
||||
free: "secondary",
|
||||
starter: "secondary",
|
||||
// yellow
|
||||
starter: "warning",
|
||||
starter_legacy: "warning",
|
||||
growth_legacy: "warning",
|
||||
// green
|
||||
@@ -20,7 +20,7 @@ const teamPlanToBadgeVariant: Record<
|
||||
pro: "default",
|
||||
};
|
||||
|
||||
function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
|
||||
export function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
|
||||
if (plan === "growth_legacy") {
|
||||
return "Growth - Legacy";
|
||||
}
|
||||
@@ -33,13 +33,14 @@ function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
|
||||
export function TeamPlanBadge(props: {
|
||||
plan: Team["billingPlan"];
|
||||
className?: string;
|
||||
postfix?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant={teamPlanToBadgeVariant[props.plan]}
|
||||
className={cn("px-1.5 capitalize", props.className)}
|
||||
>
|
||||
{getTeamPlanBadgeLabel(props.plan)}
|
||||
{`${getTeamPlanBadgeLabel(props.plan)}${props.postfix || ""}`}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { formatDistance } from "date-fns";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { unstable_cache } from "next/cache";
|
||||
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-2" 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="flex items-center gap-2 pl-5 text-foreground text-sm hover:underline"
|
||||
>
|
||||
View More <ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getChangelog = unstable_cache(
|
||||
async () => {
|
||||
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=10",
|
||||
);
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.posts as ChangelogItem[];
|
||||
},
|
||||
["changelog"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getWalletConnections } from "@/api/analytics";
|
||||
import { type Project, getProjects } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { subDays } from "date-fns";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { Changelog } from "./_components/Changelog";
|
||||
import { InviteTeamMembersButton } from "./_components/invite-team-members-button";
|
||||
import {
|
||||
type ProjectWithAnalytics,
|
||||
@@ -52,20 +52,30 @@ export default async function Page(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container flex grow flex-col gap-6 pt-8 pb-20">
|
||||
<Alert variant={"info"}>
|
||||
<CircleAlertIcon className="h-4 w-4" />
|
||||
<AlertTitle>Looking for Engines?</AlertTitle>
|
||||
<AlertDescription>
|
||||
Engines, contracts, project settings, and more are now managed
|
||||
within projects. Open or create a project to access them.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<TeamProjectsPage
|
||||
projects={projectsWithTotalWallets}
|
||||
team={team}
|
||||
client={client}
|
||||
/>
|
||||
<div className="container flex grow flex-col gap-4 lg:flex-row">
|
||||
{/* left */}
|
||||
<div className="flex grow flex-col gap-6 pt-8 lg:pb-20">
|
||||
<DismissibleAlert
|
||||
title="Looking for Engines?"
|
||||
description="Engines, contracts, project settings, and more are now managed within projects. Open or create a project to access them."
|
||||
localStorageId={`${team.id}-engines-alert`}
|
||||
/>
|
||||
|
||||
<TeamProjectsPage
|
||||
projects={projectsWithTotalWallets}
|
||||
team={team}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* right */}
|
||||
<div className="w-full px-4 py-8 lg:w-[300px]">
|
||||
<h2 className="mb-3 font-semibold text-2xl tracking-tight">
|
||||
Changelog
|
||||
</h2>
|
||||
|
||||
<Changelog />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getClientTransactions,
|
||||
getInAppWalletUsage,
|
||||
getUniversalBridgeUsage,
|
||||
getUserOpUsage,
|
||||
getWalletConnections,
|
||||
getWalletUsers,
|
||||
@@ -14,6 +15,7 @@ import { redirect } from "next/navigation";
|
||||
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UniversalBridgeStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
} from "types/analytics";
|
||||
@@ -24,7 +26,10 @@ import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
|
||||
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import {
|
||||
EmptyStateCard,
|
||||
EmptyStateContent,
|
||||
} from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import { Suspense } from "react";
|
||||
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
|
||||
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
|
||||
@@ -100,6 +105,7 @@ async function OverviewPageContent(props: {
|
||||
userOpUsage,
|
||||
clientTransactionsTimeSeries,
|
||||
clientTransactions,
|
||||
universalBridgeUsage,
|
||||
] = await Promise.all([
|
||||
// Aggregated wallet connections
|
||||
getWalletConnections({
|
||||
@@ -148,6 +154,13 @@ async function OverviewPageContent(props: {
|
||||
to: range.to,
|
||||
period: "all",
|
||||
}),
|
||||
// Universal Bridge
|
||||
getUniversalBridgeUsage({
|
||||
teamId: teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isEmpty =
|
||||
@@ -164,8 +177,9 @@ async function OverviewPageContent(props: {
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
|
||||
<div className="">
|
||||
<UsersChartCard
|
||||
<AppHighlightsCard
|
||||
userStats={walletUserStatsTimeSeries}
|
||||
volumeStats={universalBridgeUsage}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
@@ -218,70 +232,96 @@ async function OverviewPageContent(props: {
|
||||
);
|
||||
}
|
||||
|
||||
type UserMetrics = {
|
||||
totalUsers: number;
|
||||
type AggregatedMetrics = {
|
||||
activeUsers: number;
|
||||
newUsers: number;
|
||||
returningUsers: number;
|
||||
totalVolume: number;
|
||||
feesCollected: number;
|
||||
};
|
||||
|
||||
type TimeSeriesMetrics = UserMetrics & {
|
||||
type TimeSeriesMetrics = AggregatedMetrics & {
|
||||
date: string;
|
||||
};
|
||||
|
||||
function processTimeSeriesData(
|
||||
userStats: WalletUserStats[],
|
||||
volumeStats: UniversalBridgeStats[],
|
||||
): TimeSeriesMetrics[] {
|
||||
const metrics: TimeSeriesMetrics[] = [];
|
||||
|
||||
let cumulativeUsers = 0;
|
||||
for (const stat of userStats) {
|
||||
cumulativeUsers += stat.newUsers ?? 0;
|
||||
const volume = volumeStats
|
||||
.filter((v) => v.date === stat.date && v.status === "completed")
|
||||
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
|
||||
|
||||
const fees = volumeStats
|
||||
.filter((v) => v.date === stat.date && v.status === "completed")
|
||||
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
|
||||
|
||||
metrics.push({
|
||||
date: stat.date,
|
||||
activeUsers: stat.totalUsers ?? 0,
|
||||
returningUsers: stat.returningUsers ?? 0,
|
||||
newUsers: stat.newUsers ?? 0,
|
||||
totalUsers: cumulativeUsers,
|
||||
totalVolume: volume,
|
||||
feesCollected: fees,
|
||||
});
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function UsersChartCard({
|
||||
function AppHighlightsCard({
|
||||
userStats,
|
||||
volumeStats,
|
||||
searchParams,
|
||||
}: {
|
||||
userStats: WalletUserStats[];
|
||||
volumeStats: UniversalBridgeStats[];
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const timeSeriesData = processTimeSeriesData(userStats);
|
||||
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
|
||||
|
||||
const chartConfig = {
|
||||
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
|
||||
totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" },
|
||||
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
|
||||
returningUsers: {
|
||||
label: "Returning Users",
|
||||
color: "hsl(var(--chart-4))",
|
||||
totalVolume: {
|
||||
label: "Total Volume",
|
||||
color: "hsl(var(--chart-2))",
|
||||
isCurrency: true,
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
metric="Payments"
|
||||
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
|
||||
link="https://portal.thirdweb.com/connect/pay/overview"
|
||||
/>
|
||||
),
|
||||
},
|
||||
feesCollected: {
|
||||
label: "Fee Revenue",
|
||||
color: "hsl(var(--chart-4))",
|
||||
isCurrency: true,
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
metric="Fees"
|
||||
description="Your apps haven't collected any fees yet."
|
||||
link={"https://portal.thirdweb.com/connect/pay/fees"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
|
||||
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
title="Users"
|
||||
title="App Highlights"
|
||||
chartConfig={chartConfig}
|
||||
activeChart={
|
||||
(searchParams?.usersChart as keyof UserMetrics) ?? "activeUsers"
|
||||
(searchParams?.appHighlights as keyof AggregatedMetrics) ??
|
||||
"totalVolume"
|
||||
}
|
||||
data={timeSeriesData}
|
||||
aggregateFn={(_data, key) =>
|
||||
// If there is only one data point, use that one, otherwise use the previous
|
||||
timeSeriesData.filter((d) => (d[key] as number) > 0).length >= 2
|
||||
? timeSeriesData[timeSeriesData.length - 2]?.[key]
|
||||
: timeSeriesData[timeSeriesData.length - 1]?.[key]
|
||||
timeSeriesData.reduce((acc, curr) => acc + curr[key], 0)
|
||||
}
|
||||
// Get the trend from the last two COMPLETE periods
|
||||
trendFn={(data, key) =>
|
||||
@@ -291,7 +331,7 @@ function UsersChartCard({
|
||||
1
|
||||
: undefined
|
||||
}
|
||||
queryKey="usersChart"
|
||||
queryKey="appHighlights"
|
||||
existingQueryParams={searchParams}
|
||||
/>
|
||||
);
|
||||
|
||||
+5
-5
@@ -26,7 +26,7 @@ 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 { ArrowDownNarrowWideIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -251,10 +251,10 @@ function SearchInput(props: {
|
||||
return (
|
||||
<div className="relative grow">
|
||||
<Input
|
||||
placeholder="Search Projects by name or Client ID"
|
||||
placeholder="Project name or Client ID"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onValueChange(e.target.value)}
|
||||
className="bg-background pl-9 lg:w-[400px]"
|
||||
className="bg-background pl-9 lg:w-[320px]"
|
||||
/>
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -298,8 +298,8 @@ function SelectBy(props: {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-[200px] bg-background capitalize">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Sort by</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowDownNarrowWideIcon className="size-4 text-muted-foreground" />
|
||||
{valueToLabel[props.value]}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
||||
+4
@@ -7,9 +7,12 @@ import { PlanInfoCardUI } from "./PlanInfoCard";
|
||||
export function PlanInfoCardClient(props: {
|
||||
subscriptions: TeamSubscription[];
|
||||
team: Team;
|
||||
openPlanSheetButtonByDefault: boolean;
|
||||
highlightPlan: Team["billingPlan"] | undefined;
|
||||
}) {
|
||||
return (
|
||||
<PlanInfoCardUI
|
||||
openPlanSheetButtonByDefault={props.openPlanSheetButtonByDefault}
|
||||
team={props.team}
|
||||
subscriptions={props.subscriptions}
|
||||
getTeam={async () => {
|
||||
@@ -26,6 +29,7 @@ export function PlanInfoCardClient(props: {
|
||||
|
||||
return res.data.result;
|
||||
}}
|
||||
highlightPlan={props.highlightPlan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+10
@@ -118,6 +118,8 @@ function Story(props: {
|
||||
team={team}
|
||||
subscriptions={zeroUsageOnDemandSubs}
|
||||
getTeam={teamTeamStub}
|
||||
highlightPlan={undefined}
|
||||
openPlanSheetButtonByDefault={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -129,6 +131,8 @@ function Story(props: {
|
||||
}}
|
||||
subscriptions={zeroUsageOnDemandSubs}
|
||||
getTeam={teamTeamStub}
|
||||
highlightPlan={undefined}
|
||||
openPlanSheetButtonByDefault={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -137,6 +141,8 @@ function Story(props: {
|
||||
team={team}
|
||||
subscriptions={trialPlanZeroUsageOnDemandSubs}
|
||||
getTeam={teamTeamStub}
|
||||
highlightPlan={undefined}
|
||||
openPlanSheetButtonByDefault={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -145,6 +151,8 @@ function Story(props: {
|
||||
team={team}
|
||||
subscriptions={subsWith1Usage}
|
||||
getTeam={teamTeamStub}
|
||||
highlightPlan={undefined}
|
||||
openPlanSheetButtonByDefault={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -153,6 +161,8 @@ function Story(props: {
|
||||
team={team}
|
||||
subscriptions={subsWith4Usage}
|
||||
getTeam={teamTeamStub}
|
||||
highlightPlan={undefined}
|
||||
openPlanSheetButtonByDefault={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
+9
-2
@@ -28,11 +28,15 @@ export function PlanInfoCardUI(props: {
|
||||
subscriptions: TeamSubscription[];
|
||||
team: Team;
|
||||
getTeam: () => Promise<Team>;
|
||||
openPlanSheetButtonByDefault: boolean;
|
||||
highlightPlan: Team["billingPlan"] | undefined;
|
||||
}) {
|
||||
const { subscriptions, team } = props;
|
||||
const { subscriptions, team, openPlanSheetButtonByDefault } = props;
|
||||
const validPlan = getValidTeamPlan(team);
|
||||
const isActualFreePlan = team.billingPlan === "free";
|
||||
const [isPlanSheetOpen, setIsPlanSheetOpen] = useState(false);
|
||||
const [isPlanSheetOpen, setIsPlanSheetOpen] = useState(
|
||||
openPlanSheetButtonByDefault,
|
||||
);
|
||||
|
||||
const planSub = subscriptions.find(
|
||||
(subscription) => subscription.type === "PLAN",
|
||||
@@ -54,6 +58,7 @@ export function PlanInfoCardUI(props: {
|
||||
isOpen={isPlanSheetOpen}
|
||||
onOpenChange={setIsPlanSheetOpen}
|
||||
getTeam={props.getTeam}
|
||||
highlightPlan={props.highlightPlan}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
|
||||
@@ -298,6 +303,7 @@ function ViewPlansSheet(props: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
getTeam: () => Promise<Team>;
|
||||
highlightPlan: Team["billingPlan"] | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={props.isOpen} onOpenChange={props.onOpenChange}>
|
||||
@@ -309,6 +315,7 @@ function ViewPlansSheet(props: {
|
||||
team={props.team}
|
||||
trialPeriodEndedAt={props.trialPeriodEndedAt}
|
||||
getTeam={props.getTeam}
|
||||
highlightPlan={props.highlightPlan}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { type Team, getTeamBySlug } from "@/api/team";
|
||||
import { getTeamSubscriptions } from "@/api/team-subscription";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { Billing } from "components/settings/Account/Billing";
|
||||
@@ -10,8 +10,13 @@ export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
showPlans?: string | string[];
|
||||
highlight?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const pagePath = `/team/${params.team_slug}/settings/billing`;
|
||||
|
||||
const [account, team, authToken] = await Promise.all([
|
||||
@@ -41,6 +46,12 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<Billing
|
||||
highlightPlan={
|
||||
typeof searchParams.highlight === "string"
|
||||
? (searchParams.highlight as Team["billingPlan"])
|
||||
: undefined
|
||||
}
|
||||
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
|
||||
team={team}
|
||||
subscriptions={subscriptions}
|
||||
twAccount={account}
|
||||
|
||||
+5
-1
@@ -50,9 +50,13 @@ export function ProjectSidebarLayout(props: {
|
||||
tracking: tracking("account-abstraction"),
|
||||
},
|
||||
{
|
||||
label: "Universal Bridge",
|
||||
href: `${layoutPath}/connect/universal-bridge`,
|
||||
icon: PayIcon,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Universal Bridge <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
tracking: tracking("universal-bridge"),
|
||||
},
|
||||
{
|
||||
|
||||
+26
-38
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import {
|
||||
@@ -7,9 +8,7 @@ import {
|
||||
TrackedUnderlineLink,
|
||||
} from "@/components/ui/tracked-link";
|
||||
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { AAFooter } from "./AAFooterSection";
|
||||
|
||||
const TRACKING_CATEGORY = "smart-wallet";
|
||||
@@ -100,27 +99,12 @@ function GasCreditAlert(props: {
|
||||
teamSlug: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useLocalStorage(
|
||||
`gas-credit-${props.projectId}`,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-card p-4">
|
||||
<Button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
|
||||
aria-label="Close alert"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="mb-0.5 font-semibold">OP Gas Credit Program</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<DismissibleAlert
|
||||
localStorageId={`${props.projectId}-gas-credit-alert`}
|
||||
title="OP Gas Credit Program"
|
||||
description={
|
||||
<>
|
||||
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
|
||||
<TrackedUnderlineLink
|
||||
target="_blank"
|
||||
@@ -130,22 +114,26 @@ function GasCreditAlert(props: {
|
||||
>
|
||||
Learn More
|
||||
</TrackedUnderlineLink>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Button asChild variant="outline" size="sm" className="bg-background">
|
||||
<TrackedLinkTW
|
||||
href={`/team/${props.teamSlug}/~/settings/credits`}
|
||||
target="_blank"
|
||||
className="gap-2"
|
||||
category={TRACKING_CATEGORY}
|
||||
label="claim-credits"
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-background"
|
||||
>
|
||||
Claim your credits <ArrowRightIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TrackedLinkTW
|
||||
href={`/team/${props.teamSlug}/~/settings/credits`}
|
||||
target="_blank"
|
||||
className="gap-2"
|
||||
category={TRACKING_CATEGORY}
|
||||
label="claim-credits"
|
||||
>
|
||||
Claim your credits <ArrowRightIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-3
@@ -14,11 +14,11 @@ export default async function Page(props: {
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
searchParams: {
|
||||
searchParams: Promise<{
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
@@ -32,10 +32,11 @@ export default async function Page(props: {
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
interval: searchParams.interval,
|
||||
defaultRange: "last-30",
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={props.searchParams}>
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<div>
|
||||
<div className="mb-4 flex justify-start">
|
||||
<PayAnalyticsFilter />
|
||||
|
||||
+9
-32
@@ -19,15 +19,10 @@ import { Loader2Icon, LockIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
arbitrumSepolia,
|
||||
baseSepolia,
|
||||
optimismSepolia,
|
||||
sepolia,
|
||||
} from "thirdweb/chains";
|
||||
import * as z from "zod";
|
||||
import { CopyTextButton } from "../../../../../../../../@/components/ui/CopyTextButton";
|
||||
import { useTrack } from "../../../../../../../../hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "../../../../../../../../hooks/chains/allChains";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client";
|
||||
import { deleteUserAccessToken, getUserAccessToken } from "./utils";
|
||||
@@ -53,6 +48,7 @@ export function SendTestTransaction(props: {
|
||||
const [hasSentTx, setHasSentTx] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
const trackEvent = useTrack();
|
||||
const chainsQuery = useAllChainsData();
|
||||
|
||||
const userAccessToken =
|
||||
props.userAccessToken ?? getUserAccessToken(props.project.id) ?? "";
|
||||
@@ -227,32 +223,13 @@ export function SendTestTransaction(props: {
|
||||
<p className="text-sm">Network</p>
|
||||
<SingleNetworkSelector
|
||||
className="bg-background"
|
||||
chainIds={[
|
||||
baseSepolia.id,
|
||||
optimismSepolia.id,
|
||||
arbitrumSepolia.id,
|
||||
sepolia.id,
|
||||
1301,
|
||||
1946,
|
||||
1993,
|
||||
919,
|
||||
6342,
|
||||
10143,
|
||||
44787,
|
||||
80002,
|
||||
80069,
|
||||
98985,
|
||||
128123,
|
||||
167009,
|
||||
168587773,
|
||||
37714555429,
|
||||
1952959480,
|
||||
97,
|
||||
296,
|
||||
1740,
|
||||
4202,
|
||||
10200,
|
||||
]}
|
||||
chainIds={chainsQuery.allChains
|
||||
.filter(
|
||||
(chain) =>
|
||||
chain.testnet === true &&
|
||||
chain.stackType !== "zksync_stack",
|
||||
)
|
||||
.map((chain) => chain.chainId)}
|
||||
client={thirdwebClient}
|
||||
chainId={form.watch("chainId")}
|
||||
onChange={(chainId) => {
|
||||
|
||||
+4
@@ -32,6 +32,10 @@ type ExecutionResult4337Serialized =
|
||||
| {
|
||||
status: "QUEUED";
|
||||
}
|
||||
| {
|
||||
status: "FAILED";
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
status: "SUBMITTED";
|
||||
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
|
||||
|
||||
+48
-2
@@ -183,6 +183,52 @@ export async function createWalletAccessToken(props: {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "eoa:read",
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "eoa:create",
|
||||
requiredMetadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
projectId: props.project.id,
|
||||
@@ -289,7 +335,6 @@ export async function createManagementAccessToken(props: {
|
||||
});
|
||||
if (res.success) {
|
||||
const data = res.data;
|
||||
// store the management access token in the project
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
@@ -308,8 +353,9 @@ export async function createManagementAccessToken(props: {
|
||||
],
|
||||
},
|
||||
);
|
||||
return res;
|
||||
}
|
||||
return res;
|
||||
throw new Error(`Failed to create management access token: ${res.error}`);
|
||||
}
|
||||
|
||||
export function maskSecret(secret: string) {
|
||||
|
||||
+13
-2
@@ -8,12 +8,14 @@ import { ServerWalletsTable } from "./wallet-table/wallet-table";
|
||||
|
||||
export default async function TransactionsServerWalletsPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const { team_slug, project_slug } = await props.params;
|
||||
const { page } = await props.searchParams;
|
||||
const [authToken, project] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getProject(team_slug, project_slug),
|
||||
@@ -30,6 +32,8 @@ export default async function TransactionsServerWalletsPage(props: {
|
||||
const managementAccessToken =
|
||||
projectEngineCloudService?.managementAccessToken;
|
||||
|
||||
const pageSize = 10;
|
||||
const currentPage = Number.parseInt(page ?? "1");
|
||||
const eoas = managementAccessToken
|
||||
? await listEoas({
|
||||
client: vaultClient,
|
||||
@@ -37,10 +41,14 @@ export default async function TransactionsServerWalletsPage(props: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {},
|
||||
options: {
|
||||
page: currentPage - 1,
|
||||
// @ts-expect-error - TODO: fix this
|
||||
page_size: pageSize,
|
||||
},
|
||||
},
|
||||
})
|
||||
: { data: { items: [] }, error: null, success: true };
|
||||
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,6 +58,9 @@ export default async function TransactionsServerWalletsPage(props: {
|
||||
<div className="flex flex-col gap-8">
|
||||
<ServerWalletsTable
|
||||
wallets={eoas.data.items as Wallet[]}
|
||||
totalRecords={eoas.data.totalRecords}
|
||||
currentPage={currentPage}
|
||||
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
project={project}
|
||||
teamSlug={team_slug}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
|
||||
+69
@@ -19,12 +19,21 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { format } from "date-fns/format";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
predictSmartAccountAddress,
|
||||
} from "thirdweb/wallets/smart";
|
||||
import { Button } from "../../../../../../../../../@/components/ui/button";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "../../../../../../../../../@/components/ui/pagination";
|
||||
import { useThirdwebClient } from "../../../../../../../../../@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "../../../../../../../../../@/lib/DashboardRouter";
|
||||
import { useV5DashboardChain } from "../../../../../../../../../lib/v5-adapter";
|
||||
@@ -36,11 +45,17 @@ export function ServerWalletsTableUI({
|
||||
project,
|
||||
teamSlug,
|
||||
managementAccessToken,
|
||||
totalRecords,
|
||||
currentPage,
|
||||
totalPages,
|
||||
}: {
|
||||
wallets: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
totalRecords: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
}) {
|
||||
const [showSigners, setShowSigners] = useState(false);
|
||||
return (
|
||||
@@ -121,6 +136,60 @@ export function ServerWalletsTableUI({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="flex flex-col items-center p-6">
|
||||
<div className="mb-4 text-muted-foreground text-sm">
|
||||
Found {totalRecords} server wallets
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${
|
||||
currentPage > 1 ? currentPage - 1 : 1
|
||||
}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<PaginationPrevious
|
||||
className={
|
||||
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(pageNumber) => (
|
||||
<PaginationItem key={`page-${pageNumber}`}>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${pageNumber}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationLink isActive={currentPage === pageNumber}>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${
|
||||
currentPage < totalPages ? currentPage + 1 : totalPages
|
||||
}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationNext
|
||||
className={
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+9
@@ -6,16 +6,25 @@ export function ServerWalletsTable({
|
||||
wallets,
|
||||
project,
|
||||
teamSlug,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
managementAccessToken,
|
||||
}: {
|
||||
wallets: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
totalRecords: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
}) {
|
||||
return (
|
||||
<ServerWalletsTableUI
|
||||
wallets={wallets}
|
||||
totalRecords={totalRecords}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
managementAccessToken={managementAccessToken}
|
||||
|
||||
+6
-1
@@ -36,12 +36,17 @@ export function TransactionDetailsUI({
|
||||
transactionHash,
|
||||
confirmedAt,
|
||||
createdAt,
|
||||
errorMessage,
|
||||
executionParams,
|
||||
executionResult,
|
||||
} = transaction;
|
||||
|
||||
const status = executionResult?.status as keyof typeof statusDetails;
|
||||
const errorMessage =
|
||||
executionResult && "error" in executionResult
|
||||
? executionResult.error
|
||||
: executionResult && "revertData" in executionResult
|
||||
? executionResult.revertData?.revertReason
|
||||
: null;
|
||||
|
||||
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
|
||||
const explorer = chain?.explorers?.[0];
|
||||
|
||||
+4
@@ -45,5 +45,9 @@ export async function getEngineInstance(params: {
|
||||
result: EngineInstance;
|
||||
};
|
||||
|
||||
if (!json.result.url.endsWith("/")) {
|
||||
json.result.url += "/";
|
||||
}
|
||||
|
||||
return json.result;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "components/analytics/date-range-selector";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UniversalBridgeStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
@@ -16,12 +17,16 @@ import type {
|
||||
|
||||
import {
|
||||
getInAppWalletUsage,
|
||||
getUniversalBridgeUsage,
|
||||
getUserOpUsage,
|
||||
getWalletConnections,
|
||||
getWalletUsers,
|
||||
isProjectActive,
|
||||
} from "@/api/analytics";
|
||||
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import {
|
||||
EmptyStateCard,
|
||||
EmptyStateContent,
|
||||
} from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import { RangeSelector } from "components/analytics/range-selector";
|
||||
import { Suspense } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -116,6 +121,7 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
<div className="container flex max-w-7xl grow flex-col">
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
<ProjectAnalytics
|
||||
params={params}
|
||||
project={project}
|
||||
range={range}
|
||||
interval={interval}
|
||||
@@ -133,12 +139,13 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
|
||||
async function ProjectAnalytics(props: {
|
||||
project: Project;
|
||||
params: PageParams;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
searchParams: PageSearchParams;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { project, range, interval, searchParams, client } = props;
|
||||
const { project, params, range, interval, searchParams, client } = props;
|
||||
|
||||
// Fetch all analytics data in parallel
|
||||
const [
|
||||
@@ -147,6 +154,7 @@ async function ProjectAnalytics(props: {
|
||||
inAppWalletUsage,
|
||||
userOpUsageTimeSeries,
|
||||
userOpUsage,
|
||||
universalBridgeUsage,
|
||||
] = await Promise.allSettled([
|
||||
// Aggregated wallet connections
|
||||
getWalletConnections({
|
||||
@@ -187,22 +195,30 @@ async function ProjectAnalytics(props: {
|
||||
to: range.to,
|
||||
period: "all",
|
||||
}),
|
||||
// Universal Bridge
|
||||
getUniversalBridgeUsage({
|
||||
teamId: project.teamId,
|
||||
projectId: project.id,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{walletUserStatsTimeSeries.status === "fulfilled" &&
|
||||
universalBridgeUsage.status === "fulfilled" &&
|
||||
walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0) ? (
|
||||
<div className="">
|
||||
<UsersChartCard
|
||||
<AppHighlightsCard
|
||||
chartKey={
|
||||
(searchParams.usersChart as
|
||||
| "totalUsers"
|
||||
| "activeUsers"
|
||||
| "newUsers"
|
||||
| "returningUsers") ?? "activeUsers"
|
||||
(searchParams.appHighlights as keyof AggregatedMetrics) ??
|
||||
"totalVolume"
|
||||
}
|
||||
params={params}
|
||||
userStats={walletUserStatsTimeSeries.value}
|
||||
volumeStats={universalBridgeUsage.value}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
@@ -267,14 +283,14 @@ async function ProjectAnalytics(props: {
|
||||
);
|
||||
}
|
||||
|
||||
type UserMetrics = {
|
||||
totalUsers: number;
|
||||
type AggregatedMetrics = {
|
||||
activeUsers: number;
|
||||
newUsers: number;
|
||||
returningUsers: number;
|
||||
totalVolume: number;
|
||||
feesCollected: number;
|
||||
};
|
||||
|
||||
type TimeSeriesMetrics = UserMetrics & {
|
||||
type TimeSeriesMetrics = AggregatedMetrics & {
|
||||
date: string;
|
||||
};
|
||||
|
||||
@@ -283,52 +299,82 @@ type TimeSeriesMetrics = UserMetrics & {
|
||||
*/
|
||||
function processTimeSeriesData(
|
||||
userStats: WalletUserStats[],
|
||||
volumeStats: UniversalBridgeStats[],
|
||||
): TimeSeriesMetrics[] {
|
||||
const metrics: TimeSeriesMetrics[] = [];
|
||||
|
||||
let cumulativeUsers = 0;
|
||||
for (const stat of userStats) {
|
||||
cumulativeUsers += stat.newUsers ?? 0;
|
||||
const volume = volumeStats
|
||||
.filter((v) => v.date === stat.date && v.status === "completed")
|
||||
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
|
||||
|
||||
const fees = volumeStats
|
||||
.filter((v) => v.date === stat.date && v.status === "completed")
|
||||
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
|
||||
|
||||
metrics.push({
|
||||
date: stat.date,
|
||||
activeUsers: stat.totalUsers ?? 0,
|
||||
returningUsers: stat.returningUsers ?? 0,
|
||||
newUsers: stat.newUsers ?? 0,
|
||||
totalUsers: cumulativeUsers,
|
||||
totalVolume: volume,
|
||||
feesCollected: fees,
|
||||
});
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function UsersChartCard({
|
||||
function AppHighlightsCard({
|
||||
chartKey,
|
||||
userStats,
|
||||
volumeStats,
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
chartKey: keyof UserMetrics;
|
||||
chartKey: keyof AggregatedMetrics;
|
||||
userStats: WalletUserStats[];
|
||||
volumeStats: UniversalBridgeStats[];
|
||||
params: PageParams;
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const timeSeriesData = processTimeSeriesData(userStats);
|
||||
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
|
||||
|
||||
const chartConfig = {
|
||||
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
|
||||
totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" },
|
||||
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
|
||||
returningUsers: {
|
||||
label: "Returning Users",
|
||||
color: "hsl(var(--chart-4))",
|
||||
totalVolume: {
|
||||
label: "Total Volume",
|
||||
color: "hsl(var(--chart-2))",
|
||||
isCurrency: true,
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
metric="Payments"
|
||||
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
|
||||
link="https://portal.thirdweb.com/connect/pay/overview"
|
||||
/>
|
||||
),
|
||||
},
|
||||
feesCollected: {
|
||||
label: "Fee Revenue",
|
||||
color: "hsl(var(--chart-4))",
|
||||
isCurrency: true,
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
metric="Fees"
|
||||
description="Your app hasn't collected any fees yet."
|
||||
link={`/team/${params.team_slug}/${params.project_slug}/connect/universal-bridge/settings`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
|
||||
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
title="Users"
|
||||
title="App Highlights"
|
||||
chartConfig={chartConfig}
|
||||
activeChart={chartKey}
|
||||
queryKey="usersChart"
|
||||
queryKey="appHighlights"
|
||||
data={timeSeriesData}
|
||||
aggregateFn={(_data, key) =>
|
||||
// If there is only one data point, use that one, otherwise use the previous
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PosthogIdentifierServer } from "components/wallets/PosthogIdentifierServer";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { EnsureValidConnectedWalletLoginServer } from "../../components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
|
||||
@@ -28,6 +31,10 @@ export default async function RootTeamLayout(props: {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<div className="flex grow flex-col">
|
||||
{team.billingPlan === "starter_legacy" && (
|
||||
<StarterLegacyDiscontinuedBanner teamSlug={team_slug} />
|
||||
)}
|
||||
|
||||
{team.billingStatus === "pastDue" && (
|
||||
<PastDueBanner teamSlug={team_slug} />
|
||||
)}
|
||||
@@ -50,3 +57,31 @@ export default async function RootTeamLayout(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StarterLegacyDiscontinuedBanner(props: {
|
||||
teamSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-red-600 border-b bg-red-50 px-4 py-6 text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-100">
|
||||
<div className="text-center">
|
||||
<p>Starter legacy plans are being discontinued on May 31, 2025</p>
|
||||
<p>
|
||||
To prevent service interruptions and losing access to your current
|
||||
features select a new plan
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="mt-3 gap-2 border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800"
|
||||
>
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true`}
|
||||
>
|
||||
Select a new plan
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function BarChart({
|
||||
}}
|
||||
className="aspect-auto h-[250px] w-full pt-6"
|
||||
>
|
||||
{data.length === 0 ? (
|
||||
{data.length === 0 || data.every((d) => d[activeKey] === 0) ? (
|
||||
<EmptyChartState> {emptyChartContent} </EmptyChartState>
|
||||
) : (
|
||||
<RechartsBarChart
|
||||
@@ -87,7 +87,7 @@ export function BarChart({
|
||||
});
|
||||
}}
|
||||
valueFormatter={(v: unknown) =>
|
||||
isCurrency
|
||||
isCurrency || chartConfig[activeKey]?.isCurrency
|
||||
? toUSD(v as number)
|
||||
: formatTickerNumber(v as number)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { BarChart } from "./BarChart";
|
||||
import { Stat } from "./Stat";
|
||||
|
||||
type CombinedBarChartConfig<K extends string> = {
|
||||
[key in K]: { label: string; color: string };
|
||||
[key in K]: {
|
||||
label: string;
|
||||
color: string;
|
||||
isCurrency?: boolean;
|
||||
emptyContent?: React.ReactNode;
|
||||
};
|
||||
};
|
||||
|
||||
// TODO - don't reload page on tab change -> make this a client component, load all the charts at once on server and switch between them on client
|
||||
@@ -72,7 +77,7 @@ export function CombinedBarChartCard<
|
||||
<Stat
|
||||
label={chartConfig[key].label}
|
||||
value={
|
||||
isCurrency
|
||||
isCurrency || chartConfig[key].isCurrency
|
||||
? toUSD(aggregateFn(data, key) ?? 0)
|
||||
: (aggregateFn(data, key) ?? 0)
|
||||
}
|
||||
@@ -95,6 +100,7 @@ export function CombinedBarChartCard<
|
||||
chartConfig={chartConfig}
|
||||
data={data}
|
||||
activeKey={activeChart}
|
||||
emptyChartContent={chartConfig[activeChart].emptyContent}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -6,25 +6,46 @@ import Link from "next/link";
|
||||
export function EmptyStateCard({
|
||||
metric,
|
||||
link,
|
||||
}: { metric: string; link?: string }) {
|
||||
description,
|
||||
}: { metric: string; link?: string; description?: string }) {
|
||||
return (
|
||||
<Card className="container h-[300px] p-2 md:h-[400px]">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-md border text-center">
|
||||
<div className="flex size-8 items-center justify-center rounded-md border bg-card text-muted-foreground">
|
||||
<PlugIcon className="size-4" />
|
||||
</div>
|
||||
<div className="font-semibold text-lg">No data available</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Your app may not be configured to use {metric}.
|
||||
</div>
|
||||
{link && (
|
||||
<Button asChild variant="primary" size="sm" className="mt-4">
|
||||
<Link href={link} target="_blank" className="text-sm">
|
||||
Configure {metric}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<EmptyStateContent
|
||||
metric={metric}
|
||||
link={link}
|
||||
description={description}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyStateContent({
|
||||
metric,
|
||||
description,
|
||||
link,
|
||||
}: {
|
||||
metric: string;
|
||||
description?: string;
|
||||
link?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-2 text-foreground">
|
||||
<div className="flex size-8 items-center justify-center rounded-md border bg-card">
|
||||
<PlugIcon className="size-4" />
|
||||
</div>
|
||||
<div className="font-semibold text-lg">No data available</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{description ?? `Your app may not be configured to use ${metric}.`}
|
||||
</div>
|
||||
{link && (
|
||||
<Button asChild variant="primary" size="sm" className="mt-4">
|
||||
<Link href={link} target="_blank" className="text-sm">
|
||||
Configure {metric}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-84
@@ -67,114 +67,46 @@ function randomNotifications(count: number): NotificationMetadata[] {
|
||||
|
||||
export const AllUnread: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(10),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
notifications: randomNotifications(10),
|
||||
isPending: false,
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: [],
|
||||
isPending: true,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: [],
|
||||
isPending: true,
|
||||
},
|
||||
},
|
||||
notifications: [],
|
||||
isPending: true,
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const NoNotifications: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: [],
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: [],
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
notifications: randomNotifications(10).map((x) => ({
|
||||
...x,
|
||||
isRead: Math.random() > 0.5,
|
||||
})),
|
||||
isPending: false,
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
+30
-111
@@ -8,12 +8,12 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { ActivityIcon, BellIcon, InboxIcon } from "lucide-react";
|
||||
import { BellIcon, InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { cleanupReadNotifications } from "./fetch-notifications";
|
||||
|
||||
export type NotificationMetadata = {
|
||||
@@ -24,19 +24,7 @@ export type NotificationMetadata = {
|
||||
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>;
|
||||
}) {
|
||||
@@ -45,22 +33,10 @@ export function NotificationButtonUI(props: {
|
||||
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,
|
||||
@@ -68,58 +44,30 @@ export function NotificationButtonUI(props: {
|
||||
}));
|
||||
}, [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);
|
||||
await cleanupReadNotifications(inboxNotifications);
|
||||
},
|
||||
[props.markNotificationAsRead],
|
||||
[props.markNotificationAsRead, inboxNotifications],
|
||||
);
|
||||
|
||||
const notificationTabs: NotificationTabs = {
|
||||
inbox: {
|
||||
notifications: inboxNotifications,
|
||||
isPending: inboxNotificationsQuery.isPending,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: changelogNotifications,
|
||||
isPending: changelogsQuery.isPending,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationButtonInner
|
||||
notificationTabs={notificationTabs}
|
||||
notifications={inboxNotifications}
|
||||
isPending={inboxNotificationsQuery.isPending}
|
||||
markNotificationAsRead={markAsRead}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationButtonInner(props: {
|
||||
notificationTabs: NotificationTabs;
|
||||
notifications: NotificationMetadata[];
|
||||
isPending: boolean;
|
||||
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,
|
||||
),
|
||||
};
|
||||
const unreadCount = props.notifications.filter((x) => !x.isRead).length;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
@@ -128,9 +76,16 @@ export function NotificationButtonInner(props: {
|
||||
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" />
|
||||
<BellIcon
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground",
|
||||
unreadCount > 0 && "text-foreground",
|
||||
)}
|
||||
/>
|
||||
{unreadCount > 0 && (
|
||||
<div className="-top-1.5 -right-1.5 absolute flex items-center justify-center rounded-full bg-[#e1002d] px-1 py-0.5 text-white text-xs">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -140,64 +95,28 @@ export function NotificationButtonInner(props: {
|
||||
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"),
|
||||
},
|
||||
{
|
||||
isActive: tab === "changelogs",
|
||||
name: (
|
||||
<BadgeTab
|
||||
name="Changelog"
|
||||
unreadCount={unreadNotifications.changelogs.length}
|
||||
/>
|
||||
),
|
||||
onClick: () => setTab("changelogs"),
|
||||
},
|
||||
]}
|
||||
<div className="flex gap-2 border-b px-4 py-4">
|
||||
<span className="font-semibold">Notifications</span>
|
||||
<BadgeTab unreadCount={unreadCount} />
|
||||
</div>
|
||||
<NotificationContent
|
||||
notifications={props.notifications}
|
||||
isPending={props.isPending}
|
||||
icon={InboxIcon}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
|
||||
{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">
|
||||
<span className="rounded-xl border bg-card px-1.5 py-0.5 text-xs">
|
||||
{props.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
+4
-26
@@ -30,18 +30,13 @@ async function fetchGhostPosts(tag: string) {
|
||||
// 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;
|
||||
export async function cleanupReadNotifications(
|
||||
notifications: NotificationMetadata[],
|
||||
) {
|
||||
const readIds = await getReadNotificationIds();
|
||||
|
||||
// Get all current notification IDs
|
||||
const currentIds = new Set([
|
||||
...changelogs.map((item) => item.id),
|
||||
...updates.map((item) => item.id),
|
||||
]);
|
||||
const currentIds = new Set([...notifications.map((item) => item.id)]);
|
||||
|
||||
// remove ids that are no longer being displayed
|
||||
const usedIds = new Set(
|
||||
@@ -59,23 +54,6 @@ export async function markNotificationAsRead(id: string) {
|
||||
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"),
|
||||
|
||||
@@ -147,7 +147,6 @@ function Variant(props: {
|
||||
const Comp =
|
||||
props.type === "mobile" ? TeamHeaderMobileUI : TeamHeaderDesktopUI;
|
||||
|
||||
const getChangelogsStub = () => Promise.resolve([]);
|
||||
const getInboxNotificationsStub = () => Promise.resolve([]);
|
||||
const markNotificationAsReadStub = () => Promise.resolve();
|
||||
|
||||
@@ -166,7 +165,6 @@ function Variant(props: {
|
||||
connectButton={<ConnectButtonStub />}
|
||||
createProject={() => {}}
|
||||
client={storybookThirdwebClient}
|
||||
getChangelogNotifications={getChangelogsStub}
|
||||
getInboxNotifications={getInboxNotificationsStub}
|
||||
markNotificationAsRead={markNotificationAsReadStub}
|
||||
/>
|
||||
|
||||
@@ -31,7 +31,6 @@ export type TeamHeaderCompProps = {
|
||||
createProject: (team: Team) => void;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
};
|
||||
@@ -117,7 +116,6 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
connectButton={props.connectButton}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
@@ -202,7 +200,6 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
|
||||
-2
@@ -11,7 +11,6 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { doLogout } from "../../../login/auth-actions";
|
||||
import {
|
||||
getChangelogNotifications,
|
||||
getInboxNotifications,
|
||||
markNotificationAsRead,
|
||||
} from "../NotificationButton/fetch-notifications";
|
||||
@@ -64,7 +63,6 @@ export function TeamHeaderLoggedIn(props: {
|
||||
},
|
||||
client: props.client,
|
||||
accountAddress: props.accountAddress,
|
||||
getChangelogNotifications: getChangelogNotifications,
|
||||
getInboxNotifications: getInboxNotifications,
|
||||
markNotificationAsRead: markNotificationAsRead,
|
||||
};
|
||||
|
||||
@@ -152,6 +152,24 @@ export async function promptNebula(params: {
|
||||
break;
|
||||
}
|
||||
|
||||
case "error": {
|
||||
const data = JSON.parse(event.data) as {
|
||||
code: number;
|
||||
error: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
params.handleStream({
|
||||
event: "error",
|
||||
data: {
|
||||
code: data.code,
|
||||
errorMessage: data.error.message,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "init": {
|
||||
const data = JSON.parse(event.data);
|
||||
params.handleStream({
|
||||
@@ -242,6 +260,13 @@ type ChatStreamedResponse =
|
||||
chain_ids: number[];
|
||||
networks: NebulaContext["networks"];
|
||||
};
|
||||
}
|
||||
| {
|
||||
event: "error";
|
||||
data: {
|
||||
code: number;
|
||||
errorMessage: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ChatStreamedEvent =
|
||||
@@ -269,4 +294,8 @@ type ChatStreamedEvent =
|
||||
| {
|
||||
event: "context";
|
||||
data: string;
|
||||
}
|
||||
| {
|
||||
event: "error";
|
||||
data: string;
|
||||
};
|
||||
|
||||
@@ -6,11 +6,8 @@ type SessionContextFilter = {
|
||||
type NebulaUserMessageContentItem =
|
||||
| {
|
||||
type: "image";
|
||||
image_url: string;
|
||||
}
|
||||
| {
|
||||
type: "image";
|
||||
b64: string;
|
||||
image_url: string | null;
|
||||
b64: string | null;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { storybookThirdwebClient } from "../../../../../stories/utils";
|
||||
import { type AssetBalance, AssetsSectionUI } from "./AssetsSection";
|
||||
|
||||
const meta = {
|
||||
title: "Nebula/AssetsSection",
|
||||
component: AssetsSectionUI,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AssetsSectionUI>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const tokensStub: AssetBalance[] = [
|
||||
{
|
||||
chain_id: 8453,
|
||||
token_address: "0xe8e55a847bb446d967ef92f4580162fb8f2d3f38",
|
||||
name: "Broge",
|
||||
symbol: "BROGE",
|
||||
decimals: 18,
|
||||
balance: "10000000000000000000000",
|
||||
},
|
||||
{
|
||||
chain_id: 8453,
|
||||
token_address: "0x8d2757ea27aabf172da4cca4e5474c76016e3dc5",
|
||||
name: "clBTC",
|
||||
symbol: "clBTC",
|
||||
decimals: 18,
|
||||
balance: "2",
|
||||
},
|
||||
{
|
||||
chain_id: 8453,
|
||||
token_address: "0xb56d0839998fd79efcd15c27cf966250aa58d6d3",
|
||||
name: "BASED USA",
|
||||
symbol: "USA",
|
||||
decimals: 18,
|
||||
balance: "1000000000000000000",
|
||||
},
|
||||
{
|
||||
chain_id: 8453,
|
||||
token_address: "0x600c9b69a65fb6d2551623a53ddef17b050233cd",
|
||||
name: "BearPaw",
|
||||
symbol: "PAW",
|
||||
decimals: 18,
|
||||
balance: "48888800000000000000",
|
||||
},
|
||||
{
|
||||
chain_id: 8453,
|
||||
token_address: "0x4c96a67b0577358894407af7bc3158fc1dffbeb5",
|
||||
name: "Degen Point Of View",
|
||||
symbol: "POV",
|
||||
decimals: 18,
|
||||
balance: "69000000000000000000",
|
||||
},
|
||||
{
|
||||
chain_id: 8453,
|
||||
token_address: "0x4200000000000000000000000000000000000006",
|
||||
name: "Wrapped Ether",
|
||||
symbol: "WETH",
|
||||
decimals: 18,
|
||||
balance: "6237535850425",
|
||||
},
|
||||
];
|
||||
|
||||
export const MultipleAssets: Story = {
|
||||
args: {
|
||||
data: tokensStub,
|
||||
isPending: false,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleAsset: Story = {
|
||||
args: {
|
||||
data: tokensStub.slice(0, 1),
|
||||
isPending: false,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyAssets: Story = {
|
||||
args: {
|
||||
data: [],
|
||||
isPending: false,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
data: [],
|
||||
isPending: true,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
defineChain,
|
||||
getAddress,
|
||||
toTokens,
|
||||
} from "thirdweb";
|
||||
import {
|
||||
Blobbie,
|
||||
TokenIcon,
|
||||
TokenProvider,
|
||||
useActiveAccount,
|
||||
useActiveWalletChain,
|
||||
} from "thirdweb/react";
|
||||
import { getWalletBalance } from "thirdweb/wallets";
|
||||
import { ChainIconClient } from "../../../../../components/icons/ChainIcon";
|
||||
import { useAllChainsData } from "../../../../../hooks/chains/allChains";
|
||||
import { nebulaAppThirdwebClient } from "../../utils/nebulaThirdwebClient";
|
||||
|
||||
export type AssetBalance = {
|
||||
chain_id: number;
|
||||
token_address: string;
|
||||
balance: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
|
||||
export function AssetsSectionUI(props: {
|
||||
data: AssetBalance[];
|
||||
isPending: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
if (props.data.length === 0 && !props.isPending) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-2 py-1">
|
||||
<div className="rounded-full border p-1">
|
||||
<XIcon className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">No Assets </div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{!props.isPending &&
|
||||
props.data.map((asset) => (
|
||||
<AssetItem
|
||||
key={`${asset.chain_id}-${asset.token_address}`}
|
||||
asset={asset}
|
||||
client={props.client}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.isPending &&
|
||||
new Array(10).fill(null).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<SkeletonAssetItem key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonAssetItem() {
|
||||
return (
|
||||
<div className="flex h-[48px] items-center gap-2 px-2 py-1">
|
||||
<Skeleton className="size-8 rounded-full" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-3 w-32 bg-muted" />
|
||||
<Skeleton className="h-3 w-24 bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetItem(props: {
|
||||
asset: AssetBalance;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const chainMeta = idToChain.get(props.asset.chain_id);
|
||||
const isNativeToken = props.asset.token_address === NATIVE_TOKEN_ADDRESS;
|
||||
|
||||
return (
|
||||
<TokenProvider
|
||||
address={props.asset.token_address}
|
||||
client={props.client}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineChain(props.asset.chain_id)}
|
||||
>
|
||||
<div className="relative flex h-[48px] items-center gap-2.5 rounded-lg px-2 py-1 hover:bg-accent">
|
||||
<div className="relative">
|
||||
<TokenIcon
|
||||
className="size-8 rounded-full border"
|
||||
loadingComponent={
|
||||
<Blobbie
|
||||
address={props.asset.token_address}
|
||||
className="size-8 rounded-full"
|
||||
/>
|
||||
}
|
||||
fallbackComponent={
|
||||
<Blobbie
|
||||
address={props.asset.token_address}
|
||||
className="size-8 rounded-full"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!isNativeToken && (
|
||||
<div className="-right-0.5 -bottom-0.5 absolute rounded-full border bg-background p-0.5">
|
||||
<ChainIconClient
|
||||
client={props.client}
|
||||
className="size-3.5"
|
||||
src={chainMeta?.icon?.url || ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col text-sm">
|
||||
<Link
|
||||
href={
|
||||
isNativeToken
|
||||
? `https://thirdweb.com/${props.asset.chain_id}`
|
||||
: `https://thirdweb.com/${props.asset.chain_id}/${props.asset.token_address}`
|
||||
}
|
||||
target="_blank"
|
||||
className="truncate font-medium before:absolute before:inset-0"
|
||||
>
|
||||
{props.asset.name}
|
||||
</Link>
|
||||
|
||||
<p className="truncate text-muted-foreground text-sm">
|
||||
{`${toTokens(BigInt(props.asset.balance), props.asset.decimals)} ${props.asset.symbol}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TokenProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetsSection(props: {
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const account = useActiveAccount();
|
||||
const activeChain = useActiveWalletChain();
|
||||
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ["v1/tokens/erc20", account?.address, activeChain?.id],
|
||||
queryFn: async () => {
|
||||
if (!account || !activeChain) {
|
||||
return [];
|
||||
}
|
||||
const chains = [...new Set([1, 8453, 10, 137, activeChain.id])];
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/erc20/${account?.address}`,
|
||||
);
|
||||
url.searchParams.set("limit", "50");
|
||||
url.searchParams.set("metadata", "true");
|
||||
url.searchParams.set("include_spam", "false");
|
||||
url.searchParams.set("clientId", nebulaAppThirdwebClient.clientId);
|
||||
for (const chain of chains) {
|
||||
url.searchParams.append("chain", chain.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const json = (await response.json()) as {
|
||||
data: AssetBalance[];
|
||||
};
|
||||
|
||||
const tokensToShowOnTop = new Set(
|
||||
[
|
||||
// base
|
||||
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // usdc,
|
||||
"0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", // wbtc
|
||||
"0x4200000000000000000000000000000000000006", // wrapped eth
|
||||
// ethereum
|
||||
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // usdc
|
||||
"0xdac17f958d2ee523a2206206994597c13d831ec7", // usdt
|
||||
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52", // bnb
|
||||
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // weth
|
||||
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", // wbtc
|
||||
// optimism
|
||||
"0x4200000000000000000000000000000000000042", // op token
|
||||
"0xdc6ff44d5d932cbd77b52e5612ba0529dc6226f1", // world coin
|
||||
"0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", // usdt
|
||||
"0x0b2c639c533813f4aa9d7837caf62653d097ff85", // usdc
|
||||
"0x4200000000000000000000000000000000000006", // wrapped eth
|
||||
// polygon
|
||||
"0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", // weth
|
||||
"0xc2132d05d31c914a87c6611c10748aeb04b58e8f", // usdt
|
||||
"0x3BA4c387f786bFEE076A58914F5Bd38d668B42c3", // bnb
|
||||
"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", // usdc
|
||||
"0x2791bca1f2de4661ed88a30c99a7a9449aa84174", // usdc.e
|
||||
].map((x) => getAddress(x)),
|
||||
);
|
||||
|
||||
return json.data.sort((a, b) => {
|
||||
if (tokensToShowOnTop.has(getAddress(a.token_address))) {
|
||||
return -1;
|
||||
}
|
||||
if (tokensToShowOnTop.has(getAddress(b.token_address))) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
enabled: !!account && !!activeChain,
|
||||
});
|
||||
|
||||
const nativeBalances = useQuery({
|
||||
queryKey: ["getWalletBalance", account?.address, activeChain?.id],
|
||||
queryFn: async () => {
|
||||
if (!account || !activeChain) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chains = [...new Set([1, 8453, 10, 137, activeChain.id])];
|
||||
|
||||
const result = await Promise.allSettled(
|
||||
chains.map((chain) =>
|
||||
getWalletBalance({
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(chain),
|
||||
client: props.client,
|
||||
address: account.address,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return result
|
||||
.filter((r) => r.status === "fulfilled")
|
||||
.map((r) => ({
|
||||
chain_id: r.value.chainId,
|
||||
token_address: r.value.tokenAddress,
|
||||
balance: r.value.value.toString(),
|
||||
name: r.value.name,
|
||||
symbol: r.value.symbol,
|
||||
decimals: r.value.decimals,
|
||||
}))
|
||||
.filter((x) => x.balance !== "0") as AssetBalance[];
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = assetsQuery.isPending || nativeBalances.isPending;
|
||||
|
||||
const data = [...(nativeBalances.data ?? []), ...(assetsQuery.data ?? [])];
|
||||
|
||||
return (
|
||||
<AssetsSectionUI data={data} isPending={isPending} client={props.client} />
|
||||
);
|
||||
}
|
||||
@@ -36,8 +36,10 @@ import {
|
||||
AccountBlobbie,
|
||||
AccountName,
|
||||
AccountProvider,
|
||||
WalletIcon,
|
||||
WalletName,
|
||||
WalletProvider,
|
||||
useActiveWallet,
|
||||
} from "thirdweb/react";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import type { Wallet } from "thirdweb/wallets";
|
||||
@@ -82,7 +84,11 @@ export function ChatBar(props: {
|
||||
if (images.length > 0) {
|
||||
for (const image of images) {
|
||||
if (image.b64) {
|
||||
userMessage.content.push({ type: "image", b64: image.b64 });
|
||||
userMessage.content.push({
|
||||
type: "image",
|
||||
b64: image.b64,
|
||||
image_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,6 +256,7 @@ export function ChatBar(props: {
|
||||
<ImageUploadButton
|
||||
multiple
|
||||
value={undefined}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={(files) => {
|
||||
const totalFiles = files.length + images.length;
|
||||
|
||||
@@ -446,8 +453,18 @@ function WalletSelector(props: {
|
||||
selectedAddress: string | undefined;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeWallet = useActiveWallet();
|
||||
|
||||
if (!props.selectedAddress) {
|
||||
const accountBlobbie = <AccountBlobbie className="size-3 rounded-full" />;
|
||||
const accountAvatarFallback = (
|
||||
<WalletIcon
|
||||
className="size-3 rounded-lg"
|
||||
fallbackComponent={accountBlobbie}
|
||||
loadingComponent={accountBlobbie}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!props.selectedAddress || !activeWallet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -466,22 +483,32 @@ function WalletSelector(props: {
|
||||
aria-expanded={open}
|
||||
className="flex h-auto items-center gap-1 rounded-full px-2 py-1.5 text-xs"
|
||||
>
|
||||
<AccountProvider
|
||||
address={props.selectedAddress}
|
||||
client={props.client}
|
||||
>
|
||||
<AccountAvatar
|
||||
className="size-4 rounded-full"
|
||||
loadingComponent={
|
||||
<Skeleton className="size-3 rounded-full border" />
|
||||
}
|
||||
fallbackComponent={
|
||||
<AccountBlobbie className="size-3 rounded-full" />
|
||||
}
|
||||
/>
|
||||
{shortenAddress(props.selectedAddress)}
|
||||
<ChevronDownIcon className="size-3 text-muted-foreground/70" />
|
||||
</AccountProvider>
|
||||
<WalletProvider id={activeWallet.id}>
|
||||
<AccountProvider
|
||||
address={props.selectedAddress}
|
||||
client={props.client}
|
||||
>
|
||||
<AccountAvatar
|
||||
className="size-3 rounded-full"
|
||||
loadingComponent={accountAvatarFallback}
|
||||
fallbackComponent={accountAvatarFallback}
|
||||
/>
|
||||
<AccountName
|
||||
className="text-xs"
|
||||
loadingComponent={
|
||||
<span className="text-xs">
|
||||
{shortenAddress(props.selectedAddress)}
|
||||
</span>
|
||||
}
|
||||
fallbackComponent={
|
||||
<span className="text-xs">
|
||||
{shortenAddress(props.selectedAddress)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<ChevronDownIcon className="size-3 text-muted-foreground/70" />
|
||||
</AccountProvider>
|
||||
</WalletProvider>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -496,81 +523,107 @@ function WalletSelector(props: {
|
||||
</div>
|
||||
|
||||
<div className="[&>*:not(:last-child)]:border-b">
|
||||
{sortedWallets.map((wallet) => (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div
|
||||
key={wallet.address}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between px-3 py-4 hover:bg-accent/50",
|
||||
props.selectedAddress === wallet.address && "bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
props.onClick(wallet);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AccountProvider address={wallet.address} client={props.client}>
|
||||
<WalletProvider id={wallet.walletId}>
|
||||
<div className="flex items-center gap-2">
|
||||
<AccountAvatar
|
||||
className="size-8 rounded-full"
|
||||
loadingComponent={
|
||||
<Skeleton className="size-8 rounded-full border" />
|
||||
}
|
||||
fallbackComponent={
|
||||
<AccountBlobbie className="size-8 rounded-full" />
|
||||
}
|
||||
/>
|
||||
{sortedWallets.map((wallet) => {
|
||||
const accountBlobbie = (
|
||||
<AccountBlobbie className="size-8 rounded-full" />
|
||||
);
|
||||
const accountAvatarFallback = (
|
||||
<WalletIcon
|
||||
className="size-8 rounded-lg"
|
||||
fallbackComponent={accountBlobbie}
|
||||
loadingComponent={accountBlobbie}
|
||||
/>
|
||||
);
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<AccountName
|
||||
className="text-sm"
|
||||
loadingComponent={
|
||||
<span className="font-mono text-sm">
|
||||
{shortenAddress(wallet.address)}
|
||||
</span>
|
||||
}
|
||||
fallbackComponent={
|
||||
<span className="font-mono text-sm">
|
||||
{shortenAddress(wallet.address)}
|
||||
</span>
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
key={wallet.address}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between px-3 py-4 hover:bg-accent/50",
|
||||
props.selectedAddress === wallet.address && "bg-accent/50",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setOpen(false);
|
||||
props.onClick(wallet);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
props.onClick(wallet);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletProvider id={wallet.walletId}>
|
||||
<AccountProvider
|
||||
address={wallet.address}
|
||||
client={props.client}
|
||||
>
|
||||
<WalletProvider id={wallet.walletId}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AccountAvatar
|
||||
className="size-8 rounded-full"
|
||||
loadingComponent={accountAvatarFallback}
|
||||
fallbackComponent={accountAvatarFallback}
|
||||
/>
|
||||
|
||||
<CopyButton address={wallet.address} />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
<AccountName
|
||||
className="text-sm"
|
||||
loadingComponent={
|
||||
<span className="text-sm">
|
||||
{shortenAddress(wallet.address)}
|
||||
</span>
|
||||
}
|
||||
fallbackComponent={
|
||||
<span className="text-sm">
|
||||
{shortenAddress(wallet.address)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{wallet.walletId === "smart" && (
|
||||
<Badge variant="outline" className="bg-card px-2">
|
||||
Gasless
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CopyButton address={wallet.address} />
|
||||
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{wallet.walletId === "smart" ? (
|
||||
"Smart Account"
|
||||
) : (
|
||||
<WalletName
|
||||
fallbackComponent={<span> Your Account </span>}
|
||||
loadingComponent={
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{wallet.walletId === "smart" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-card px-2"
|
||||
>
|
||||
Gasless
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{wallet.walletId === "smart" ? (
|
||||
"Smart Account"
|
||||
) : (
|
||||
<WalletName
|
||||
fallbackComponent={
|
||||
<span> Your Account </span>
|
||||
}
|
||||
loadingComponent={
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WalletProvider>
|
||||
</AccountProvider>
|
||||
</WalletProvider>
|
||||
</AccountProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.selectedAddress === wallet.address && (
|
||||
<CheckIcon className="size-4 text-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{props.selectedAddress === wallet.address && (
|
||||
<CheckIcon className="size-4 text-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ArrowRightIcon, MessageSquareXIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -263,8 +263,11 @@ export function ChatPageContent(props: {
|
||||
const showEmptyState =
|
||||
!userHasSubmittedMessage &&
|
||||
messages.length === 0 &&
|
||||
!props.session &&
|
||||
!props.initialParams?.q;
|
||||
|
||||
const sessionWithNoMessages = props.session && messages.length === 0;
|
||||
|
||||
const connectedWalletsMeta: WalletMeta[] = connectedWallets.map((x) => ({
|
||||
address: x.getAccount()?.address || "",
|
||||
walletId: x.id,
|
||||
@@ -318,17 +321,35 @@ export function ChatPageContent(props: {
|
||||
</div>
|
||||
) : (
|
||||
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
|
||||
<Chats
|
||||
messages={messages}
|
||||
isChatStreaming={isChatStreaming}
|
||||
authToken={props.authToken}
|
||||
sessionId={sessionId}
|
||||
className="min-w-0 pt-6 pb-32"
|
||||
client={client}
|
||||
enableAutoScroll={enableAutoScroll}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
sendMessage={handleSendMessage}
|
||||
/>
|
||||
{sessionWithNoMessages && (
|
||||
<div className="container flex max-h-full max-w-[800px] flex-1 flex-col justify-center py-8">
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="mb-5 rounded-full border bg-card p-3">
|
||||
<MessageSquareXIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mb-1 text-center text-foreground">
|
||||
No messages found
|
||||
</p>
|
||||
<p className="text-balance text-center text-muted-foreground text-sm">
|
||||
This session was aborted before receiving any messages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length > 0 && (
|
||||
<Chats
|
||||
messages={messages}
|
||||
isChatStreaming={isChatStreaming}
|
||||
authToken={props.authToken}
|
||||
sessionId={sessionId}
|
||||
className="min-w-0 pt-6 pb-32"
|
||||
client={client}
|
||||
enableAutoScroll={enableAutoScroll}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
sendMessage={handleSendMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container max-w-[800px]">
|
||||
<ChatBar
|
||||
@@ -578,6 +599,20 @@ export async function handleNebulaPrompt(params: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "error": {
|
||||
hasReceivedResponse = true;
|
||||
setMessages((prev) => {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
text: res.data.errorMessage,
|
||||
type: "error",
|
||||
},
|
||||
];
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
context: contextFilters,
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ChatPageLayout(props: {
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<aside className="hidden w-[280px] shrink-0 border-border border-r bg-card lg:block">
|
||||
<aside className="hidden w-[300px] shrink-0 border-border border-r bg-card lg:block">
|
||||
<ChatSidebar
|
||||
sessions={props.sessions}
|
||||
authToken={props.authToken}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
FileCode2Icon,
|
||||
LogOutIcon,
|
||||
@@ -14,15 +19,27 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AccountAvatar,
|
||||
AccountBlobbie,
|
||||
AccountProvider,
|
||||
useActiveWallet,
|
||||
} from "thirdweb/react";
|
||||
import { WalletIcon, WalletName, WalletProvider } from "thirdweb/react";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { useIsClientMounted } from "../../../../components/ClientOnly/ClientOnly";
|
||||
import { doNebulaLogout } from "../../login/auth-actions";
|
||||
import type { TruncatedSessionInfo } from "../api/types";
|
||||
import { useNewChatPageLink } from "../hooks/useNewChatPageLink";
|
||||
import { useSessionsWithLocalOverrides } from "../hooks/useSessionsWithLocalOverrides";
|
||||
import { NebulaIcon } from "../icons/NebulaIcon";
|
||||
import { nebulaAppThirdwebClient } from "../utils/nebulaThirdwebClient";
|
||||
import { AssetsSection } from "./AssetsSection/AssetsSection";
|
||||
import { ChatSidebarLink } from "./ChatSidebarLink";
|
||||
import { NebulaConnectWallet } from "./NebulaConnectButton";
|
||||
import { TransactionsSection } from "./TransactionsSection/TransactionsSection";
|
||||
|
||||
export function ChatSidebar(props: {
|
||||
sessions: TruncatedSessionInfo[];
|
||||
@@ -87,7 +104,7 @@ export function ChatSidebar(props: {
|
||||
return (
|
||||
<ChatSidebarLink
|
||||
sessionId={session.id}
|
||||
title={session.title || session.id}
|
||||
title={session.title || "Untitled Chat"}
|
||||
key={session.id}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
@@ -97,7 +114,11 @@ export function ChatSidebar(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto space-y-1 border-y border-dashed px-2 py-4">
|
||||
<div className="mt-auto">
|
||||
<WalletDetails />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 border-t px-4 py-3">
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/nebula"
|
||||
icon={FileCode2Icon}
|
||||
@@ -120,10 +141,122 @@ export function ChatSidebar(props: {
|
||||
label="Log Out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="[&>*]:!w-full p-4">
|
||||
<NebulaConnectWallet detailsButtonClassName="!bg-background hover:!border-active-border" />
|
||||
function WalletDetails() {
|
||||
const [tab, setTab] = useState<"assets" | "transactions">("assets");
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
return (
|
||||
<DynamicHeight transition="height 220ms ease">
|
||||
<div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
|
||||
<CustomConnectButton />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-auto w-auto p-1.5 transition-transform duration-300",
|
||||
isExpanded ? "rotate-180" : "",
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-dashed">
|
||||
<TabButtons
|
||||
tabContainerClassName="px-2 pt-2"
|
||||
tabClassName="!text-sm py-1.5"
|
||||
tabs={[
|
||||
{
|
||||
name: "Assets",
|
||||
onClick: () => setTab("assets"),
|
||||
isActive: tab === "assets",
|
||||
},
|
||||
{
|
||||
name: "Recent Activity",
|
||||
onClick: () => setTab("transactions"),
|
||||
isActive: tab === "transactions",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="h-[230px] overflow-y-auto py-4 pr-1 pl-2">
|
||||
{tab === "assets" && (
|
||||
<AssetsSection client={nebulaAppThirdwebClient} />
|
||||
)}
|
||||
|
||||
{tab === "transactions" && (
|
||||
<TransactionsSection client={nebulaAppThirdwebClient} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomConnectButton() {
|
||||
const activeWallet = useActiveWallet();
|
||||
const accountBlobbie = <AccountBlobbie className="size-8 rounded-full" />;
|
||||
const accountAvatarFallback = (
|
||||
<WalletIcon
|
||||
className="size-8 rounded-lg"
|
||||
fallbackComponent={accountBlobbie}
|
||||
loadingComponent={accountBlobbie}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="[&>*]:!w-full grow">
|
||||
<NebulaConnectWallet
|
||||
detailsButtonClassName="!bg-background hover:!border-active-border"
|
||||
customDetailsButton={
|
||||
activeWallet
|
||||
? (address) => {
|
||||
return (
|
||||
<WalletProvider id={activeWallet.id}>
|
||||
<AccountProvider
|
||||
address={address}
|
||||
client={nebulaAppThirdwebClient}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-auto w-full items-center justify-start gap-2.5 rounded-lg px-2 hover:bg-accent"
|
||||
>
|
||||
<AccountAvatar
|
||||
className="size-8 rounded-full"
|
||||
fallbackComponent={accountAvatarFallback}
|
||||
loadingComponent={accountAvatarFallback}
|
||||
/>
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="text-sm">
|
||||
{shortenAddress(address)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{activeWallet.id === "smart" ? (
|
||||
<span> Smart Wallet </span>
|
||||
) : (
|
||||
<WalletName
|
||||
fallbackComponent={<span>Wallet</span>}
|
||||
loadingComponent={<span>Wallet</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</AccountProvider>
|
||||
</WalletProvider>
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +273,7 @@ function ToggleThemeButton() {
|
||||
icon={
|
||||
isClientMounted ? (theme === "light" ? SunIcon : MoonIcon) : Spinner
|
||||
}
|
||||
label="Theme"
|
||||
label="Toggle Theme"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -152,16 +285,22 @@ function SidebarIconLink(props: {
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-2.5 rounded-lg px-2 py-2 text-left text-sm"
|
||||
>
|
||||
<Link href={props.href} target={props.target} prefetch={false}>
|
||||
<props.icon className="size-4" />
|
||||
{props.label}
|
||||
</Link>
|
||||
</Button>
|
||||
<ToolTipLabel label={props.label}>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="size-10 rounded-full p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link
|
||||
href={props.href}
|
||||
target={props.target}
|
||||
prefetch={false}
|
||||
aria-label={props.label}
|
||||
>
|
||||
<props.icon className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,13 +310,15 @@ function SidebarIconButton(props: {
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-2.5 rounded-lg px-2 py-2 text-left text-sm"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<props.icon className="size-4" />
|
||||
{props.label}
|
||||
</Button>
|
||||
<ToolTipLabel label={props.label}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={props.onClick}
|
||||
aria-label={props.label}
|
||||
className="size-10 rounded-full p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<props.icon className="size-5" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,11 +202,11 @@ function RenderMessage(props: {
|
||||
<NebulaImage
|
||||
type="submitted"
|
||||
url={
|
||||
"b64" in msg
|
||||
typeof msg.b64 === "string"
|
||||
? msg.b64.startsWith("data:image")
|
||||
? msg.b64
|
||||
: `data:image/png;base64,${msg.b64}`
|
||||
: msg.image_url
|
||||
: (msg.image_url ?? "")
|
||||
}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ export const NebulaConnectWallet = (props: {
|
||||
connectButtonClassName?: string;
|
||||
signInLinkButtonClassName?: string;
|
||||
detailsButtonClassName?: string;
|
||||
customDetailsButton?: (address: string) => React.ReactElement;
|
||||
}) => {
|
||||
const router = useDashboardRouter();
|
||||
const { theme } = useTheme();
|
||||
@@ -63,6 +64,7 @@ export const NebulaConnectWallet = (props: {
|
||||
);
|
||||
}
|
||||
|
||||
const { customDetailsButton } = props;
|
||||
return (
|
||||
<ConnectButton
|
||||
theme={getSDKTheme(t)}
|
||||
@@ -90,6 +92,9 @@ export const NebulaConnectWallet = (props: {
|
||||
}}
|
||||
detailsButton={{
|
||||
className: props.detailsButtonClassName,
|
||||
render: customDetailsButton
|
||||
? () => customDetailsButton(account.address)
|
||||
: undefined,
|
||||
}}
|
||||
chains={allChainsV5}
|
||||
// we have an AutoConnect already added in root layout with AA configuration
|
||||
|
||||
@@ -55,7 +55,9 @@ export function NebulaImage(
|
||||
height={props.type === "response" ? props.height : undefined}
|
||||
src={src}
|
||||
className="w-full rounded-lg border hover:border-active-border"
|
||||
skeleton={<div className="animate-skeleton bg-muted" />}
|
||||
skeleton={
|
||||
<div className="min-h-[300px] animate-skeleton bg-muted" />
|
||||
}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { storybookThirdwebClient } from "../../../../../stories/utils";
|
||||
import {
|
||||
TransactionSectionUI,
|
||||
type WalletTransaction,
|
||||
} from "./TransactionsSection";
|
||||
|
||||
const meta = {
|
||||
title: "Nebula/TransactionsSection",
|
||||
component: TransactionSectionUI,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof TransactionSectionUI>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const transactionsStub: WalletTransaction[] = [
|
||||
{
|
||||
chain_id: "8453",
|
||||
hash: "0x2a098695dcfa32a67ec115af7c8da1ef6f443ea72baf7e49525204dd521a985e",
|
||||
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
|
||||
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
value: "0",
|
||||
decoded: {
|
||||
name: "transfer",
|
||||
signature: "transfer(address,uint256)",
|
||||
inputs: {
|
||||
to: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
|
||||
value: "10000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: "8453",
|
||||
hash: "0xc521bfa0ba3e68fa1a52c67f93a8e215d3ade0b45956ba215390bcc0576202f1",
|
||||
value: "1000000000000000",
|
||||
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
|
||||
to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
|
||||
decoded: {
|
||||
name: "",
|
||||
signature: "",
|
||||
inputs: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: "8453",
|
||||
hash: "0xf2d92059c9ea425ccf7568bfe2589b3c7e45b108b5af658ec79c2f2d3723e410",
|
||||
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
|
||||
to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
|
||||
value: "1000000000000000",
|
||||
decoded: {
|
||||
name: "",
|
||||
signature: "",
|
||||
inputs: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: "8453",
|
||||
hash: "0xea3da430876c09acfa665450a91edb99fe8dc018864c5dfa3ac53bf265ce8d66",
|
||||
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
|
||||
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
value: "0",
|
||||
decoded: {
|
||||
name: "transfer",
|
||||
signature: "transfer(address,uint256)",
|
||||
inputs: {
|
||||
to: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
|
||||
value: "100000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: "8453",
|
||||
hash: "0xad03e5b350645f2e4cdd066c30b2f6b708aa34bd4d1c5ca20fd81ecfe1656164",
|
||||
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
|
||||
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
value: "0",
|
||||
decoded: {
|
||||
name: "approve",
|
||||
signature: "approve(address,uint256)",
|
||||
inputs: {
|
||||
spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
|
||||
value: "10000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: "8453",
|
||||
hash: "0xd3106aaa4b7e530ac7530c8ea4984eec52670aabaf5969ae6bc8d8246e74c3c0",
|
||||
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
|
||||
to_address: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
|
||||
value: "0",
|
||||
decoded: {
|
||||
name: "initiateTransaction",
|
||||
signature:
|
||||
"initiateTransaction((bytes32,address,uint256,address,address,uint256,address,uint256,bytes,bytes),bytes)",
|
||||
inputs: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: "8453",
|
||||
hash: "0xd284b6e0dd938b4610ff1877c1d4692a8d10d83ec6be24789dc87e1ef4aa4756",
|
||||
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
|
||||
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
value: "0",
|
||||
decoded: {
|
||||
name: "approve",
|
||||
signature: "approve(address,uint256)",
|
||||
inputs: {
|
||||
spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
|
||||
value: "1000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const MultipleAssets: Story = {
|
||||
args: {
|
||||
data: transactionsStub,
|
||||
isPending: false,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleAsset: Story = {
|
||||
args: {
|
||||
data: transactionsStub.slice(0, 1),
|
||||
isPending: false,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyAssets: Story = {
|
||||
args: {
|
||||
data: [],
|
||||
isPending: false,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
data: [],
|
||||
isPending: true,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { ChainIconClient } from "../../../../../components/icons/ChainIcon";
|
||||
import { useAllChainsData } from "../../../../../hooks/chains/allChains";
|
||||
import { nebulaAppThirdwebClient } from "../../utils/nebulaThirdwebClient";
|
||||
|
||||
// Note: this is not the full object type returned from insight API, it only includes fields we care about
|
||||
export type WalletTransaction = {
|
||||
chain_id: string;
|
||||
value: string;
|
||||
hash: string;
|
||||
from_address: string;
|
||||
to_address: string;
|
||||
decoded: {
|
||||
name: string;
|
||||
signature: string;
|
||||
inputs: null | object;
|
||||
};
|
||||
};
|
||||
|
||||
export function TransactionSectionUI(props: {
|
||||
data: WalletTransaction[];
|
||||
isPending: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
if (props.data.length === 0 && !props.isPending) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-2 py-1">
|
||||
<div className="rounded-full border p-1">
|
||||
<XIcon className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">No Recent Activity</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{!props.isPending &&
|
||||
props.data.map((asset) => (
|
||||
<TransactionInfo
|
||||
key={asset.hash}
|
||||
transaction={asset}
|
||||
client={props.client}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.isPending &&
|
||||
new Array(10).fill(null).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<SkeletonAssetItem key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonAssetItem() {
|
||||
return (
|
||||
<div className="flex h-[48px] items-center gap-2 px-2 py-1">
|
||||
<Skeleton className="size-8 rounded-full" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-3 w-32 bg-muted" />
|
||||
<Skeleton className="h-3 w-24 bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionInfo(props: {
|
||||
transaction: WalletTransaction;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const chainMeta = idToChain.get(Number(props.transaction.chain_id));
|
||||
const title = getTransactionTitle(props.transaction);
|
||||
const description = getTransactionDescription(props.transaction);
|
||||
const explorer =
|
||||
chainMeta?.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.transaction.chain_id}`;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[48px] items-center gap-2.5 rounded-lg px-2 py-1 hover:bg-accent">
|
||||
<ChainIconClient
|
||||
client={props.client}
|
||||
className="size-8 rounded-full border"
|
||||
src={chainMeta?.icon?.url || ""}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-col text-sm">
|
||||
<Link
|
||||
href={`${explorer}/tx/${props.transaction.hash}`}
|
||||
target="_blank"
|
||||
className="truncate font-medium capitalize before:absolute before:inset-0"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTransactionTitle(transaction: WalletTransaction) {
|
||||
if (transaction.decoded.name) {
|
||||
return transaction.decoded.name;
|
||||
}
|
||||
|
||||
if (transaction.decoded.signature) {
|
||||
const nameFromSignature = transaction.decoded.signature.split("(")[0];
|
||||
if (nameFromSignature) {
|
||||
return nameFromSignature;
|
||||
}
|
||||
}
|
||||
|
||||
if (transaction.value !== "0") {
|
||||
return "Transfer";
|
||||
}
|
||||
|
||||
return "Transaction Sent";
|
||||
}
|
||||
|
||||
function getTransactionDescription(transaction: WalletTransaction) {
|
||||
if (
|
||||
typeof transaction.decoded.inputs === "object" &&
|
||||
transaction.decoded.inputs !== null
|
||||
) {
|
||||
if (
|
||||
"to" in transaction.decoded.inputs &&
|
||||
typeof transaction.decoded.inputs.to === "string"
|
||||
) {
|
||||
return `To: ${shortenAddress(transaction.decoded.inputs.to)}`;
|
||||
}
|
||||
|
||||
if (
|
||||
"spender" in transaction.decoded.inputs &&
|
||||
typeof transaction.decoded.inputs.spender === "string"
|
||||
) {
|
||||
return `Spender: ${shortenAddress(transaction.decoded.inputs.spender)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `To: ${shortenAddress(transaction.to_address)}`;
|
||||
}
|
||||
|
||||
export function TransactionsSection(props: {
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const account = useActiveAccount();
|
||||
const activeChain = useActiveWalletChain();
|
||||
|
||||
const txQuery = useQuery({
|
||||
queryKey: ["v1/wallets/transactions", account?.address, activeChain?.id],
|
||||
queryFn: async () => {
|
||||
if (!account || !activeChain) {
|
||||
return [];
|
||||
}
|
||||
const chains = [...new Set([1, 8453, 10, 137, activeChain.id])];
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/wallets/${account.address}/transactions`,
|
||||
);
|
||||
url.searchParams.set("limit", "20");
|
||||
url.searchParams.set("decode", "true");
|
||||
url.searchParams.set("clientId", nebulaAppThirdwebClient.clientId);
|
||||
|
||||
const threeMonthsAgoUnixTime = Math.floor(
|
||||
(Date.now() - 3 * 30 * 24 * 60 * 60 * 1000) / 1000,
|
||||
);
|
||||
|
||||
url.searchParams.set(
|
||||
"filter_block_timestamp_gte",
|
||||
`${threeMonthsAgoUnixTime}`,
|
||||
);
|
||||
|
||||
for (const chain of chains) {
|
||||
url.searchParams.append("chain", chain.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const json = (await response.json()) as {
|
||||
data?: WalletTransaction[];
|
||||
};
|
||||
|
||||
return json.data ?? [];
|
||||
},
|
||||
retry: false,
|
||||
enabled: !!account && !!activeChain,
|
||||
});
|
||||
|
||||
return (
|
||||
<TransactionSectionUI
|
||||
data={txQuery.data ?? []}
|
||||
isPending={txQuery.isPending}
|
||||
client={props.client}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,19 @@ export function useSessionsWithLocalOverrides(
|
||||
) {
|
||||
const newAddedSessions = useStore(newSessionsStore);
|
||||
const deletedSessions = useStore(deletedSessionsStore);
|
||||
return [...newAddedSessions, ..._sessions].filter((s) => {
|
||||
const mergedSessions = [..._sessions];
|
||||
|
||||
for (const session of newAddedSessions) {
|
||||
// if adding a new session that has same id as existing session, update the existing session
|
||||
const index = mergedSessions.findIndex((s) => s.id === session.id);
|
||||
if (index !== -1) {
|
||||
mergedSessions[index] = session;
|
||||
} else {
|
||||
mergedSessions.unshift(session);
|
||||
}
|
||||
}
|
||||
|
||||
return mergedSessions.filter((s) => {
|
||||
return !deletedSessions.some((d) => d === s.id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function DateRangeSelector(props: {
|
||||
export function getLastNDaysRange(id: DurationId) {
|
||||
const durationInfo = durationPresets.find((preset) => preset.id === id);
|
||||
if (!durationInfo) {
|
||||
throw new Error("Invalid duration id");
|
||||
throw new Error(`Invalid duration id: ${id}`);
|
||||
}
|
||||
|
||||
const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues
|
||||
|
||||
@@ -179,6 +179,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
!!config.applicationImageUrl?.length || !!config.applicationName?.length;
|
||||
|
||||
const authRequiredPlan = "accelerate";
|
||||
const brandingRequiredPlan = "starter";
|
||||
|
||||
// accelerate or higher plan required
|
||||
const canEditSmsCountries =
|
||||
@@ -275,7 +276,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
form={form}
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
requiredPlan={authRequiredPlan}
|
||||
requiredPlan={brandingRequiredPlan}
|
||||
/>
|
||||
|
||||
<NativeAppsFieldset form={form} />
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { normalizeTimeISOString } from "@/lib/time";
|
||||
import { DateRangeSelector } from "components/analytics/date-range-selector";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
type DurationId,
|
||||
} from "components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "components/analytics/interval-selector";
|
||||
import { getUniversalBridgeFiltersFromSearchParams } from "lib/time";
|
||||
import {
|
||||
@@ -9,28 +13,93 @@ import {
|
||||
useSetResponsiveSearchParams,
|
||||
} from "responsive-rsc";
|
||||
|
||||
const STORAGE_KEY = "thirdweb:ub-analytics-range";
|
||||
|
||||
type SavedRange = {
|
||||
rangeType: "custom" | DurationId | undefined;
|
||||
interval: "day" | "week";
|
||||
};
|
||||
|
||||
type SearchParams = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
interval?: "day" | "week";
|
||||
};
|
||||
|
||||
export function PayAnalyticsFilter() {
|
||||
const responsiveSearchParams = useResponsiveSearchParams();
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
|
||||
// Load saved range from localStorage using useQuery
|
||||
useQuery<SavedRange | null>({
|
||||
queryKey: [
|
||||
"savedRange",
|
||||
responsiveSearchParams.from,
|
||||
responsiveSearchParams.to,
|
||||
],
|
||||
queryFn: () => {
|
||||
const savedRangeString = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedRangeString) {
|
||||
try {
|
||||
const savedRange = JSON.parse(savedRangeString) as SavedRange;
|
||||
// Get the current range based on the saved range type
|
||||
const { range } = getUniversalBridgeFiltersFromSearchParams({
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
interval: savedRange.interval,
|
||||
defaultRange:
|
||||
savedRange.rangeType === "custom"
|
||||
? "last-30"
|
||||
: savedRange.rangeType || "last-30",
|
||||
});
|
||||
|
||||
setResponsiveSearchParams((v) => ({
|
||||
...v,
|
||||
from: normalizeTimeISOString(range.from),
|
||||
to: normalizeTimeISOString(range.to),
|
||||
interval: savedRange.interval,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved range:", e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
enabled: !responsiveSearchParams.from && !responsiveSearchParams.to,
|
||||
});
|
||||
|
||||
const { range, interval } = getUniversalBridgeFiltersFromSearchParams({
|
||||
from: responsiveSearchParams.from,
|
||||
to: responsiveSearchParams.to,
|
||||
interval: responsiveSearchParams.interval,
|
||||
defaultRange: "last-30",
|
||||
});
|
||||
|
||||
const saveToLocalStorage = (params: {
|
||||
rangeType: string;
|
||||
interval: "day" | "week";
|
||||
}) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(params));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
popoverAlign="end"
|
||||
setRange={(newRange) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
setResponsiveSearchParams((v: SearchParams) => {
|
||||
const newParams = {
|
||||
...v,
|
||||
from: normalizeTimeISOString(newRange.from),
|
||||
to: normalizeTimeISOString(newRange.to),
|
||||
};
|
||||
// Save to localStorage
|
||||
saveToLocalStorage({
|
||||
rangeType: newRange.type || "last-30",
|
||||
interval: newParams.interval || "day",
|
||||
});
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -38,11 +107,17 @@ export function PayAnalyticsFilter() {
|
||||
<IntervalSelector
|
||||
intervalType={interval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
setResponsiveSearchParams((v: SearchParams) => {
|
||||
const newParams = {
|
||||
...v,
|
||||
interval: newInterval,
|
||||
};
|
||||
// Save to localStorage
|
||||
saveToLocalStorage({
|
||||
rangeType: range.type || "last-30",
|
||||
interval: newInterval,
|
||||
});
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { useId, useMemo } from "react";
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
|
||||
import type { UniversalBridgeWalletStats } from "types/analytics";
|
||||
import { CardHeading, ChangeBadge, NoDataOverlay, chartHeight } from "./common";
|
||||
import { CardHeading, NoDataOverlay, chartHeight } from "./common";
|
||||
|
||||
type GraphDataItem = {
|
||||
date: string;
|
||||
@@ -20,7 +20,7 @@ export function PayNewCustomers(props: {
|
||||
/**
|
||||
* For each date, compute the total number of wallets that have never existed before in the time series
|
||||
*/
|
||||
const { graphData, trend } = useMemo(() => {
|
||||
const { graphData } = useMemo(() => {
|
||||
const dates = new Set<string>();
|
||||
for (const item of props.data) {
|
||||
if (!dates.has(item.date)) {
|
||||
@@ -47,16 +47,7 @@ export function PayNewCustomers(props: {
|
||||
value: newUsers,
|
||||
});
|
||||
}
|
||||
const lastPeriod = newUsersData[newUsersData.length - 2];
|
||||
const currentPeriod = newUsersData[newUsersData.length - 1];
|
||||
// Calculate the percent change from last period to current period
|
||||
const trend =
|
||||
lastPeriod && currentPeriod && lastPeriod.value > 0
|
||||
? (currentPeriod.value - lastPeriod.value) / lastPeriod.value
|
||||
: lastPeriod?.value === 0
|
||||
? 100
|
||||
: 0;
|
||||
return { graphData: newUsersData, trend };
|
||||
return { graphData: newUsersData };
|
||||
}, [props.data, props.dateFormat]);
|
||||
const isEmpty = useMemo(
|
||||
() => graphData.length === 0 || graphData.every((x) => x.value === 0),
|
||||
@@ -86,17 +77,6 @@ export function PayNewCustomers(props: {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!isEmpty && (
|
||||
<SkeletonContainer
|
||||
loadedData={trend}
|
||||
className="rounded-2xl"
|
||||
skeletonData={1}
|
||||
render={(v) => {
|
||||
return <ChangeBadge percent={v} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +102,7 @@ export function PayNewCustomers(props: {
|
||||
{payload?.date}
|
||||
</p>
|
||||
<p className="text-base text-medium">
|
||||
Customers: {payload?.value}
|
||||
New Customers: {payload?.value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ export function PaymentHistory(props: {
|
||||
});
|
||||
return res;
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
const isEmpty = useMemo(
|
||||
() => !payPurchaseData?.data.length,
|
||||
@@ -68,8 +69,8 @@ export function PaymentHistory(props: {
|
||||
<table className="w-full selection:bg-inverted selection:text-inverted-foreground ">
|
||||
<thead>
|
||||
<TableHeadingRow>
|
||||
<TableHeading> Paid </TableHeading>
|
||||
<TableHeading> Bought </TableHeading>
|
||||
<TableHeading> Sent </TableHeading>
|
||||
<TableHeading> Received </TableHeading>
|
||||
<TableHeading>Type</TableHeading>
|
||||
<TableHeading>Status</TableHeading>
|
||||
<TableHeading>Recipient</TableHeading>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo } from "react";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
|
||||
import type { UniversalBridgeStats } from "types/analytics";
|
||||
import { toUSD } from "../../../../utils/number";
|
||||
import { CardHeading, ChangeBadge, NoDataOverlay, chartHeight } from "./common";
|
||||
import { CardHeading, NoDataOverlay, chartHeight } from "./common";
|
||||
|
||||
type GraphData = {
|
||||
date: string;
|
||||
@@ -24,7 +24,7 @@ export function Payouts(props: {
|
||||
props.data.every((x) => x.developerFeeUsdCents === 0);
|
||||
|
||||
const barColor = isEmpty ? "hsl(var(--accent))" : "hsl(var(--chart-1))";
|
||||
const { graphData, totalPayoutsUSD, trend } = useMemo(() => {
|
||||
const { graphData, totalPayoutsUSD } = useMemo(() => {
|
||||
const dates = new Set<string>();
|
||||
for (const item of props.data) {
|
||||
if (!dates.has(item.date)) {
|
||||
@@ -49,18 +49,9 @@ export function Payouts(props: {
|
||||
value: total / 100,
|
||||
});
|
||||
}
|
||||
const lastPeriod = cleanedData[cleanedData.length - 2];
|
||||
const currentPeriod = cleanedData[cleanedData.length - 1];
|
||||
const trend =
|
||||
lastPeriod && currentPeriod && lastPeriod.value > 0
|
||||
? (currentPeriod.value - lastPeriod.value) / lastPeriod.value
|
||||
: lastPeriod?.value === 0
|
||||
? 100
|
||||
: 0;
|
||||
return {
|
||||
graphData: cleanedData,
|
||||
totalPayoutsUSD: totalPayouts / 100,
|
||||
trend,
|
||||
};
|
||||
}, [props.data, props.dateFormat]);
|
||||
|
||||
@@ -90,17 +81,6 @@ export function Payouts(props: {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!isEmpty && (
|
||||
<SkeletonContainer
|
||||
className="rounded-2xl"
|
||||
loadedData={trend}
|
||||
skeletonData={1}
|
||||
render={(percent) => {
|
||||
return <ChangeBadge percent={percent} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full justify-center">
|
||||
|
||||
@@ -24,11 +24,10 @@ export function TotalPayVolume(props: {
|
||||
};
|
||||
}) {
|
||||
const uniqueId = useId();
|
||||
const [successType, setSuccessType] = useState<"success" | "fail">("success");
|
||||
const [type, setType] = useState<"all" | "crypto" | "fiat">("all");
|
||||
|
||||
const graphData: GraphData[] | undefined = useMemo(() => {
|
||||
let data = (() => {
|
||||
const data = (() => {
|
||||
switch (type) {
|
||||
case "crypto": {
|
||||
return props.data?.filter((x) => x.type === "onchain");
|
||||
@@ -45,13 +44,6 @@ export function TotalPayVolume(props: {
|
||||
}
|
||||
})();
|
||||
|
||||
data = (() => {
|
||||
if (successType === "fail") {
|
||||
return data.filter((x) => x.status === "failed");
|
||||
}
|
||||
return data.filter((x) => x.status === "completed");
|
||||
})();
|
||||
|
||||
const dates = new Set<string>();
|
||||
for (const item of data) {
|
||||
if (!dates.has(item.date)) {
|
||||
@@ -72,15 +64,13 @@ export function TotalPayVolume(props: {
|
||||
});
|
||||
}
|
||||
return cleanedData;
|
||||
}, [props.data, type, successType, props.dateFormat]);
|
||||
}, [props.data, type, props.dateFormat]);
|
||||
|
||||
const isEmpty =
|
||||
graphData.length === 0 || graphData.every((x) => x.value === 0);
|
||||
const chartColor = isEmpty
|
||||
? "hsl(var(--muted-foreground))"
|
||||
: successType === "success"
|
||||
? "hsl(var(--chart-1))"
|
||||
: "hsl(var(--chart-3))";
|
||||
: "hsl(var(--chart-1))";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
@@ -104,21 +94,6 @@ export function TotalPayVolume(props: {
|
||||
<SelectItem value="fiat">Fiat</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={successType}
|
||||
onValueChange={(value: "success" | "fail") => {
|
||||
setSuccessType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-transparent">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="success">Successful</SelectItem>
|
||||
<SelectItem value="fail">Failed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
|
||||
|
||||
export function NoDataOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-card/50 text-muted-foreground text-sm backdrop-blur-sm">
|
||||
@@ -14,39 +10,6 @@ export function CardHeading(props: { children: React.ReactNode }) {
|
||||
return <h3 className="font-medium text-base">{props.children}</h3>;
|
||||
}
|
||||
|
||||
export function ChangeBadge(props: { percent: number }) {
|
||||
const percentValue = `${props.percent.toFixed(0)}%`;
|
||||
let label = "No change compared to prior range";
|
||||
if (props.percent !== 0) {
|
||||
label = `
|
||||
${props.percent >= 0 ? "Increase" : "Decrease"} of ${percentValue} compared to prior range
|
||||
`;
|
||||
}
|
||||
return (
|
||||
<ToolTipLabel label={label}>
|
||||
<div>
|
||||
<Badge
|
||||
variant={props.percent >= 0 ? "success" : "destructive"}
|
||||
className="gap-1 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{props.percent >= 0 ? (
|
||||
<ArrowUpIcon className="size-4 " />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-4" />
|
||||
)}
|
||||
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
signDisplay: "never",
|
||||
}).format(props.percent)}
|
||||
</Badge>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableData({ children }: { children: React.ReactNode }) {
|
||||
return <td className="px-3 py-2 text-sm">{children}</td>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TeamPlanBadge } from "../../../../app/(app)/components/TeamPlanBadge";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import {
|
||||
TeamPlanBadge,
|
||||
getTeamPlanBadgeLabel,
|
||||
} from "../../../../app/(app)/components/TeamPlanBadge";
|
||||
import { planToTierRecordForGating } from "./planToTierRecord";
|
||||
|
||||
type SwitchProps = React.ComponentProps<typeof Switch>;
|
||||
@@ -26,26 +31,40 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
return (
|
||||
<ToolTipLabel
|
||||
hoverable
|
||||
contentClassName="max-w-[300px]"
|
||||
label={
|
||||
isUpgradeRequired ? (
|
||||
<span>
|
||||
To access this feature, <br /> Upgrade to the{" "}
|
||||
<TrackedLinkTW
|
||||
target="_blank"
|
||||
href={`/team/${props.teamSlug}/~/settings/billing`}
|
||||
category="advancedFeature"
|
||||
label={props.trackingLabel}
|
||||
className="text-link-foreground capitalize hover:text-foreground"
|
||||
>
|
||||
{props.requiredPlan} plan
|
||||
</TrackedLinkTW>
|
||||
</span>
|
||||
<div className="w-full min-w-[280px]">
|
||||
<h3 className="font-medium text-base">
|
||||
<span className="capitalize">
|
||||
{getTeamPlanBadgeLabel(props.requiredPlan)}+
|
||||
</span>{" "}
|
||||
plan required
|
||||
</h3>
|
||||
<p className="mb-3.5 text-muted-foreground">
|
||||
Upgrade your plan to use this feature
|
||||
</p>
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button asChild size="sm" className="justify-start gap-2">
|
||||
<TrackedLinkTW
|
||||
target="_blank"
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
category="advancedFeature"
|
||||
label="checkout"
|
||||
>
|
||||
{`Upgrade to ${getTeamPlanBadgeLabel(props.requiredPlan)} plan`}
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{isUpgradeRequired && <TeamPlanBadge plan={props.requiredPlan} />}
|
||||
{isUpgradeRequired && (
|
||||
<TeamPlanBadge plan={props.requiredPlan} postfix="+" />
|
||||
)}
|
||||
<Switch
|
||||
{...props.switchProps}
|
||||
checked={props.switchProps?.checked && !isUpgradeRequired}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface BillingPricingProps {
|
||||
team: Team;
|
||||
trialPeriodEndedAt: string | undefined;
|
||||
getTeam: () => Promise<Team>;
|
||||
highlightPlan: Team["billingPlan"] | undefined;
|
||||
}
|
||||
|
||||
type CtaLink =
|
||||
@@ -49,6 +50,7 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
|
||||
team,
|
||||
trialPeriodEndedAt,
|
||||
getTeam,
|
||||
highlightPlan,
|
||||
}) => {
|
||||
const validTeamPlan = getValidTeamPlan(team);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -65,17 +67,28 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
|
||||
const isCurrentPlanScheduledToCancel = team.planCancellationDate !== null;
|
||||
|
||||
const highlightGrowthPlan =
|
||||
!isCurrentPlanScheduledToCancel &&
|
||||
(validTeamPlan === "free" ||
|
||||
validTeamPlan === "starter" ||
|
||||
validTeamPlan === "growth_legacy");
|
||||
highlightPlan === "growth" ||
|
||||
(!highlightPlan &&
|
||||
!isCurrentPlanScheduledToCancel &&
|
||||
(validTeamPlan === "free" ||
|
||||
validTeamPlan === "starter" ||
|
||||
validTeamPlan === "growth_legacy"));
|
||||
|
||||
const highlightStarterPlan =
|
||||
!isCurrentPlanScheduledToCancel && validTeamPlan === "starter_legacy";
|
||||
highlightPlan === "starter" ||
|
||||
(!highlightPlan &&
|
||||
!isCurrentPlanScheduledToCancel &&
|
||||
validTeamPlan === "starter_legacy");
|
||||
const highlightAcceleratePlan =
|
||||
!isCurrentPlanScheduledToCancel && validTeamPlan === "growth";
|
||||
highlightPlan === "accelerate" ||
|
||||
(!highlightPlan &&
|
||||
!isCurrentPlanScheduledToCancel &&
|
||||
validTeamPlan === "growth");
|
||||
const highlightScalePlan =
|
||||
!isCurrentPlanScheduledToCancel && validTeamPlan === "accelerate";
|
||||
highlightPlan === "scale" ||
|
||||
(!highlightPlan &&
|
||||
!isCurrentPlanScheduledToCancel &&
|
||||
validTeamPlan === "accelerate");
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -13,6 +13,8 @@ interface BillingProps {
|
||||
subscriptions: TeamSubscription[];
|
||||
twAccount: Account;
|
||||
client: ThirdwebClient;
|
||||
openPlanSheetButtonByDefault: boolean;
|
||||
highlightPlan: Team["billingPlan"] | undefined;
|
||||
}
|
||||
|
||||
export const Billing: React.FC<BillingProps> = ({
|
||||
@@ -20,6 +22,8 @@ export const Billing: React.FC<BillingProps> = ({
|
||||
subscriptions,
|
||||
twAccount,
|
||||
client,
|
||||
openPlanSheetButtonByDefault,
|
||||
highlightPlan,
|
||||
}) => {
|
||||
const validPayment =
|
||||
team.billingStatus === "validPayment" || team.billingStatus === "pastDue";
|
||||
@@ -27,7 +31,12 @@ export const Billing: React.FC<BillingProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div>
|
||||
<PlanInfoCardClient team={team} subscriptions={subscriptions} />
|
||||
<PlanInfoCardClient
|
||||
team={team}
|
||||
subscriptions={subscriptions}
|
||||
openPlanSheetButtonByDefault={openPlanSheetButtonByDefault}
|
||||
highlightPlan={highlightPlan}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreditsInfoCard
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import type { DurationId } from "../components/analytics/date-range-selector";
|
||||
|
||||
export function getNebulaFiltersFromSearchParams(params: {
|
||||
from: string | undefined | string[];
|
||||
@@ -17,11 +18,12 @@ export function getUniversalBridgeFiltersFromSearchParams(params: {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
defaultRange: DurationId;
|
||||
}) {
|
||||
return getFiltersFromSearchParams({
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
interval: params.interval,
|
||||
defaultRange: "last-120",
|
||||
defaultRange: params.defaultRange,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "rm -rf .next && next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"format": "biome format ./src --write",
|
||||
@@ -28,7 +28,6 @@
|
||||
"@radix-ui/react-switch": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "1.2.3",
|
||||
"@tanstack/react-query": "5.74.4",
|
||||
"@thirdweb-dev/engine": "0.0.19",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import ThirdwebProvider from "@/components/thirdweb-provider";
|
||||
import { metadataBase } from "@/lib/constants";
|
||||
import type { Metadata } from "next";
|
||||
import { Eip5792GetCapabilitiesPreview } from "../../../../components/account-abstraction/5792-get-capabilities";
|
||||
import { Eip5792SendCallsPreview } from "../../../../components/account-abstraction/5792-send-calls";
|
||||
import { PageLayout } from "../../../../components/blocks/APIHeader";
|
||||
import { CodeExample } from "../../../../components/code/code-example";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase,
|
||||
title: "EIP-5792 Wallet Capabilities | thirdweb Connect",
|
||||
description:
|
||||
"EIP-5792 capabilities allow you to view the capabilities of the connected wallet",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
title="EIP-5792 Wallet Capabilities"
|
||||
description={
|
||||
<>
|
||||
EIP-5792 capabilities allow you to view the capabilities of the
|
||||
connected wallet.
|
||||
</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
|
||||
>
|
||||
<div className="flex flex-col gap-14">
|
||||
<Eip5792GetCapabilities />
|
||||
<Eip5792SendCalls />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Eip5792GetCapabilities() {
|
||||
return (
|
||||
<>
|
||||
<CodeExample
|
||||
header={{
|
||||
title: "Getting the wallet capabilities",
|
||||
description:
|
||||
"Get the capabilities of the connected wallet using the useCapabilities hook",
|
||||
}}
|
||||
preview={<Eip5792GetCapabilitiesPreview />}
|
||||
code={`\
|
||||
import { useCapabilities } from "thirdweb/react";
|
||||
|
||||
function App() {
|
||||
// requires a connected wallet
|
||||
// try metamask or coinbase wallet to view their capabilities
|
||||
// works with in-app wallets too!
|
||||
const capabilities = useCapabilities();
|
||||
console.log(capabilities);
|
||||
|
||||
return <div>Capabilities: {JSON.stringify(capabilities)}</div>;
|
||||
}
|
||||
`}
|
||||
lang="tsx"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Eip5792SendCalls() {
|
||||
return (
|
||||
<>
|
||||
<CodeExample
|
||||
header={{
|
||||
title: "Sending calls to the wallet",
|
||||
description:
|
||||
"Send batched calls to the connected wallet using the useSendCalls hook",
|
||||
}}
|
||||
preview={<Eip5792SendCallsPreview />}
|
||||
lang="tsx"
|
||||
code={`\
|
||||
import { useSendCalls, useWaitForCallsReceipt } from "thirdweb/react";
|
||||
|
||||
function App() {
|
||||
const { mutate: sendCalls, isPending, data } = useSendCalls();
|
||||
const { data: receipt, isLoading: isConfirming } = useWaitForCallsReceipt(data);
|
||||
|
||||
const handleClick = async () => {
|
||||
sendCalls({
|
||||
calls: [firstTransaction, secondTransaction],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={handleClick}>Send calls</button>
|
||||
{isPending && <p>Sending...</p>}
|
||||
{isConfirming && <p>Confirming...</p>}
|
||||
{receipt && <p>Confirmed! {receipt?.receipts?.[0]?.transactionHash}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,11 @@ function Eip7702SmartAccount() {
|
||||
return (
|
||||
<>
|
||||
<CodeExample
|
||||
header={{
|
||||
title: "Turning in-app wallets into EIP-7702 smart accounts",
|
||||
description:
|
||||
"In-app wallets can be turned into EIP-7702 smart accounts by changing the execution mode",
|
||||
}}
|
||||
preview={<Eip7702SmartAccountPreview />}
|
||||
code={`\
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import ThirdwebProvider from "@/components/thirdweb-provider";
|
||||
import { metadataBase } from "@/lib/constants";
|
||||
import type { Metadata } from "next";
|
||||
import { ConnectSmartAccountCustomPreview } from "../../../../components/account-abstraction/connect-smart-account";
|
||||
import { SponsoredTxPreview } from "../../../../components/account-abstraction/sponsored-tx";
|
||||
import { PageLayout } from "../../../../components/blocks/APIHeader";
|
||||
import { CodeExample } from "../../../../components/code/code-example";
|
||||
import { SponsoredInAppTxPreview } from "../../../../components/in-app-wallet/sponsored-tx";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase,
|
||||
@@ -21,7 +23,11 @@ export default function Page() {
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
|
||||
>
|
||||
<SponsoredTx />
|
||||
<div className="flex flex-col gap-14">
|
||||
<SponsoredTx />
|
||||
<SponsoredInAppTx />
|
||||
<ConnectSmartAccount />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
@@ -30,9 +36,15 @@ export default function Page() {
|
||||
function SponsoredTx() {
|
||||
return (
|
||||
<CodeExample
|
||||
header={{
|
||||
title: "Usage with any external wallet",
|
||||
description:
|
||||
"Use the accountAbstraction flag on the ConnectButton or useConnect hook to automatically convert any external wallet to a EIP-4337 smart contract wallet.",
|
||||
}}
|
||||
preview={<SponsoredTxPreview />}
|
||||
code={`import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import { TransactionButton } from "thirdweb/react";
|
||||
code={`\
|
||||
import { useConnect } from "thirdweb/react";
|
||||
import { inAppWallet } from "thirdweb/wallets";
|
||||
|
||||
function App(){
|
||||
return (<>
|
||||
@@ -54,3 +66,93 @@ function SponsoredTx() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsoredInAppTx() {
|
||||
return (
|
||||
<CodeExample
|
||||
header={{
|
||||
title: "Usage with in-app wallets",
|
||||
description:
|
||||
"Set the executionMode when creating your in-app wallet to turn it into a EIP-4337 smart contract wallet. Note that when set, the returned address will be the smart contract wallet address.",
|
||||
}}
|
||||
preview={<SponsoredInAppTxPreview />}
|
||||
code={`
|
||||
import { inAppWallet } from "thirdweb/wallets";
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import {
|
||||
ConnectButton,
|
||||
TransactionButton,
|
||||
} from "thirdweb/react";
|
||||
import { baseSepolia } from "thirdweb/chains";
|
||||
|
||||
const wallets = [
|
||||
inAppWallet(
|
||||
{
|
||||
// turn on gas sponsorship for in-app wallets
|
||||
// Can use EIP4337 or EIP7702 on supported chains
|
||||
executionMode: {
|
||||
mode: "EIP4337",
|
||||
smartAccount: { chain: baseSepolia, sponsorGas: true },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<ConnectButton client={client} wallets={wallets} />
|
||||
|
||||
{/* signless, sponsored transactions */}
|
||||
<TransactionButton
|
||||
transaction={() =>
|
||||
claimTo({
|
||||
contract,
|
||||
to: "0x123...",
|
||||
tokenId: 0n,
|
||||
quantity: 1n,
|
||||
})
|
||||
}
|
||||
>
|
||||
Mint
|
||||
</TransactionButton>
|
||||
</>
|
||||
);
|
||||
}`}
|
||||
lang="tsx"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectSmartAccount() {
|
||||
return (
|
||||
<CodeExample
|
||||
header={{
|
||||
title: "Build custom UI",
|
||||
description: "Build your own UI to connect to 4337 smart accounts",
|
||||
}}
|
||||
preview={<ConnectSmartAccountCustomPreview />}
|
||||
code={`\
|
||||
import { useConnect } from "thirdweb/react";
|
||||
import { inAppWallet } from "thirdweb/wallets";
|
||||
|
||||
const wallet = inAppWallet({
|
||||
executionMode: {
|
||||
mode: "EIP4337",
|
||||
smartAccount: { chain: baseSepolia, sponsorGas: true },
|
||||
},
|
||||
});
|
||||
|
||||
function App(){
|
||||
const { connect } = useConnect();
|
||||
|
||||
return (<>
|
||||
<button onClick={() => connect(async () => {
|
||||
await wallet.connect({ client, strategy: "google" });
|
||||
return adminWallet;
|
||||
})}>Connect with Google</button>
|
||||
</>);
|
||||
};`}
|
||||
lang="tsx"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ function UIIntegration() {
|
||||
const wallets = [
|
||||
inAppWallet(
|
||||
// built-in auth methods
|
||||
// or bring your own auth endpoint
|
||||
{ auth: {
|
||||
options: [
|
||||
"google",
|
||||
@@ -70,8 +71,12 @@ function UIIntegration() {
|
||||
"guest",
|
||||
]
|
||||
}
|
||||
},
|
||||
// optional execution mode, defaults to "EOA"
|
||||
executionMode: {
|
||||
mode: "EIP7702", // or "EIP4337" or "EOA"
|
||||
sponsorGas: true, // sponsor gas for all transactions
|
||||
}
|
||||
// or bring your own auth endpoint
|
||||
)
|
||||
];
|
||||
|
||||
|
||||
@@ -9,16 +9,64 @@ export function useEngineTxStatus(queueId: string | undefined) {
|
||||
if (!queueId) throw new Error("No queue ID provided");
|
||||
const res = await get_engine_tx_status(queueId);
|
||||
|
||||
const txStatus: EngineTxStatus = {
|
||||
queueId: queueId,
|
||||
status: res.result.status,
|
||||
chainId: res.result.chainId,
|
||||
transactionHash: res.result.transactionHash,
|
||||
queuedAt: res.result.queuedAt,
|
||||
sentAt: res.result.sentAt,
|
||||
minedAt: res.result.minedAt,
|
||||
cancelledAt: res.result.cancelledAt,
|
||||
};
|
||||
let txStatus: EngineTxStatus;
|
||||
switch (res.status) {
|
||||
case "QUEUED": {
|
||||
txStatus = {
|
||||
queueId: queueId,
|
||||
status: "queued",
|
||||
chainId: res.chain.id.toString(),
|
||||
transactionHash: null,
|
||||
queuedAt: res.createdAt,
|
||||
sentAt: null,
|
||||
minedAt: null,
|
||||
cancelledAt: null,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "SUBMITTED": {
|
||||
txStatus = {
|
||||
queueId: queueId,
|
||||
status: "sent",
|
||||
chainId: res.chain.id.toString(),
|
||||
transactionHash: null,
|
||||
queuedAt: res.createdAt,
|
||||
sentAt: res.createdAt,
|
||||
minedAt: null,
|
||||
cancelledAt: null,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "CONFIRMED": {
|
||||
txStatus = {
|
||||
queueId: queueId,
|
||||
status: "mined",
|
||||
chainId: res.chain.id.toString(),
|
||||
transactionHash: res.transactionHash,
|
||||
queuedAt: res.createdAt,
|
||||
sentAt: res.confirmedAt,
|
||||
minedAt: res.confirmedAt,
|
||||
cancelledAt: null,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "FAILED": {
|
||||
txStatus = {
|
||||
queueId: queueId,
|
||||
status: "errored",
|
||||
chainId: res.chain.id.toString(),
|
||||
transactionHash: null,
|
||||
queuedAt: res.createdAt,
|
||||
sentAt: null,
|
||||
minedAt: null,
|
||||
cancelledAt: res.cancelledAt,
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown engine tx status: ${res}`);
|
||||
}
|
||||
}
|
||||
|
||||
return txStatus;
|
||||
},
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { Engine } from "@thirdweb-dev/engine";
|
||||
|
||||
import { Engine, defineChain, encode, getContract } from "thirdweb";
|
||||
import { multicall } from "thirdweb/extensions/common";
|
||||
import * as ERC20 from "thirdweb/extensions/erc20";
|
||||
import * as ERC1155 from "thirdweb/extensions/erc1155";
|
||||
import { THIRDWEB_CLIENT } from "../../lib/client";
|
||||
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
|
||||
const ENGINE_VAULT_ACCESS_TOKEN = process.env
|
||||
.ENGINE_VAULT_ACCESS_TOKEN as string;
|
||||
|
||||
const engine = new Engine({
|
||||
url: process.env.ENGINE_URL as string,
|
||||
accessToken: process.env.ENGINE_ACCESS_TOKEN as string,
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: BACKEND_WALLET_ADDRESS,
|
||||
client: THIRDWEB_CLIENT,
|
||||
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
export async function airdrop_tokens_with_engine(params: {
|
||||
@@ -17,20 +23,37 @@ export async function airdrop_tokens_with_engine(params: {
|
||||
amount: string;
|
||||
}[];
|
||||
}) {
|
||||
const res = await engine.erc20.mintBatchTo(
|
||||
params.chainId.toString(),
|
||||
params.contractAddress,
|
||||
BACKEND_WALLET_ADDRESS,
|
||||
{
|
||||
data: params.receivers,
|
||||
},
|
||||
const contract = getContract({
|
||||
address: params.contractAddress,
|
||||
chain: defineChain(params.chainId),
|
||||
client: THIRDWEB_CLIENT,
|
||||
});
|
||||
const data = await Promise.all(
|
||||
params.receivers.map((receiver) =>
|
||||
encode(
|
||||
ERC20.mintTo({
|
||||
contract,
|
||||
to: receiver.toAddress,
|
||||
amount: receiver.amount,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
const tx = multicall({
|
||||
contract,
|
||||
data,
|
||||
});
|
||||
|
||||
return res.result;
|
||||
const res = await serverWallet.enqueueTransaction({ transaction: tx });
|
||||
|
||||
return res.transactionId;
|
||||
}
|
||||
|
||||
export async function get_engine_tx_status(queueId: string) {
|
||||
const status = await engine.transaction.status(queueId);
|
||||
const status = await Engine.getTransactionStatus({
|
||||
client: THIRDWEB_CLIENT,
|
||||
transactionId: queueId,
|
||||
});
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -49,17 +72,19 @@ type MintNFTParams = {
|
||||
};
|
||||
|
||||
export async function mint_erc1155_nft_with_engine(params: MintNFTParams) {
|
||||
const res = await engine.erc1155.mintTo(
|
||||
params.chainId.toString(),
|
||||
params.contractAddress,
|
||||
BACKEND_WALLET_ADDRESS,
|
||||
{
|
||||
receiver: params.toAddress,
|
||||
metadataWithSupply: params.metadataWithSupply,
|
||||
},
|
||||
);
|
||||
const tx = ERC1155.mintTo({
|
||||
contract: getContract({
|
||||
address: params.contractAddress,
|
||||
chain: defineChain(params.chainId),
|
||||
client: THIRDWEB_CLIENT,
|
||||
}),
|
||||
nft: params.metadataWithSupply.metadata,
|
||||
to: params.toAddress,
|
||||
supply: BigInt(params.metadataWithSupply.supply),
|
||||
});
|
||||
const res = await serverWallet.enqueueTransaction({ transaction: tx });
|
||||
|
||||
return res.result;
|
||||
return res.transactionId;
|
||||
}
|
||||
|
||||
type ClaimNFTParams = {
|
||||
@@ -71,16 +96,17 @@ type ClaimNFTParams = {
|
||||
};
|
||||
|
||||
export async function claim_erc1155_nft_with_engine(params: ClaimNFTParams) {
|
||||
const res = await engine.erc1155.claimTo(
|
||||
params.chainId.toString(),
|
||||
params.contractAddress,
|
||||
BACKEND_WALLET_ADDRESS,
|
||||
{
|
||||
receiver: params.receiverAddress,
|
||||
quantity: params.quantity.toString(),
|
||||
tokenId: params.tokenId,
|
||||
},
|
||||
);
|
||||
const tx = ERC1155.claimTo({
|
||||
contract: getContract({
|
||||
address: params.contractAddress,
|
||||
chain: defineChain(params.chainId),
|
||||
client: THIRDWEB_CLIENT,
|
||||
}),
|
||||
to: params.receiverAddress,
|
||||
tokenId: BigInt(params.tokenId),
|
||||
quantity: BigInt(params.quantity),
|
||||
});
|
||||
const res = await serverWallet.enqueueTransaction({ transaction: tx });
|
||||
|
||||
return res.result;
|
||||
return res.transactionId;
|
||||
}
|
||||
|
||||
@@ -34,49 +34,49 @@ export function AirdropCode() {
|
||||
}
|
||||
|
||||
const engineAirdropSendCode = `\
|
||||
const chainId = ${airdropExample.chainId};
|
||||
const contractAddress = "${airdropExample.contractAddress}";
|
||||
const addresses = ${JSON.stringify(
|
||||
airdropExample.receivers.map((x) => ({
|
||||
address: x.toAddress,
|
||||
quantity: x.amount,
|
||||
recipient: x.toAddress,
|
||||
amount: x.amount,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
)};
|
||||
|
||||
const url = \`\${YOUR_ENGINE_URL}\/contract/\${chainId}/\${contractAddress}\/erc1155\/airdrop\`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": "Bearer YOUR_SECRET_TOKEN",
|
||||
"Content-Type": "application/json",
|
||||
"X-Backend-Wallet-Address": "YOUR_BACKEND_WALLET_ADDRESS",
|
||||
},
|
||||
body: JSON.stringify({ addresses }),
|
||||
const contract = getContract({
|
||||
address: ${airdropExample.contractAddress},
|
||||
chain: defineChain(${airdropExample.chainId}),
|
||||
client: THIRDWEB_CLIENT,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const queueId = data.queueId;
|
||||
const transaction = airdropERC20({
|
||||
contract,
|
||||
tokenAddress: ${airdropExample.contractAddress},
|
||||
contents: addresses,
|
||||
});
|
||||
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: BACKEND_WALLET_ADDRESS,
|
||||
client: THIRDWEB_CLIENT,
|
||||
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const { transactionId } = await serverWallet.enqueueTransaction({ transaction });
|
||||
`;
|
||||
|
||||
const engineAirdropGetStatus = `\
|
||||
function getEngineTxStatus(queueId: string) {
|
||||
const url = \`\${YOUR_ENGINE_URL}\/transaction/\${queueId}\`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": "Bearer YOUR_SECRET_TOKEN",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.result;
|
||||
}
|
||||
|
||||
// you can keep polling for the status until you get a status of either "mined" or "errored" or "cancelled"
|
||||
const result = await getEngineTxStatus(queueId);
|
||||
const result = await Engine.getTransactionStatus({
|
||||
client: THIRDWEB_CLIENT,
|
||||
transactionId: transactionId,
|
||||
});
|
||||
|
||||
console.log(result.status);
|
||||
|
||||
// or wait for the transaction to be mined (polls status until it's mined)
|
||||
const result = await Engine.waitForTransactionHash({
|
||||
client: THIRDWEB_CLIENT,
|
||||
transactionId: transactionId,
|
||||
});
|
||||
|
||||
console.log(result.transactionHash);
|
||||
`;
|
||||
|
||||
@@ -32,10 +32,10 @@ export function EngineAirdropPreview() {
|
||||
const res = await airdropMutation.mutateAsync();
|
||||
updateEngineTxStatus({
|
||||
chainId: airdropExample.chainId,
|
||||
queueId: res.queueId,
|
||||
queueId: res,
|
||||
});
|
||||
|
||||
setQueueId(res.queueId);
|
||||
setQueueId(res);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const airdropExample = {
|
||||
contractAddress: "0xcB30dB8FB977e8b27ae34c86aF16C4F5E428c0bE",
|
||||
contractAddress: "0x6E238275023A2575136CF60f655B6B2C0C58b4ac",
|
||||
chainId: 84532,
|
||||
chainName: "Base Sepolia",
|
||||
chainExplorer: "https://base-sepolia.blockscout.com",
|
||||
|
||||
@@ -13,7 +13,7 @@ export function MintCode() {
|
||||
|
||||
<div className="h-4" />
|
||||
<h3 className="mb-2 font-semibold text-xl tracking-tight">
|
||||
Send Transaction Request to Mint NFTs
|
||||
Send Transaction Request to Mint Dynamic NFTs
|
||||
</h3>
|
||||
<Code code={engineMintCode} lang="typescript" />
|
||||
|
||||
@@ -36,48 +36,45 @@ export function MintCode() {
|
||||
const engineMintCode = `\
|
||||
const chainId = ${mintExample.chainId};
|
||||
const contractAddress = "${mintExample.contractAddress}";
|
||||
const url = \`\${YOUR_ENGINE_URL}\/contract/\${chainId}/\${contractAddress}\/erc1155\/mint-to\`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": "Bearer YOUR_SECRET_TOKEN",
|
||||
"Content-Type": "application/json",
|
||||
"X-Backend-Wallet-Address": "YOUR_BACKEND_WALLET_ADDRESS",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
receiver: "0x....",
|
||||
metadataWithSupply: {
|
||||
metadata: {
|
||||
name: "...",
|
||||
description: "...",
|
||||
image: "...", // ipfs or https link to your asset
|
||||
},
|
||||
supply: "1",
|
||||
},
|
||||
})
|
||||
const contract = getContract({
|
||||
address: ${mintExample.contractAddress},
|
||||
chain: defineChain(${mintExample.chainId}),
|
||||
client: THIRDWEB_CLIENT,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data.queueId);
|
||||
const transaction = mintTo({
|
||||
contract,
|
||||
to: "0x....",
|
||||
nft: {
|
||||
name: "...",
|
||||
description: "...",
|
||||
image: "...", // ipfs or https link to your asset
|
||||
},
|
||||
supply: "1",
|
||||
});
|
||||
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: BACKEND_WALLET_ADDRESS,
|
||||
client: THIRDWEB_CLIENT,
|
||||
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const { transactionId } = await serverWallet.enqueueTransaction({ transaction });
|
||||
`;
|
||||
|
||||
const getEngineStatusCode = `\
|
||||
function getEngineTxStatus(queueId: string) {
|
||||
const url = \`\${YOUR_ENGINE_URL}\/transaction/\${queueId}\`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": "Bearer YOUR_SECRET_TOKEN",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.result;
|
||||
}
|
||||
|
||||
// you can keep polling for the status until you get a status of either "mined" or "errored" or "cancelled"
|
||||
const result = await getEngineTxStatus(queueId);
|
||||
const result = await Engine.getTransactionStatus({
|
||||
client: THIRDWEB_CLIENT,
|
||||
transactionId: transactionId,
|
||||
});
|
||||
|
||||
console.log(result.status);
|
||||
|
||||
// or wait for the transaction to be mined (polls status until it's mined)
|
||||
const result = await Engine.waitForTransactionHash({
|
||||
client: THIRDWEB_CLIENT,
|
||||
transactionId: transactionId,
|
||||
});
|
||||
|
||||
console.log(result.transactionHash);
|
||||
`;
|
||||
|
||||
@@ -231,10 +231,10 @@ export function EngineMintPreview() {
|
||||
});
|
||||
|
||||
// optimistic update
|
||||
queryClient.setQueryData(["engineTxStatus", result.queueId], {
|
||||
queryClient.setQueryData(["engineTxStatus", result], {
|
||||
status: "queued",
|
||||
chainId: mintExample.chainId.toString(),
|
||||
queueId: result.queueId,
|
||||
queueId: result,
|
||||
transactionHash: null,
|
||||
queuedAt: new Date().toISOString(),
|
||||
sentAt: null,
|
||||
@@ -242,7 +242,7 @@ export function EngineMintPreview() {
|
||||
cancelledAt: null,
|
||||
} satisfies EngineTxStatus);
|
||||
|
||||
setQueueId(result.queueId);
|
||||
setQueueId(result);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const mintExample = {
|
||||
contractAddress: "0x8CD193648f5D4E8CD9fD0f8d3865052790A680f6",
|
||||
contractAddress: "0x8a4E14591088bBce4a4Dcfa677C1af78d336d6FB",
|
||||
chainId: 84532,
|
||||
chainName: "Base Sepolia",
|
||||
chainExplorer: "https://base-sepolia.blockscout.com",
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function Page() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
title="Mint NFTs"
|
||||
title="Mint Dynamic NFTs"
|
||||
description={
|
||||
<>
|
||||
Allow your users to mint new tokens into any given contract. You
|
||||
|
||||
@@ -71,7 +71,7 @@ export function EngineWebhooksPreview() {
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const res = await claimMutation.mutateAsync(values.receiverAddress);
|
||||
setQueueId(res.queueId);
|
||||
setQueueId(res);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,62 +7,40 @@ const staticSidebarLinks: SidebarLink[] = [
|
||||
isCollapsible: false,
|
||||
links: [
|
||||
{
|
||||
name: "Connect Wallet",
|
||||
expanded: false,
|
||||
links: [
|
||||
{
|
||||
name: "Button",
|
||||
href: "/connect/sign-in/button",
|
||||
},
|
||||
{
|
||||
name: "Embed",
|
||||
href: "/connect/sign-in/embed",
|
||||
},
|
||||
{
|
||||
name: "Headless",
|
||||
href: "/connect/sign-in/headless",
|
||||
},
|
||||
],
|
||||
name: "ConnectButton",
|
||||
href: "/connect/sign-in/button",
|
||||
},
|
||||
{
|
||||
name: "In-App Wallet",
|
||||
expanded: false,
|
||||
links: [
|
||||
{
|
||||
name: "Any Auth",
|
||||
href: "/connect/in-app-wallet",
|
||||
},
|
||||
{
|
||||
name: "Ecosystems",
|
||||
href: "/connect/in-app-wallet/ecosystem",
|
||||
},
|
||||
{
|
||||
name: "Sponsor Gas",
|
||||
href: "/connect/in-app-wallet/sponsor",
|
||||
},
|
||||
],
|
||||
name: "ConnectEmbed",
|
||||
href: "/connect/sign-in/embed",
|
||||
},
|
||||
{
|
||||
name: "Account Abstraction",
|
||||
expanded: false,
|
||||
links: [
|
||||
{
|
||||
name: "Connect",
|
||||
href: "/connect/account-abstraction/connect",
|
||||
},
|
||||
{
|
||||
name: "EIP-4337",
|
||||
href: "/connect/account-abstraction/sponsor",
|
||||
},
|
||||
{
|
||||
name: "EIP-7702",
|
||||
href: "/connect/account-abstraction/7702",
|
||||
},
|
||||
{
|
||||
name: "Native (zkSync)",
|
||||
href: "/connect/account-abstraction/native-aa",
|
||||
},
|
||||
],
|
||||
name: "Headless Connect",
|
||||
href: "/connect/sign-in/headless",
|
||||
},
|
||||
{
|
||||
name: "In-App Wallets",
|
||||
href: "/connect/in-app-wallet",
|
||||
},
|
||||
{
|
||||
name: "Ecosystem Wallets",
|
||||
href: "/connect/in-app-wallet/ecosystem",
|
||||
},
|
||||
{
|
||||
name: "EIP-4337",
|
||||
href: "/connect/account-abstraction/sponsor",
|
||||
},
|
||||
{
|
||||
name: "EIP-7702",
|
||||
href: "/connect/account-abstraction/7702",
|
||||
},
|
||||
{
|
||||
name: "EIP-5792",
|
||||
href: "/connect/account-abstraction/5792",
|
||||
},
|
||||
{
|
||||
name: "Native AA (zkSync)",
|
||||
href: "/connect/account-abstraction/native-aa",
|
||||
},
|
||||
{
|
||||
name: "Auth",
|
||||
@@ -77,7 +55,7 @@ const staticSidebarLinks: SidebarLink[] = [
|
||||
href: "/connect/blockchain-api",
|
||||
},
|
||||
{
|
||||
name: "Headless UI",
|
||||
name: "Headless Components",
|
||||
expanded: false,
|
||||
links: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import {} from "thirdweb/extensions/erc1155";
|
||||
import {
|
||||
ConnectButton,
|
||||
useActiveWallet,
|
||||
useCapabilities,
|
||||
useWalletInfo,
|
||||
} from "thirdweb/react";
|
||||
import { createWallet } from "thirdweb/wallets";
|
||||
import { THIRDWEB_CLIENT } from "../../lib/client";
|
||||
import CodeClient from "../code/code.client";
|
||||
|
||||
export function Eip5792GetCapabilitiesPreview() {
|
||||
const capabilities = useCapabilities();
|
||||
const activeWallet = useActiveWallet();
|
||||
const walletInfo = useWalletInfo(activeWallet?.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
{capabilities.isLoading ? (
|
||||
<div className="mt-24 w-full">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col justify-center gap-2 p-2">
|
||||
<ConnectButton
|
||||
client={THIRDWEB_CLIENT}
|
||||
chain={sepolia}
|
||||
wallets={[
|
||||
createWallet("io.metamask"),
|
||||
createWallet("com.coinbase.wallet"),
|
||||
]}
|
||||
connectButton={{
|
||||
label: "Login to view wallet capabilities",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-lg">
|
||||
{walletInfo.data?.name || "Wallet"} Capabilities
|
||||
</p>
|
||||
{capabilities.data ? (
|
||||
<CodeClient
|
||||
code={JSON.stringify(capabilities.data, null, 2)}
|
||||
lang="json"
|
||||
loader={<div className="mt-24 w-full">Loading...</div>}
|
||||
className="max-h-[500px] w-[400px] overflow-y-auto"
|
||||
scrollableContainerClassName="h-full"
|
||||
scrollableClassName="h-full"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
import { getContract } from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import { getNFT, getOwnedNFTs } from "thirdweb/extensions/erc1155";
|
||||
import {
|
||||
ConnectButton,
|
||||
MediaRenderer,
|
||||
useActiveAccount,
|
||||
useReadContract,
|
||||
useSendCalls,
|
||||
useWaitForCallsReceipt,
|
||||
} from "thirdweb/react";
|
||||
import { shortenHex } from "thirdweb/utils";
|
||||
import { createWallet } from "thirdweb/wallets";
|
||||
import { THIRDWEB_CLIENT } from "../../lib/client";
|
||||
import { Spinner } from "../ui/Spinner/Spinner";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../ui/card";
|
||||
|
||||
const chain = sepolia;
|
||||
|
||||
const editionDropContract = getContract({
|
||||
address: "0x7B3e0B8353Ad5cD6C60355B50550F63335752f9F",
|
||||
chain,
|
||||
client: THIRDWEB_CLIENT,
|
||||
});
|
||||
|
||||
const editionDropContract2 = getContract({
|
||||
address: "0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8",
|
||||
chain,
|
||||
client: THIRDWEB_CLIENT,
|
||||
});
|
||||
|
||||
export function Eip5792SendCallsPreview() {
|
||||
const activeEOA = useActiveAccount();
|
||||
const { data: nft, isLoading: isNftLoading } = useReadContract(getNFT, {
|
||||
contract: editionDropContract2,
|
||||
tokenId: 0n,
|
||||
});
|
||||
const { data: nft2, isLoading: isNft2Loading } = useReadContract(getNFT, {
|
||||
contract: editionDropContract,
|
||||
tokenId: 1n,
|
||||
});
|
||||
const { data: ownedNfts } = useReadContract(getOwnedNFTs, {
|
||||
contract: editionDropContract,
|
||||
useIndexer: false,
|
||||
// biome-ignore lint/style/noNonNullAssertion: handled by queryOptions
|
||||
address: activeEOA?.address!,
|
||||
queryOptions: { enabled: !!activeEOA },
|
||||
});
|
||||
const { data: ownedNfts2 } = useReadContract(getOwnedNFTs, {
|
||||
contract: editionDropContract2,
|
||||
useIndexer: false,
|
||||
// biome-ignore lint/style/noNonNullAssertion: handled by queryOptions
|
||||
address: activeEOA?.address!,
|
||||
queryOptions: { enabled: !!activeEOA },
|
||||
});
|
||||
|
||||
const sendCalls = useSendCalls();
|
||||
const results = useWaitForCallsReceipt(sendCalls.data);
|
||||
|
||||
const txHash = results.data?.receipts?.[0]?.transactionHash;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-md flex-col items-center justify-center gap-4">
|
||||
{isNftLoading || isNft2Loading ? (
|
||||
<Card className="w-full">
|
||||
<CardContent className="flex items-center justify-center pt-6">
|
||||
<Spinner className="h-8 w-8 text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-full">
|
||||
{!activeEOA && (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="pt-6">
|
||||
<ConnectButton
|
||||
client={THIRDWEB_CLIENT}
|
||||
chain={sepolia}
|
||||
wallets={[
|
||||
createWallet("io.metamask"),
|
||||
createWallet("com.coinbase.wallet"),
|
||||
]}
|
||||
connectButton={{
|
||||
label: "Login to mint!",
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Mint 2 NFTs at once</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-row items-center justify-center gap-6">
|
||||
<div className="flex flex-col items-center">
|
||||
{nft && (
|
||||
<>
|
||||
<MediaRenderer
|
||||
client={THIRDWEB_CLIENT}
|
||||
src={nft.metadata.image}
|
||||
className="h-32 w-32 rounded-lg"
|
||||
alt={nft.metadata.name || "NFT Image"}
|
||||
/>
|
||||
<p className="mt-2 font-medium text-sm">
|
||||
{nft.metadata.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Owned:{" "}
|
||||
{ownedNfts2?.[0]?.quantityOwned.toString() || "0"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 w-8 items-center justify-center">
|
||||
<span className="font-bold text-3xl">+</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
{nft2 && (
|
||||
<>
|
||||
<MediaRenderer
|
||||
client={THIRDWEB_CLIENT}
|
||||
src={nft2.metadata.image}
|
||||
className="h-32 w-32 rounded-lg"
|
||||
alt={nft2.metadata.name || "NFT Image"}
|
||||
/>
|
||||
<p className="mt-2 font-medium text-sm">
|
||||
{nft2.metadata.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Owned:{" "}
|
||||
{ownedNfts?.[0]?.quantityOwned.toString() || "0"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-center">
|
||||
{activeEOA && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await sendCalls.mutateAsync({
|
||||
calls: [
|
||||
claimTo({
|
||||
contract: editionDropContract2,
|
||||
tokenId: 0n,
|
||||
to: activeEOA.address,
|
||||
quantity: 1n,
|
||||
}),
|
||||
claimTo({
|
||||
contract: editionDropContract,
|
||||
tokenId: 1n,
|
||||
to: activeEOA.address,
|
||||
quantity: 1n,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}}
|
||||
disabled={sendCalls.isPending || results.isLoading}
|
||||
>
|
||||
{results.isLoading ? (
|
||||
"Confirming..."
|
||||
) : sendCalls.isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4 text-current" />
|
||||
Minting...
|
||||
</>
|
||||
) : (
|
||||
"Batch Mint with EIP-5792"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{sendCalls.data && results.isLoading && (
|
||||
<Card className="mt-4 w-full">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Spinner className="h-5 w-5 text-primary" />
|
||||
<p className="text-center text-sm">
|
||||
Waiting for transaction to be confirmed...
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{txHash && (
|
||||
<Card className="mt-4 w-full">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="font-medium text-green-500">
|
||||
Transaction Confirmed!
|
||||
</p>
|
||||
<a
|
||||
href={`${chain.blockExplorers?.[0]?.url}/tx/${txHash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-primary text-sm hover:underline"
|
||||
>
|
||||
{shortenHex(txHash)}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-1"
|
||||
role="img"
|
||||
aria-label="Open in Explorer"
|
||||
>
|
||||
<path
|
||||
d="M18 13V19C18 19.5304 17.7893 20.0391 17.4142 20.4142C17.0391 20.7893 16.5304 21 16 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V8C3 7.46957 3.21071 6.96086 3.58579 6.58579C3.96086 6.21071 4.46957 6 5 6H11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 3H21V9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 14L21 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { getContract } from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { baseSepolia } from "thirdweb/chains";
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import { getNFT, getOwnedNFTs } from "thirdweb/extensions/erc1155";
|
||||
import {
|
||||
@@ -10,15 +10,18 @@ import {
|
||||
MediaRenderer,
|
||||
TransactionButton,
|
||||
useActiveAccount,
|
||||
useActiveWallet,
|
||||
useDisconnect,
|
||||
useReadContract,
|
||||
} from "thirdweb/react";
|
||||
import { shortenHex } from "thirdweb/utils";
|
||||
import { inAppWallet } from "thirdweb/wallets/in-app";
|
||||
import { THIRDWEB_CLIENT } from "../../lib/client";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const chain = sepolia;
|
||||
const editionDropAddress = "0x7B3e0B8353Ad5cD6C60355B50550F63335752f9F";
|
||||
const editionDropTokenId = 1n;
|
||||
const chain = baseSepolia;
|
||||
const editionDropAddress = "0x638263e3eAa3917a53630e61B1fBa685308024fa";
|
||||
const editionDropTokenId = 2n;
|
||||
|
||||
const editionDropContract = getContract({
|
||||
address: editionDropAddress,
|
||||
@@ -36,6 +39,8 @@ const iaw = inAppWallet({
|
||||
export function Eip7702SmartAccountPreview() {
|
||||
const [txHash, setTxHash] = useState<string | null>(null);
|
||||
const activeEOA = useActiveAccount();
|
||||
const activeWallet = useActiveWallet();
|
||||
const { disconnect } = useDisconnect();
|
||||
const { data: nft, isLoading: isNftLoading } = useReadContract(getNFT, {
|
||||
contract: editionDropContract,
|
||||
tokenId: editionDropTokenId,
|
||||
@@ -48,6 +53,21 @@ export function Eip7702SmartAccountPreview() {
|
||||
queryOptions: { enabled: !!activeEOA },
|
||||
});
|
||||
|
||||
if (activeEOA && activeWallet && activeWallet?.id !== iaw.id) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
Please connect with an in-app wallet for this example
|
||||
<Button
|
||||
onClick={() => {
|
||||
disconnect(activeWallet);
|
||||
}}
|
||||
>
|
||||
Disconnect current wallet
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
{isNftLoading ? (
|
||||
@@ -57,7 +77,7 @@ export function Eip7702SmartAccountPreview() {
|
||||
<div className="flex flex-col justify-center gap-2 p-2">
|
||||
<ConnectButton
|
||||
client={THIRDWEB_CLIENT}
|
||||
chain={sepolia}
|
||||
chain={chain}
|
||||
wallets={[iaw]}
|
||||
connectButton={{
|
||||
label: "Login to mint!",
|
||||
|
||||
@@ -117,6 +117,21 @@ export const connectLinks: Array<{
|
||||
},
|
||||
] as const;
|
||||
|
||||
const apisLinks = [
|
||||
{
|
||||
name: "Insight",
|
||||
href: "https://insight-api.thirdweb.com/reference",
|
||||
},
|
||||
{
|
||||
name: "Engine Cloud",
|
||||
href: "https://engine.thirdweb.com/reference#tag/write",
|
||||
},
|
||||
{
|
||||
name: "Universal Bridge",
|
||||
href: "https://bridge.thirdweb.com/reference",
|
||||
},
|
||||
];
|
||||
|
||||
const supportLinks = [
|
||||
{
|
||||
name: "Get thirdweb support",
|
||||
@@ -221,7 +236,15 @@ export function Header() {
|
||||
<DocSearch variant="search" />
|
||||
</div>
|
||||
|
||||
<div className="xl:px-2">
|
||||
<div className="xl:px-1">
|
||||
<DropdownLinks
|
||||
links={apisLinks}
|
||||
onLinkClick={() => setShowBurgerMenu(false)}
|
||||
category="APIs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xl:px-1">
|
||||
<DropdownLinks
|
||||
links={supportLinks}
|
||||
onLinkClick={() => setShowBurgerMenu(false)}
|
||||
|
||||
@@ -95,7 +95,7 @@ You can specify which onramp provider to present to your users. By default, we c
|
||||
client={client}
|
||||
detailsModal={{
|
||||
payOptions: {
|
||||
preferredProvider: "STRIPE" | "KADO" | "TRANSAK",
|
||||
preferredProvider: "STRIPE" | "TRANSAK" | "COINBASE",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user