Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f8da8fab | ||
|
|
da3fc99c0b | ||
|
|
fb4c190eb3 | ||
|
|
1c31a1e4df | ||
|
|
04c10f82fd | ||
|
|
ab173886ac | ||
|
|
df06bec699 | ||
|
|
6000dacfbb | ||
|
|
fc6535069b | ||
|
|
b15c3c0747 | ||
|
|
1985de89aa | ||
|
|
2b87b7bb75 | ||
|
|
d99a985ab4 | ||
|
|
e74574ccf5 | ||
|
|
98d05d6fb6 | ||
|
|
f4ac3c2a3a | ||
|
|
a3a4bf4361 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+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: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -45,6 +45,8 @@ 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 +75,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
|
||||
@@ -554,6 +557,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 +733,7 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//zkCandy
|
||||
//zkCandy Testnet
|
||||
302: {
|
||||
headerImgUrl: thirdwebBanner.src,
|
||||
about:
|
||||
@@ -730,6 +745,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>
|
||||
);
|
||||
|
||||
+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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ export default async function Page(props: {
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
interval: searchParams.interval,
|
||||
defaultRange: "last-30",
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
+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";
|
||||
|
||||
@@ -82,7 +82,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 +254,7 @@ export function ChatBar(props: {
|
||||
<ImageUploadButton
|
||||
multiple
|
||||
value={undefined}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={(files) => {
|
||||
const totalFiles = files.length + images.length;
|
||||
|
||||
@@ -479,7 +484,19 @@ function WalletSelector(props: {
|
||||
<AccountBlobbie className="size-3 rounded-full" />
|
||||
}
|
||||
/>
|
||||
{shortenAddress(props.selectedAddress)}
|
||||
<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>
|
||||
</Button>
|
||||
@@ -528,12 +545,12 @@ function WalletSelector(props: {
|
||||
<AccountName
|
||||
className="text-sm"
|
||||
loadingComponent={
|
||||
<span className="font-mono text-sm">
|
||||
<span className="text-sm">
|
||||
{shortenAddress(wallet.address)}
|
||||
</span>
|
||||
}
|
||||
fallbackComponent={
|
||||
<span className="font-mono text-sm">
|
||||
<span className="text-sm">
|
||||
{shortenAddress(wallet.address)}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,7 +87,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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.push(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,91 @@ import {
|
||||
useSetResponsiveSearchParams,
|
||||
} from "responsive-rsc";
|
||||
|
||||
const STORAGE_KEY = "thirdweb:ub-analytics-range";
|
||||
|
||||
type SavedRange = {
|
||||
rangeType: string;
|
||||
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 || "last-30") as DurationId,
|
||||
});
|
||||
|
||||
setResponsiveSearchParams((v) => ({
|
||||
...v,
|
||||
from: normalizeTimeISOString(range.from),
|
||||
to: normalizeTimeISOString(range.to),
|
||||
interval: savedRange.interval,
|
||||
}));
|
||||
} catch (e) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
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 +105,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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ const walletActions: SidebarLink = (() => {
|
||||
href: `${parentSlug}/switchnetwork`,
|
||||
},
|
||||
{
|
||||
name: "SignAuthorization (Experimental)",
|
||||
name: "SignAuthorization",
|
||||
href: `${parentSlug}/signauthorization`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ The design is akin to letting us know what your intent is.
|
||||
- Buy: "I want to buy x USDC on y Chain using z Token"
|
||||
- Sell: "I want to sell x USDC on y Chain for z Token"
|
||||
- Transfer: "Just transfer all my money to vitalik"
|
||||
- Onramp: "I want to buy x USDC on y Chain by paying with card"
|
||||
|
||||
We will return the transactions needed to achieve whatever you desire.
|
||||
You may then handle execution yourself or use our extensions.
|
||||
@@ -127,4 +128,43 @@ Console.WriteLine($"Sell hashes: {JsonConvert.SerializeObject(sellHashes, Format
|
||||
var transferResult = await bridge.Execute(myWallet, preparedTransfer);
|
||||
var transferHashes = transferResult.Select(receipt => receipt.TransactionHash).ToList();
|
||||
Console.WriteLine($"Transfer hashes: {JsonConvert.SerializeObject(transferHashes, Formatting.Indented)}");
|
||||
```
|
||||
|
||||
## Onramping with Fiat
|
||||
The onramp flow will return a link for you to display/open as you please. You may poll the status of that onramp by its ID.
|
||||
In some cases, you may receive an additional set of onchain steps required to get to your destination token post on-ramp, in such cases, you may use our extension `IsSwapRequiredPostOnramp` to check, and if a swap is indeed required, you may use our `Execute` extensions to execute the transactions, or manually execute them by going through each `Step`.
|
||||
|
||||
```csharp
|
||||
// Onramp - Get a quote for buying crypto with Fiat
|
||||
var preparedOnramp = await bridge.Onramp_Prepare(
|
||||
onramp: OnrampProvider.Coinbase,
|
||||
chainId: 8453,
|
||||
tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
|
||||
amount: "10000000",
|
||||
receiver: await myWallet.GetAddress()
|
||||
);
|
||||
Console.WriteLine($"Onramp link: {preparedOnramp.Link}");
|
||||
Console.WriteLine($"Full onramp quote and steps data: {JsonConvert.SerializeObject(preparedOnramp, Formatting.Indented)}");
|
||||
|
||||
while (true)
|
||||
{
|
||||
var onrampStatus = await bridge.Onramp_Status(id: preparedOnramp.Id);
|
||||
Console.WriteLine($"Full Onramp Status: {JsonConvert.SerializeObject(onrampStatus, Formatting.Indented)}");
|
||||
if (onrampStatus.StatusType is StatusType.COMPLETED or StatusType.FAILED)
|
||||
{
|
||||
break;
|
||||
}
|
||||
await ThirdwebTask.Delay(5000);
|
||||
}
|
||||
|
||||
if (preparedOnramp.IsSwapRequiredPostOnramp())
|
||||
{
|
||||
// Execute additional steps that are required post-onramp to get to your token, manually or via the Execute extension
|
||||
var receipts = await bridge.Execute(myWallet, preparedOnramp);
|
||||
Console.WriteLine($"Onramp receipts: {JsonConvert.SerializeObject(receipts, Formatting.Indented)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No additional steps required post-onramp, you can use the tokens directly!");
|
||||
}
|
||||
```
|
||||
@@ -5,96 +5,37 @@ export const metadata = createMetadata({
|
||||
description: "Sign an EIP-7702 Payload to Set Code to your EOA.",
|
||||
});
|
||||
|
||||
# [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) Integration (Experimental)
|
||||
# [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) Integration (Low-level API)
|
||||
Integrates `authorizationList` for any transactions.
|
||||
This EIP essentially allows you to set code to an EOA, unlocking a world of possibilities to enhance their functionality.
|
||||
|
||||
The best way to understand it outside of reading the EIP is looking at the example below; to preface it: we sign an authorization using the wallet we want to set code to. Another wallet sends a transaction with said authorization passed in, essentially activating it. The authority wallet now has code set to it pointing to an (insecure) [Delegation](https://thirdweb.com/odyssey-911867/0x654F42b74885EE6803F403f077bc0409f1066c58) contract in this case, which allows any wallet to execute any call through it on behalf of the authority. In this example, we call the wallet executing both the authorization and the claim transaction afterwards, the exectuor.
|
||||
|
||||
An authority may execute its own authorization, the only difference is internal whereby the authorization nonce is incremented by 1.
|
||||
|
||||
You almost never need to use this API directly, but it's available for low-level access. Use higher level [Account Abstraction](/dotnet/wallets/providers/account-abstraction) APIs instead.
|
||||
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
// Chain and contract addresses
|
||||
var chainWith7702 = 911867;
|
||||
var erc20ContractAddress = "0xAA462a5BE0fc5214507FDB4fB2474a7d5c69065b"; // Fake ERC20
|
||||
var delegationContractAddress = "0x654F42b74885EE6803F403f077bc0409f1066c58"; // BatchCallDelegation
|
||||
|
||||
// Initialize contracts normally
|
||||
var erc20Contract = await ThirdwebContract.Create(client: client, address: erc20ContractAddress, chain: chainWith7702);
|
||||
var delegationContract = await ThirdwebContract.Create(client: client, address: delegationContractAddress, chain: chainWith7702);
|
||||
|
||||
// Initialize a (to-be) 7702 EOA
|
||||
var eoaWallet = await PrivateKeyWallet.Generate(client);
|
||||
var eoaWalletAddress = await eoaWallet.GetAddress();
|
||||
Console.WriteLine($"EOA address: {eoaWalletAddress}");
|
||||
|
||||
// Initialize another wallet, the "executor" that will hit the eoa's (to-be) execute function
|
||||
var executorWallet = await PrivateKeyWallet.Generate(client);
|
||||
var executorWalletAddress = await executorWallet.GetAddress();
|
||||
Console.WriteLine($"Executor address: {executorWalletAddress}");
|
||||
|
||||
// Fund the executor wallet
|
||||
var fundingWallet = await PrivateKeyWallet.Create(client, privateKey);
|
||||
var fundingHash = (await fundingWallet.Transfer(chainWith7702, executorWalletAddress, BigInteger.Parse("0.001".ToWei()))).TransactionHash;
|
||||
Console.WriteLine($"Funded Executor Wallet: {fundingHash}");
|
||||
|
||||
// Sign the authorization to make it point to the delegation contract
|
||||
var authorization = await eoaWallet.SignAuthorization(chainId: chainWith7702, contractAddress: delegationContractAddress, willSelfExecute: false);
|
||||
Console.WriteLine($"Authorization: {JsonConvert.SerializeObject(authorization, Formatting.Indented)}");
|
||||
|
||||
// Execute the delegation
|
||||
var tx = await ThirdwebTransaction.Create(executorWallet, new ThirdwebTransactionInput(chainId: chainWith7702, to: executorWalletAddress, authorization: authorization));
|
||||
var hash = (await ThirdwebTransaction.SendAndWaitForTransactionReceipt(tx)).TransactionHash;
|
||||
Console.WriteLine($"Authorization execution transaction hash: {hash}");
|
||||
|
||||
// Prove that code has been deployed to the eoa
|
||||
var rpc = ThirdwebRPC.GetRpcInstance(client, chainWith7702);
|
||||
var code = await rpc.SendRequestAsync<string>("eth_getCode", eoaWalletAddress, "latest");
|
||||
Console.WriteLine($"EOA code: {code}");
|
||||
|
||||
// Log erc20 balance of executor before the claim
|
||||
var executorBalanceBefore = await erc20Contract.ERC20_BalanceOf(executorWalletAddress);
|
||||
Console.WriteLine($"Executor balance before: {executorBalanceBefore}");
|
||||
|
||||
// Prepare the claim call
|
||||
var claimCallData = erc20Contract.CreateCallData(
|
||||
"claim",
|
||||
new object[]
|
||||
{
|
||||
executorWalletAddress, // receiver
|
||||
100, // quantity
|
||||
Constants.NATIVE_TOKEN_ADDRESS, // currency
|
||||
0, // pricePerToken
|
||||
new object[] { Array.Empty<byte>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO }, // allowlistProof
|
||||
Array.Empty<byte>() // data
|
||||
}
|
||||
);
|
||||
|
||||
// Embed the claim call in the execute call
|
||||
var executeCallData = delegationContract.CreateCallData(
|
||||
method: "execute",
|
||||
parameters: new object[]
|
||||
{
|
||||
new List<Thirdweb.Console.Call>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Data = claimCallData.HexToBytes(),
|
||||
To = erc20ContractAddress,
|
||||
Value = BigInteger.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Execute from the executor wallet targeting the eoa which is pointing to the delegation contract
|
||||
var tx2 = await ThirdwebTransaction.Create(executorWallet, new ThirdwebTransactionInput(chainId: chainWith7702, to: eoaWalletAddress, data: executeCallData));
|
||||
var hash2 = (await ThirdwebTransaction.SendAndWaitForTransactionReceipt(tx2)).TransactionHash;
|
||||
Console.WriteLine($"Token claim transaction hash: {hash2}");
|
||||
|
||||
// Log erc20 balance of executor after the claim
|
||||
var executorBalanceAfter = await erc20Contract.ERC20_BalanceOf(executorWalletAddress);
|
||||
Console.WriteLine($"Executor balance after: {executorBalanceAfter}");
|
||||
var authorization = await eoaWallet.SignAuthorization(chainId: chainWith7702Support, contractAddress: delegationContractAddress, willSelfExecute: false);
|
||||
```
|
||||
|
||||
_Note that for the time being this only works on 7702-enabled chains such as [Odyssey](https://thirdweb.com/odyssey-911867) and the feature has only been integrated with `PrivateKeyWallet`._
|
||||
<Details summary="Parameters">
|
||||
|
||||
### chainId (required)
|
||||
|
||||
Your chain ID as a `BigInteger` value.
|
||||
|
||||
### contractAddress (required)
|
||||
The address of the contract you want to authorize. Your EOA will essentially become a smart contract if this authorization is executed.
|
||||
|
||||
### willSelfExecute (required)
|
||||
Whether the authorization will be executed by the EOA itself or by a third-party. It will affect the nonce of the authorization.
|
||||
|
||||
</Details>
|
||||
|
||||
<Details summary="Return Value">
|
||||
|
||||
### string
|
||||
|
||||
The method returns an `EIP7702Authorization` object that can be passed to our `ThirdwebTransaction`/`ThirdwebTransactionInput` related APIs and broadcast as such.
|
||||
|
||||
</Details>
|
||||
|
||||
@@ -6,7 +6,49 @@ export const metadata = createMetadata({
|
||||
"Instantiate a SmartWallet for enhanced user interactions with blockchain applications.",
|
||||
});
|
||||
|
||||
# SmartWallet.Create
|
||||
# Native Account Abstraction (via EIP-7702 Smart EOAs)
|
||||
|
||||
With the recent Ethereum upgrade Pectra, EIP-7702 allows you to upgrade your EOA and get SmartWallet-like functionality with:
|
||||
- Much cheaper gas costs, batching functionality
|
||||
- No account separation - your wallet address does not change, not even on zksync chains (once they implement EIP-7702)
|
||||
- Much faster execution, with the option of paying for gas yourself or having thirdweb manage gas sponsorship, similar to SmartWallet.
|
||||
|
||||
The API is also drastically simplified!
|
||||
|
||||
### ExecutionMode.EIP7702Sponsored
|
||||
Upgrade to an EIP7702 smart account, unlocking all functionality of 4337 without the downsides, and thirdweb handles the execution and gas sponsorship for you!
|
||||
```csharp
|
||||
var smartEoa = await InAppWallet.Create(
|
||||
client: thirdwebClient,
|
||||
authProvider: AuthProvider.Google,
|
||||
executionMode: ExecutionMode.EIP7702Sponsored
|
||||
);
|
||||
```
|
||||
|
||||
### ExecutionMode.EIP7702
|
||||
Upgrade to an EIP7702 smart account, unlocking all functionality of 4337 without the downsides, but sponsoring gas yourself.
|
||||
```csharp
|
||||
var smartEoa = await InAppWallet.Create(
|
||||
client: thirdwebClient,
|
||||
authProvider: AuthProvider.Google,
|
||||
executionMode: ExecutionMode.EIP7702
|
||||
);
|
||||
```
|
||||
|
||||
### ExecutionMode.EOA
|
||||
"Normal" EOA Execution, no smart account functionality
|
||||
```csharp
|
||||
var basicEoa = await InAppWallet.Create(
|
||||
client: thirdwebClient,
|
||||
authProvider: AuthProvider.Google,
|
||||
// does not need to be explicitly passed, is the default but we're showing it here
|
||||
executionMode: ExecutionMode.EOA
|
||||
);
|
||||
```
|
||||
|
||||
_When using EIP-7702 execution modes - if not already delegated to a smart account - an EIP-7702 authorization will be signed and bundled with your first transaction, similar to how 4337 works with initcode, but without the large gas costs, slower execution and chain specific requirements._
|
||||
|
||||
# SmartWallet (via EIP-4337 Bundlers)
|
||||
|
||||
Instantiate a `SmartWallet` to enable advanced blockchain interactions, including gasless transactions through account abstraction. This wallet type is especially useful for creating a user-friendly experience in decentralized applications.
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# Migrating from Engine v2 to v3
|
||||
|
||||
This guide outlines the key changes required to migrate your applications from Engine v2 to Engine v3. We'll cover common operations and highlight the differences in API requests and authentication.
|
||||
|
||||
## Writing to a contract
|
||||
|
||||
Migrating your contract write operations from v2 to v3 involves changes to the endpoint, authentication mechanism, and request payload structure.
|
||||
|
||||
#### OLD (v2)
|
||||
|
||||
The v2 approach used your dedicated engine url, with a specific endpoint for each chain and contract and backend wallet address in the header.
|
||||
|
||||
```bash
|
||||
curl -X POST "<engine_url>/contract/<chain>/<contract_address>/write" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <access_token>" \
|
||||
-H "x-backend-wallet-address: <backend_wallet_address>" \
|
||||
-d '{
|
||||
"functionName": "function transferFrom(address from, address to, uint256 amount)",
|
||||
"args": [
|
||||
"0x1946267d81Fb8aDeeEa28e6B98bcD446c8248473",
|
||||
"0x3EcDBF3B911d0e9052b64850693888b008e18373",
|
||||
"0"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
#### NEW (v3)
|
||||
|
||||
The v3 approach uses a generic POST endpoint, with authentication via secret key and vault access token. Contract, chain, and execution details are now part of a structured JSON payload.
|
||||
|
||||
```bash
|
||||
curl -X POST "https://engine.thirdweb.com/v1/write/contract" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-secret-key: <your-project-secret-key>" \
|
||||
-H "x-vault-access-token: <your-vault-access-token>" \
|
||||
-d '{
|
||||
"executionOptions": {
|
||||
"from": "<your-server-wallet-address>",
|
||||
"chainId": "84532"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"contractAddress": "0x...",
|
||||
"method": "function transferFrom(address from, address to, uint256 amount)",
|
||||
"params": [
|
||||
"0x1946267d81Fb8aDeeEa28e6B98bcD446c8248473",
|
||||
"0x3EcDBF3B911d0e9052b64850693888b008e18373",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Key Differences:
|
||||
|
||||
* **API Endpoint:** The endpoint has changed from a specific path per chain and contract (`<engine_url>/contract/<chain>/<contract_address>/write`) to a general endpoint (`https://engine.thirdweb.com/v1/write/contract`).
|
||||
* **Authentication:**
|
||||
* V2 used an `Authorization: Bearer <access_token>` header for general API access and an `x-backend-wallet-address` header to specify the sender.
|
||||
* V3 uses an `x-secret-key` header (your project's secret key, found in your thirdweb dashboard) for authentication. Your backend wallet is managed by thirdweb Vault, an `x-vault-access-token` header is required for all write operations.
|
||||
* **Request Structure:**
|
||||
* **Sender & Chain:** In V2, the sender wallet was a header, and the chain was part of the URL. In V3, these are specified in the request body within an `executionOptions` object: `from` for the sender wallet address and `chainId` for the target chain.
|
||||
* **Contract Call:** V2 took `functionName` and `args` directly in the body. V3 uses a `params` array in the request body. Each element in this array is an object defining the target `contractAddress`, the partial or full `method` signature (e.g., `"function mintTo(address to, uint256 amount)"`), and the `params` (arguments) for that method.
|
||||
* **Function Specification:** While V3 supports using just the method name (e.g., "transferFrom") in the `method` field, providing the full function signature (e.g., "function transferFrom(address from, address to, uint256 amount)") is highly recommended for optimal performance as it avoids an ABI lookup. V2 only required the `functionName`.
|
||||
|
||||
---
|
||||
|
||||
## Check the status of a transaction
|
||||
|
||||
Querying transaction status has also been updated, moving from a direct ID lookup via GET to a POST request with a filter-based search.
|
||||
|
||||
#### OLD (v2)
|
||||
|
||||
In v2, transaction status was fetched using a GET request with the `queue_id` in the URL.
|
||||
|
||||
```bash
|
||||
curl -X GET "<engine_url>/transaction/status/<queue_id>" \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
#### NEW (v3)
|
||||
|
||||
V3 uses a search endpoint with a POST request, where transaction identifiers are passed in the request body using filters.
|
||||
|
||||
```bash
|
||||
curl --location 'https://engine.thirdweb.com/v1/transactions/search' \
|
||||
--header 'x-secret-key: <your-project-secret-key>' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"filters": [
|
||||
{
|
||||
"field": "id",
|
||||
"values": [
|
||||
"750cb3eb-d297-4d8e-8d29-76dea99ba294" // transaction id
|
||||
],
|
||||
"operation": "OR"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Key Differences:
|
||||
|
||||
* **API Endpoint & Method:** The V2 `GET` endpoint (`<engine_url>/transaction/status/<queue_id>`) is replaced by a V3 `POST` endpoint (`https://engine.thirdweb.com/v1/transactions/search`).
|
||||
* **Authentication:**
|
||||
* V2 used an `Authorization: Bearer <access_token>` header.
|
||||
* V3 uses an `x-secret-key` header (your project's secret key).
|
||||
* **Querying Transaction Status:**
|
||||
* In V2, the `queue_id` was passed directly in the URL path.
|
||||
* In V3, you send a `POST` request with a JSON body containing a `filters` array. To find a specific transaction, you filter by its `id` (the equivalent of V2's `queue_id`). This filter-based approach is more flexible for querying transactions based on various criteria.
|
||||
|
||||
---
|
||||
|
||||
## Reading from a contract
|
||||
|
||||
Reading data from a smart contract has also been updated in v3, shifting from URL-based parameters to a structured JSON payload over POST.
|
||||
|
||||
#### OLD (v2)
|
||||
|
||||
In v2, contract reads were GET requests. The chain, contract address, function name, and arguments were specified in the URL path and query parameters.
|
||||
|
||||
```bash
|
||||
curl -X GET "<engine_url>/contract/<chain>/<contract_address>/read?functionName=balanceOf&args=0x3EcDBF3B911d0e9052b64850693888b008e18373" \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
#### NEW (v3)
|
||||
|
||||
V3 uses a POST request to a dedicated read endpoint. All parameters, including chain, contract details, method signature, and arguments, are provided in the JSON body.
|
||||
|
||||
```bash
|
||||
curl https://engine.thirdweb.com/v1/read/contract \
|
||||
--request POST \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'x-secret-key: <your-project-secret-key>' \
|
||||
--data '{
|
||||
"params": [
|
||||
{
|
||||
"contractAddress": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
||||
"method": "function balanceOf(address account) returns (uint256)",
|
||||
"params": [
|
||||
"0xeb0effdfb4dc5b3d5d3ac6ce29f3ed213e95d675"
|
||||
]
|
||||
}
|
||||
],
|
||||
"readOptions": {
|
||||
"chainId": "84532"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
#### Key Differences:
|
||||
|
||||
* **API Endpoint & Method:**
|
||||
* V2 used a `GET` request to `<engine_url>/contract/<chain>/<contract_address>/read` with function details as query parameters.
|
||||
* V3 uses a `POST` request to the generic endpoint `https://engine.thirdweb.com/v1/read/contract`.
|
||||
* **Authentication:**
|
||||
* V2 used an `Authorization: Bearer <access_token>` header.
|
||||
* V3 uses an `x-secret-key: <your-project-secret-key>` header.
|
||||
* **Request Structure:**
|
||||
* In V2, the chain and contract address were part of the URL path, and the function name (`functionName`) and its arguments (`args`) were URL query parameters.
|
||||
* In V3, the request body is a JSON object. It includes a `params` array (where each object specifies `contractAddress`, the partial or full `method` signature, and its `params`) and a `readOptions` object to specify the `chainId`.
|
||||
* **Function Specification:** Similar to contract writes, while V3 supports using just the method name (e.g., "balanceOf") in the `method` field, providing the full function signature (e.g., "function balanceOf(address account) returns (uint256)") is highly recommended for optimal performance as it avoids an ABI lookup. V2 primarily used just the function name in the `functionName` query parameter.
|
||||
|
||||
---
|
||||
|
||||
## Extension functions
|
||||
|
||||
Engine v2 has a large number of endpoints to do specific contract calls, like mintTo, claimTo, etc.
|
||||
|
||||
Engine v3 does not have these endpoints yet, but they are coming soon. In the meantime, if your backend is written in typescript, we recommend using the [thirdweb SDK](/references/typescript/v5/functions#extensions) to do these operations:
|
||||
|
||||
##### OLD (v2)
|
||||
|
||||
```ts
|
||||
const response = await fetch(
|
||||
"<engine_url>/contract/<chain_id>/<nft_contract_address>/erc1155/mint-to",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer <thirdweb_secret_key>",
|
||||
"x-backend-wallet-address": "<backend_wallet_address>",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
receiver: "0x...",
|
||||
metadataWithSupply: {
|
||||
metadata: {
|
||||
name: "Acme Inc. Superfan",
|
||||
description: "Created with thirdweb Engine",
|
||||
image:
|
||||
"ipfs://QmciR3WLJsf2BgzTSjbG5zCxsrEQ8PqsHK7JWGWsDSNo46/nft.png",
|
||||
},
|
||||
supply: "1",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
const queueId = data.queueId;
|
||||
```
|
||||
|
||||
#### NEW (v3) - using the thirdweb SDK
|
||||
|
||||
```ts
|
||||
// Create a thirdweb client
|
||||
const client = createThirdwebClient({
|
||||
secretKey: "<your-project-secret-key>",
|
||||
});
|
||||
|
||||
// Create a server wallet
|
||||
const serverWallet = Engine.serverWallet({
|
||||
client,
|
||||
address: "<your-server-wallet-address>",
|
||||
vaultAccessToken: "<your-vault-access-token>",
|
||||
});
|
||||
|
||||
// instead of a dedicated endpoint, you can use the thirdweb SDK to do the operation
|
||||
const transaction = mintTo({
|
||||
contract: getContract({
|
||||
client,
|
||||
address: "<nft_contract_address>", // Address of the ERC1155 token contract
|
||||
chain: baseSepolia, // Chain of the ERC1155 token contract
|
||||
}),
|
||||
to: "0x...", // The address of the user to mint to
|
||||
supply: 1n, // The quantity of NFTs to mint
|
||||
nft: {
|
||||
name: "Acme Inc. Superfan",
|
||||
description: "Created with thirdweb Engine",
|
||||
image:
|
||||
"ipfs://QmciR3WLJsf2BgzTSjbG5zCxsrEQ8PqsHK7JWGWsDSNo46/nft.png",
|
||||
},
|
||||
});
|
||||
|
||||
// Enqueue the transaction via Engine
|
||||
const { transactionId } =
|
||||
await serverWallet.enqueueTransaction({
|
||||
transaction,
|
||||
});
|
||||
```
|
||||
|
||||
The entire thirdweb SDK is available to use with engine v3 in this manner using the [`Engine` namespace](/references/typescript/v5/functions#engine). Check the [thirdweb SDK docs](/references/typescript/v5/functions#extensions) for all available functions.
|
||||
@@ -67,6 +67,11 @@ export const sidebar: SideBar = {
|
||||
href: "/vault/security",
|
||||
icon: <ShieldQuestionIcon />,
|
||||
},
|
||||
{
|
||||
name: "Migrate from v2",
|
||||
href: `${engineV3Slug}/migrate`,
|
||||
icon: <WrenchIcon />,
|
||||
},
|
||||
{
|
||||
name: "Troubleshoot",
|
||||
href: `${engineV3Slug}/troubleshoot`,
|
||||
|
||||
@@ -7,7 +7,31 @@ export const metadata = createMetadata({
|
||||
"Instantiate a SmartWallet to sign transactions and messages.",
|
||||
});
|
||||
|
||||
# SmartWallet
|
||||
# Native Account Abstraction (via EIP-7702 Smart EOAs)
|
||||
Native Account Abstraction is a system that allows you to set code to an EOA, unlocking a world of possibilities to enhance their functionality. It is available since the Pectra upgrade on various chains.
|
||||
|
||||
Enabling it is as simple as creating an `InAppWallet` and passing the `ExecutionMode.EIP7702Sponsored` flag during creation.
|
||||
|
||||
```csharp
|
||||
// Turn your boring EOAs into Smart EOAs!
|
||||
var smartIaw = await ConnectWallet(
|
||||
new WalletOptions(
|
||||
provider: WalletProvider.InAppWallet,
|
||||
chainId: 11155111, // Sepolia supports EIP-7702
|
||||
inAppWalletOptions: new InAppWalletOptions(
|
||||
authprovider: AuthProvider.Google,
|
||||
executionMode: ExecutionMode.EIP7702Sponsored // new!
|
||||
)
|
||||
)
|
||||
);
|
||||
ThirdwebDebug.Log("Connected to InAppWallet: " + await smartIaw.GetAddress());
|
||||
|
||||
// Execute a transaction as usual, execution is managed by thirdweb seamlessly!
|
||||
var receipt = await smartIaw.Transfer(11155111, await smartIaw.GetAddress(), 0);
|
||||
ThirdwebDebug.Log($"Transfer receipt: https://sepolia.etherscan.io/tx/{receipt.TransactionHash}");
|
||||
```
|
||||
|
||||
# SmartWallet (via EIP-4337 Bundlers)
|
||||
|
||||
Instantiate or upgrade any other wallet to a `SmartWallet` to enable advanced blockchain interactions, including gasless transactions through Account Abstraction (ERC4337 as well as ZkSync Native AA).
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# thirdweb
|
||||
|
||||
## 5.99.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7016](https://github.com/thirdweb-dev/js/pull/7016) [`1985de8`](https://github.com/thirdweb-dev/js/commit/1985de89aae5196233789221ad06f1b6554d27fc) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Expose inMemoryStorage for inAppWallet backend usage
|
||||
|
||||
- [#7034](https://github.com/thirdweb-dev/js/pull/7034) [`fc65350`](https://github.com/thirdweb-dev/js/commit/fc6535069beefa419f827c23673f386100a60fd9) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Preload wallet balances on pay embed
|
||||
|
||||
- [#7038](https://github.com/thirdweb-dev/js/pull/7038) [`da3fc99`](https://github.com/thirdweb-dev/js/commit/da3fc99c0bb71043b902867a5003bc3d34041269) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Default to in-memory storage when creating inapp wallets outside the browser
|
||||
|
||||
## 5.99.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "thirdweb",
|
||||
"version": "5.99.0",
|
||||
"version": "5.99.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/thirdweb-dev/js.git#main"
|
||||
|
||||
@@ -11,3 +11,4 @@ export {
|
||||
type ResolveArweaveSchemeOptions,
|
||||
} from "../utils/arweave.js";
|
||||
export type { AsyncStorage } from "../utils/storage/AsyncStorage.js";
|
||||
export { inMemoryStorage } from "../utils/storage/inMemoryStorage.js";
|
||||
|
||||
@@ -18,7 +18,7 @@ export type CanClaimResult = {
|
||||
|
||||
/**
|
||||
* Check if a user can claim a drop.
|
||||
*
|
||||
* This method is only available on the `DropERC1155` contract.
|
||||
* @param options - The options for the transaction.
|
||||
* @returns Whether the user can claim the drop.
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
export type GetActiveClaimConditionParams = GetActiveClaimConditionIdParams;
|
||||
/**
|
||||
* Retrieves the active claim condition.
|
||||
* This method is only available on the `DropERC1155` contract.
|
||||
* @param options - The transaction options.
|
||||
* @returns A promise that resolves to the active claim condition.
|
||||
* @throws An error if the claim condition is unsupported.
|
||||
|
||||
@@ -9,6 +9,7 @@ export type GetClaimConditionsParams = {
|
||||
};
|
||||
/**
|
||||
* Retrieves all claim conditions.
|
||||
* This method is only available on the `DropERC1155` contract.
|
||||
* @param options - The transaction options.
|
||||
* @returns A promise that resolves to all claim conditions.
|
||||
* @throws An error if the claim conditions are unsupported by the contract.
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ClaimToParams = {
|
||||
|
||||
/**
|
||||
* Claim ERC1155 NFTs to a specified address
|
||||
* This method is only available on the `DropERC1155` contract.
|
||||
* @param options - The options for the transaction
|
||||
* @extension ERC1155
|
||||
* @example
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ResetClaimEligibilityParams = GetClaimConditionsParams;
|
||||
|
||||
/**
|
||||
* Reset the claim eligibility for all users.
|
||||
* This method is only available on the `DropERC1155` contract.
|
||||
* @param options
|
||||
* @returns the prepared transaction
|
||||
* @extension ERC1155
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SetClaimConditionsParams = {
|
||||
|
||||
/**
|
||||
* Set the claim conditions for a ERC1155 drop
|
||||
* This method is only available on the `DropERC1155` contract.
|
||||
* @param options
|
||||
* @returns the prepared transaction
|
||||
* @extension ERC1155
|
||||
|
||||
@@ -37,6 +37,7 @@ export type LazyMintParams = {
|
||||
|
||||
/**
|
||||
* Lazily mints ERC1155 tokens.
|
||||
* This method is only available on the `DropERC1155` contract.
|
||||
* @param options - The options for the lazy minting process.
|
||||
* @returns A promise that resolves to the prepared contract call.
|
||||
* @extension ERC1155
|
||||
|
||||
@@ -13,6 +13,7 @@ export type MintAdditionalSupplyToParams = {
|
||||
|
||||
/**
|
||||
* Mints a "supply" number of additional ERC1155 tokens to the specified "to" address.
|
||||
* This method is only available on the `TokenERC1155` contract.
|
||||
* @param options - The transaction options.
|
||||
* @returns A promise that resolves to the transaction result.
|
||||
* @extension ERC1155
|
||||
|
||||
@@ -16,6 +16,7 @@ export type MintAdditionalSupplyToBatchParams = WithOverrides<{
|
||||
|
||||
/**
|
||||
* This extension batches multiple `mintAdditionalSupplyToBatch` extensions into one single multicall.
|
||||
* This method is only available on the `TokenERC1155` contract.
|
||||
* Keep in mind that there is a limit of how many NFTs you can mint per transaction.
|
||||
* This limit varies depends on the network that you are transacting on.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ export type MintToParams = WithOverrides<{
|
||||
|
||||
/**
|
||||
* Mints a "supply" number of new ERC1155 tokens to the specified "to" address.
|
||||
* This method is only available on the `TokenERC1155` contract.
|
||||
* If the `nft` parameter is a string, it will be used as the token URI.
|
||||
* If the `nft` parameter is a file, it will be uploaded to the storage server and the resulting URI will be used as the token URI.
|
||||
* @param options - The transaction options.
|
||||
|
||||
@@ -40,6 +40,7 @@ export type MintToBatchParams = WithOverrides<{
|
||||
|
||||
/**
|
||||
* This extension batches multiple `mintTo` extensions into one single multicall.
|
||||
* This method is only available on the `TokenERC1155` contract.
|
||||
* Keep in mind that there is a limit of how many NFTs you can mint per transaction.
|
||||
* This limit varies depends on the network that you are transacting on.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
|
||||
/**
|
||||
* Mints a new ERC1155 token with the given minter signature
|
||||
* This method is only available on the `TokenERC1155` contract.
|
||||
* @param options - The transaction options.
|
||||
* @example
|
||||
* ```ts
|
||||
|
||||
@@ -20,6 +20,7 @@ export type UpdateTokenURIParams = {
|
||||
* which means it uses `setTokenURI` under the hood.
|
||||
* While the `setTokenURI` method only takes in a uri string, this extension takes in a user-friendly [`NFTInput`](https://portal.thirdweb.com/references/typescript/v5/NFTInput),
|
||||
* upload that content to IPFS and pass the IPFS URI (of said `NFTInput`) to the underlying `setTokenURI` method.
|
||||
* This method is only available on the `TokenERC1155` contract.
|
||||
*
|
||||
* This extension does not validate the NFTInput so make sure you are passing the proper content that you want to update.
|
||||
*
|
||||
|
||||
@@ -17,7 +17,7 @@ export type CanClaimResult = {
|
||||
|
||||
/**
|
||||
* Check if a user can claim a drop.
|
||||
*
|
||||
* This method is only available on the `DropERC20` contract.
|
||||
* @param options - The options for the transaction.
|
||||
* @returns Whether the user can claim the drop.
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as MultiById from "../../__generated__/IDropERC20/read/getClaimConditio
|
||||
|
||||
/**
|
||||
* Retrieves the active claim condition.
|
||||
* This method is only available on the `DropERC20` contract.
|
||||
* @param options - The transaction options.
|
||||
* @returns A promise that resolves to the active claim condition.
|
||||
* @throws An error if the claim condition is unsupported.
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as MultiById from "../../__generated__/IDropERC20/read/getClaimConditio
|
||||
|
||||
/**
|
||||
* Retrieves all claim conditions.
|
||||
* This method is only available on the `DropERC20` contract.
|
||||
* @param options - The transaction options.
|
||||
* @returns A promise that resolves to all claim conditions.
|
||||
* @throws An error if the claim conditions are unsupported by the contract.
|
||||
|
||||
@@ -18,6 +18,8 @@ export type ClaimToParams = {
|
||||
|
||||
/**
|
||||
* Claim ERC20 NFTs to a specified address
|
||||
* This method is only available on the `DropERC20` contract.
|
||||
*
|
||||
* @param options - The options for the transaction
|
||||
* @extension ERC20
|
||||
* @example
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
/**
|
||||
* Reset the claim eligibility for all users.
|
||||
* This method is only available on the `DropERC20` contract.
|
||||
* @param options
|
||||
* @returns the prepared transaction
|
||||
* @extension ERC20
|
||||
|
||||
@@ -21,6 +21,7 @@ export type SetClaimConditionsParams = {
|
||||
|
||||
/**
|
||||
* Set the claim conditions for a ERC20 drop
|
||||
* This method is only available on the `DropERC20` contract.
|
||||
* @param options
|
||||
* @returns the prepared transaction
|
||||
* @extension ERC20
|
||||
|
||||
@@ -27,6 +27,7 @@ export type MintToParams = Prettify<
|
||||
|
||||
/**
|
||||
* Mints a specified amount of tokens to a given address.
|
||||
* This method is only available on the `TokenERC20` contract.
|
||||
* @param options - The options for minting tokens.
|
||||
* @returns A prepared transaction object.
|
||||
* @extension ERC20
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
/**
|
||||
* Mints a new ERC20 token with the given minter signature
|
||||
* This method is only available on the `TokenERC20` contract.
|
||||
* @param options - The transaction options.
|
||||
* @extension ERC20
|
||||
* @example
|
||||
|
||||
@@ -18,6 +18,8 @@ export type CanClaimResult = {
|
||||
/**
|
||||
* Check if a user can claim a drop.
|
||||
*
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
*
|
||||
* @param options - The options for the transaction.
|
||||
* @returns Whether the user can claim the drop.
|
||||
*
|
||||
|
||||
@@ -5,6 +5,8 @@ import * as ById from "../../__generated__/IDrop/read/getClaimConditionById.js";
|
||||
|
||||
/**
|
||||
* Retrieves the active claim condition.
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
*
|
||||
* @param options - The transaction options.
|
||||
* @returns A promise that resolves to the active claim condition.
|
||||
* @throws An error if the claim condition is unsupported.
|
||||
@@ -28,6 +30,8 @@ export async function getActiveClaimCondition(
|
||||
|
||||
/**
|
||||
* Checks if the `getActiveClaimCondition` method is supported by the given contract.
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
*
|
||||
* @param availableSelectors An array of 4byte function selectors of the contract. You can get this in various ways, such as using "whatsabi" or if you have the ABI of the contract available you can use it to generate the selectors.
|
||||
* @returns A boolean indicating if the `getActiveClaimCondition` method is supported.
|
||||
* @extension ERC721
|
||||
|
||||
@@ -5,6 +5,9 @@ import * as MultiById from "../../__generated__/IDrop/read/getClaimConditionById
|
||||
|
||||
/**
|
||||
* Retrieves all claim conditions.
|
||||
*
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
*
|
||||
* @param options - The transaction options.
|
||||
* @returns A promise that resolves to all claim conditions.
|
||||
* @throws An error if the claim conditions are unsupported by the contract.
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ClaimToParams = {
|
||||
|
||||
/**
|
||||
* Claim ERC721 NFTs to a specified address
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
* @param options - The options for the transaction
|
||||
* @extension ERC721
|
||||
* @example
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ClaimToBatchParams = WithOverrides<{
|
||||
* This extension batches multiple `claimTo` extensions into one single multicall.
|
||||
* Keep in mind that there is a limit of how many NFTs you can claim per transaction.
|
||||
* This limit varies depends on the network that you are transacting on.
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
*
|
||||
* You are recommended to experiment with the number to figure out the best number for your chain of choice.
|
||||
* @extension ERC721
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
/**
|
||||
* Reset the claim eligibility for all users.
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
* @param options
|
||||
* @returns the prepared transaction
|
||||
* @extension ERC721
|
||||
|
||||
@@ -21,6 +21,7 @@ export type SetClaimConditionsParams = {
|
||||
|
||||
/**
|
||||
* Set the claim conditions for a ERC721 drop
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
* @param options
|
||||
* @returns the prepared transaction
|
||||
* @extension ERC721
|
||||
|
||||
@@ -32,6 +32,7 @@ export type CreateDelayedRevealBatchParams = {
|
||||
|
||||
/**
|
||||
* Creates a batch of encrypted NFTs that can be revealed at a later time.
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
*
|
||||
* @param options {CreateDelayedRevealBatchParams} - The delayed reveal options.
|
||||
* @param options.placeholderMetadata {@link NFTInput} - The placeholder metadata for the batch.
|
||||
|
||||
@@ -14,7 +14,7 @@ export type RevealParams = {
|
||||
|
||||
/**
|
||||
* Reveals a previously lazy minted batch of NFTs.
|
||||
*
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
* @param options {RevealParams} - The reveal parameters.
|
||||
* @param options.batchId {number} - The ID of the batch to reveal. Get this by calling `getBatchesToReveal`. {@see getBatchesToReveal}
|
||||
* @param options.password {string} - The password for the reveal, set when the batch was created. {@see createDelayedRevealBatch}
|
||||
|
||||
@@ -19,6 +19,8 @@ export type LazyMintParams = {
|
||||
|
||||
/**
|
||||
* Lazily mints ERC721 tokens.
|
||||
* This method is only available on the `DropERC721` contract.
|
||||
*
|
||||
* @param options - The options for the lazy minting process.
|
||||
* @returns A promise that resolves to the prepared contract call.
|
||||
* @extension ERC721
|
||||
|
||||
@@ -17,6 +17,8 @@ export type MintToParams = WithOverrides<{
|
||||
|
||||
/**
|
||||
* Mints a new ERC721 token and assigns it to the specified address.
|
||||
* This method is only available on the `TokenERC721` contract.
|
||||
*
|
||||
* If the `nft` parameter is a string, it will be used as the token URI.
|
||||
* If the `nft` parameter is a file, it will be uploaded to the storage server and the resulting URI will be used as the token URI.
|
||||
* @param options - The transaction options.
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
|
||||
/**
|
||||
* Mints a new ERC721 token with the given minter signature
|
||||
* This method is only available on the `TokenERC721` contract.
|
||||
*
|
||||
* @param options - The transaction options.
|
||||
* @example
|
||||
* ```ts
|
||||
|
||||
@@ -23,6 +23,7 @@ export type UpdateTokenURIParams = {
|
||||
* upload that content to IPFS and pass the IPFS URI (of said `NFTInput`) to the underlying `setTokenURI` method.
|
||||
*
|
||||
* This extension does not validate the NFTInput so make sure you are passing the proper content that you want to update.
|
||||
* This method is only available on the `TokenERC721` contract.
|
||||
*
|
||||
* @extension ERC721
|
||||
* @returns the prepared transaction from `setTokenURI`
|
||||
|
||||
@@ -63,6 +63,7 @@ import { SwapFlow } from "./swap/SwapFlow.js";
|
||||
import { SwapScreenContent } from "./swap/SwapScreenContent.js";
|
||||
import { TokenSelectorScreen } from "./swap/TokenSelectorScreen.js";
|
||||
import { TransferFlow } from "./swap/TransferFlow.js";
|
||||
import { useWalletsAndBalances } from "./swap/fetchBalancesForWallet.js";
|
||||
import {
|
||||
type SupportedChainAndTokens,
|
||||
useBuySupportedDestinations,
|
||||
@@ -218,6 +219,15 @@ function BuyScreenContent(props: BuyScreenContentProps) {
|
||||
);
|
||||
}, [props.supportedTokens, supportedSourcesQuery.data, payOptions]);
|
||||
|
||||
// preload wallets and balances
|
||||
useWalletsAndBalances({
|
||||
client: props.client,
|
||||
sourceSupportedTokens: sourceSupportedTokens || [],
|
||||
toChain: toChain,
|
||||
toToken: toToken,
|
||||
mode: payOptions.mode,
|
||||
});
|
||||
|
||||
const { fromChain, setFromChain, fromToken, setFromToken } =
|
||||
useFromTokenSelectionStates({
|
||||
payOptions,
|
||||
|
||||
+7
-47
@@ -4,7 +4,6 @@ import {
|
||||
ChevronRightIcon,
|
||||
Cross2Icon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { trackPayEvent } from "../../../../../../../analytics/track/pay.js";
|
||||
import type { Chain } from "../../../../../../../chains/types.js";
|
||||
import type { ThirdwebClient } from "../../../../../../../client/client.js";
|
||||
@@ -43,14 +42,9 @@ import { FiatValue } from "./FiatValue.js";
|
||||
import { WalletRow } from "./WalletRow.js";
|
||||
import {
|
||||
type TokenBalance,
|
||||
fetchBalancesForWallet,
|
||||
useWalletsAndBalances,
|
||||
} from "./fetchBalancesForWallet.js";
|
||||
|
||||
type WalletKey = {
|
||||
id: WalletId;
|
||||
address: string;
|
||||
};
|
||||
|
||||
export function TokenSelectorScreen(props: {
|
||||
client: ThirdwebClient;
|
||||
sourceTokens: SupportedTokens | undefined;
|
||||
@@ -71,46 +65,12 @@ export function TokenSelectorScreen(props: {
|
||||
const chainInfo = useChainMetadata(props.toChain);
|
||||
const theme = useCustomTheme();
|
||||
|
||||
const walletsAndBalances = useQuery({
|
||||
queryKey: [
|
||||
"wallets-and-balances",
|
||||
props.sourceSupportedTokens,
|
||||
props.toChain.id,
|
||||
props.toToken,
|
||||
props.tokenAmount,
|
||||
props.mode,
|
||||
activeAccount?.address,
|
||||
connectedWallets.map((w) => w.getAccount()?.address),
|
||||
],
|
||||
enabled: !!props.sourceSupportedTokens && !!chainInfo.data,
|
||||
queryFn: async () => {
|
||||
const entries = await Promise.all(
|
||||
connectedWallets.map(async (wallet) => {
|
||||
const balances = await fetchBalancesForWallet({
|
||||
wallet,
|
||||
accountAddress: activeAccount?.address,
|
||||
sourceSupportedTokens: props.sourceSupportedTokens || [],
|
||||
toChain: props.toChain,
|
||||
toToken: props.toToken,
|
||||
tokenAmount: props.tokenAmount,
|
||||
mode: props.mode,
|
||||
client: props.client,
|
||||
});
|
||||
return [
|
||||
{
|
||||
id: wallet.id,
|
||||
address: wallet.getAccount()?.address || "",
|
||||
} as WalletKey,
|
||||
balances,
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
const map = new Map<WalletKey, TokenBalance[]>();
|
||||
for (const entry of entries) {
|
||||
map.set(entry[0], entry[1]);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
const walletsAndBalances = useWalletsAndBalances({
|
||||
client: props.client,
|
||||
sourceSupportedTokens: props.sourceSupportedTokens || [],
|
||||
toChain: props.toChain,
|
||||
toToken: props.toToken,
|
||||
mode: props.mode,
|
||||
});
|
||||
|
||||
if (
|
||||
|
||||
+70
-8
@@ -1,3 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Chain } from "../../../../../../../chains/types.js";
|
||||
import { getCachedChain } from "../../../../../../../chains/utils.js";
|
||||
import type { ThirdwebClient } from "../../../../../../../client/client.js";
|
||||
@@ -9,7 +10,11 @@ import {
|
||||
type GetWalletBalanceResult,
|
||||
getWalletBalance,
|
||||
} from "../../../../../../../wallets/utils/getWalletBalance.js";
|
||||
import type { WalletId } from "../../../../../../../wallets/wallet-types.js";
|
||||
import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js";
|
||||
import { useChainMetadata } from "../../../../../../core/hooks/others/useChainQuery.js";
|
||||
import { useActiveAccount } from "../../../../../../core/hooks/wallets/useActiveAccount.js";
|
||||
import { useConnectedWallets } from "../../../../../../core/hooks/wallets/useConnectedWallets.js";
|
||||
import type {
|
||||
SupportedTokens,
|
||||
TokenInfo,
|
||||
@@ -32,7 +37,6 @@ type FetchBalancesParams = {
|
||||
sourceSupportedTokens: SupportedTokens;
|
||||
toChain: Chain;
|
||||
toToken: ERC20OrNativeToken;
|
||||
tokenAmount: string;
|
||||
mode: PayUIOptions["mode"];
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
@@ -43,13 +47,70 @@ export type TokenBalance = {
|
||||
token: TokenInfo;
|
||||
};
|
||||
|
||||
export async function fetchBalancesForWallet({
|
||||
type WalletKey = {
|
||||
id: WalletId;
|
||||
address: string;
|
||||
};
|
||||
|
||||
export function useWalletsAndBalances(props: {
|
||||
sourceSupportedTokens: SupportedTokens;
|
||||
toChain: Chain;
|
||||
toToken: ERC20OrNativeToken;
|
||||
mode: PayUIOptions["mode"];
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const activeAccount = useActiveAccount();
|
||||
const connectedWallets = useConnectedWallets();
|
||||
const chainInfo = useChainMetadata(props.toChain);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"wallets-and-balances",
|
||||
props.sourceSupportedTokens,
|
||||
props.toChain.id,
|
||||
props.toToken,
|
||||
props.mode,
|
||||
activeAccount?.address,
|
||||
connectedWallets.map((w) => w.getAccount()?.address),
|
||||
],
|
||||
enabled:
|
||||
!!props.sourceSupportedTokens && !!chainInfo.data && !!activeAccount,
|
||||
queryFn: async () => {
|
||||
const entries = await Promise.all(
|
||||
connectedWallets.map(async (wallet) => {
|
||||
const balances = await fetchBalancesForWallet({
|
||||
wallet,
|
||||
accountAddress: activeAccount?.address,
|
||||
sourceSupportedTokens: props.sourceSupportedTokens || [],
|
||||
toChain: props.toChain,
|
||||
toToken: props.toToken,
|
||||
mode: props.mode,
|
||||
client: props.client,
|
||||
});
|
||||
return [
|
||||
{
|
||||
id: wallet.id,
|
||||
address: wallet.getAccount()?.address || "",
|
||||
} as WalletKey,
|
||||
balances,
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
const map = new Map<WalletKey, TokenBalance[]>();
|
||||
for (const entry of entries) {
|
||||
map.set(entry[0], entry[1]);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBalancesForWallet({
|
||||
wallet,
|
||||
accountAddress,
|
||||
sourceSupportedTokens,
|
||||
toChain,
|
||||
toToken,
|
||||
tokenAmount,
|
||||
mode,
|
||||
client,
|
||||
}: FetchBalancesParams): Promise<TokenBalance[]> {
|
||||
@@ -133,7 +194,7 @@ export async function fetchBalancesForWallet({
|
||||
b.chain.id === chainId &&
|
||||
b.token.address.toLowerCase() === token.address.toLowerCase(),
|
||||
);
|
||||
if (!isNative && !isAlreadyFetched) {
|
||||
if (isAlreadyFetched && !isNative) {
|
||||
// ERC20 on insight-enabled chain already handled by insight call
|
||||
continue;
|
||||
}
|
||||
@@ -148,11 +209,12 @@ export async function fetchBalancesForWallet({
|
||||
});
|
||||
|
||||
const include =
|
||||
token.address === destinationToken.address &&
|
||||
token.address.toLowerCase() ===
|
||||
destinationToken.address.toLowerCase() &&
|
||||
chain.id === toChain.id
|
||||
? mode === "fund_wallet" && account.address === accountAddress
|
||||
? false
|
||||
: Number(balance.displayValue) > Number(tokenAmount)
|
||||
? !(
|
||||
mode === "fund_wallet" && account.address === accountAddress
|
||||
)
|
||||
: balance.value > 0n;
|
||||
|
||||
if (include) {
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AsyncStorage } from "./AsyncStorage.js";
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
export const inMemoryStorage: AsyncStorage = {
|
||||
getItem: async (key: string) => {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
@@ -23,7 +23,7 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
* const account = await wallet.connect({
|
||||
* client,
|
||||
* chain,
|
||||
* strategy: "google",
|
||||
* strategy: "google", // or "apple", "facebook","discord", "github", "twitch", "x", "telegram", "line", "coinbase", etc
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
@@ -31,15 +31,21 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
*
|
||||
* ### Enable smart accounts and sponsor gas for your users:
|
||||
*
|
||||
* With the `executionMode` option, you can enable smart accounts and sponsor gas for your users.
|
||||
*
|
||||
* **Using EIP-7702** (recommended):
|
||||
*
|
||||
* On chains with EIP-7702 enabled, you can upgrade the inapp wallet to a smart account, keeping the same address and performance as the regular EOA.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet } from "thirdweb/wallets";
|
||||
* import { sepolia } from "thirdweb/chains";
|
||||
*
|
||||
* const wallet = inAppWallet({
|
||||
* smartAccount: {
|
||||
* chain: sepolia,
|
||||
* executionMode: {
|
||||
* mode: "EIP7702",
|
||||
* sponsorGas: true,
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // account will be a smart account with sponsored gas enabled
|
||||
@@ -49,8 +55,28 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* **Using EIP-4337**:
|
||||
*
|
||||
* On chains without EIP-7702 enabled, you can still use smart accounts using EIP-4337, this will return a different address (the smart contract address) than the regular EOA.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet } from "thirdweb/wallets/in-app";
|
||||
*
|
||||
* const wallet = inAppWallet({
|
||||
* executionMode: {
|
||||
* mode: "EIP4337",
|
||||
* smartAccount: {
|
||||
* chain: sepolia, // chain required for EIP-4337
|
||||
* sponsorGas: true,
|
||||
* }
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Login with email
|
||||
*
|
||||
* To login with email, you can use the `preAuthenticate` function to first send a verification code to the user's email, then login with the verification code.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet, preAuthenticate } from "thirdweb/wallets/in-app";
|
||||
*
|
||||
@@ -73,22 +99,10 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Login with SIWE
|
||||
* ```ts
|
||||
* import { inAppWallet, createWallet } from "thirdweb/wallets";
|
||||
*
|
||||
* const rabby = createWallet("io.rabby");
|
||||
* const inAppWallet = inAppWallet();
|
||||
*
|
||||
* const account = await inAppWallet.connect({
|
||||
* strategy: "wallet",
|
||||
* chain: mainnet,
|
||||
* wallet: rabby,
|
||||
* client: MY_CLIENT
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Login with phone number
|
||||
*
|
||||
* Similar to email, you can login with a phone number by first sending a verification code to the user's phone number, then login with the verification code.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet, preAuthenticate } from "thirdweb/wallets/in-app";
|
||||
*
|
||||
@@ -111,8 +125,28 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Login with another wallet (SIWE)
|
||||
*
|
||||
* You can also login to the in-app wallet with another existing wallet by signing a standard Sign in with Ethereum (SIWE) message.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet, createWallet } from "thirdweb/wallets";
|
||||
*
|
||||
* const rabby = createWallet("io.rabby");
|
||||
* const inAppWallet = inAppWallet();
|
||||
*
|
||||
* const account = await inAppWallet.connect({
|
||||
* strategy: "wallet",
|
||||
* chain: mainnet,
|
||||
* wallet: rabby,
|
||||
* client: MY_CLIENT
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Login with passkey
|
||||
*
|
||||
* You can also login with a passkey. This mode requires specifying whether it should create a new passkey, or sign in with an existing passkey. We recommend checking if the user has a passkey stored in their browser to automatically login with it.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet, hasStoredPasskey } from "thirdweb/wallets/in-app";
|
||||
*
|
||||
@@ -128,6 +162,11 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
* ```
|
||||
*
|
||||
* ### Connect to a guest account
|
||||
*
|
||||
* You can also connect to a guest account, this will create a new account for the user instantly and store it in the browser's local storage.
|
||||
*
|
||||
* You can later "upgrade" this account by linking another auth method, like email or phone for example. This will preserve the account's address and history.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet } from "thirdweb/wallets";
|
||||
*
|
||||
@@ -141,6 +180,10 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
*
|
||||
* ### Connect to a backend account
|
||||
*
|
||||
* For usage in backends, you can create wallets with the `backend` strategy and a stable walletSecret.
|
||||
*
|
||||
* Make sure to keep that walletSecret safe as it is the key to access that wallet, never expose it to the client.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet } from "thirdweb/wallets";
|
||||
*
|
||||
@@ -149,7 +192,7 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
* const account = await wallet.connect({
|
||||
* client,
|
||||
* strategy: "backend",
|
||||
* walletSecret: "...", // Provided by your app
|
||||
* walletSecret: "...", // Your own secret, keep it safe
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
@@ -185,16 +228,21 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Specify a logo for your login page (Connect UI)
|
||||
* ### Specify a logo, icon and name for your login page (Connect UI)
|
||||
*
|
||||
* You can specify a logo, icon and name for your login page to customize how in-app wallets are displayed in the Connect UI components (ConnectButton and ConnectEmbed).
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet } from "thirdweb/wallets";
|
||||
* const wallet = inAppWallet({
|
||||
* metadata: {
|
||||
* image: {
|
||||
* src: "https://example.com/logo.png",
|
||||
* alt: "My logo",
|
||||
* width: 100,
|
||||
* height: 100,
|
||||
* name: "My App",
|
||||
* icon: "https://example.com/icon.png",
|
||||
* image: {
|
||||
* src: "https://example.com/logo.png",
|
||||
* alt: "My logo",
|
||||
* width: 100,
|
||||
* height: 100,
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
@@ -202,6 +250,8 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
*
|
||||
* ### Hide the ability to export the private key within the Connect Modal UI
|
||||
*
|
||||
* By default, the Connect Modal will show a button to export the private key of the wallet. You can hide this button by setting the `hidePrivateKeyExport` option to `true`.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet } from "thirdweb/wallets";
|
||||
* const wallet = inAppWallet({
|
||||
@@ -224,7 +274,7 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
|
||||
*
|
||||
* ### Override storage for the wallet state
|
||||
*
|
||||
* By default, wallet state is stored in the browser's local storage. You can override this behavior by providing a custom storage object, useful for server side integrations.
|
||||
* By default, wallet state is stored in the browser's local storage if in the browser, or in-memory storage if not in the browser. You can override this behavior by providing a custom storage object, useful for server side and CLI integrations.
|
||||
*
|
||||
* ```ts
|
||||
* import { inAppWallet } from "thirdweb/wallets";
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TEST_CLIENT } from "~test/test-clients.js";
|
||||
import { sepolia } from "../../../../chains/chain-definitions/sepolia.js";
|
||||
import { parseEventLogs } from "../../../../event/actions/parse-logs.js";
|
||||
import { userOperationEventEvent } from "../../../../extensions/erc4337/__generated__/IEntryPoint/events/UserOperationEvent.js";
|
||||
import { executedEvent } from "../../../../extensions/erc7702/__generated__/MinimalAccount/events/Executed.js";
|
||||
import { sendAndConfirmTransaction } from "../../../../transaction/actions/send-and-confirm-transaction.js";
|
||||
import { prepareTransaction } from "../../../../transaction/prepare-transaction.js";
|
||||
import { inAppWallet } from "../in-app.js";
|
||||
describe("InAppWallet Integration Tests", () => {
|
||||
it("should sign a message with backend strategy", async () => {
|
||||
const wallet = inAppWallet();
|
||||
const account = await wallet.connect({
|
||||
client: TEST_CLIENT,
|
||||
strategy: "backend",
|
||||
walletSecret: "test-secret",
|
||||
});
|
||||
expect(account.address).toBeDefined();
|
||||
const message = await account.signMessage({
|
||||
message: "Hello, world!",
|
||||
});
|
||||
expect(message).toBeDefined();
|
||||
});
|
||||
|
||||
it("should sign a message with guest strategy", async () => {
|
||||
const wallet = inAppWallet();
|
||||
const account = await wallet.connect({
|
||||
client: TEST_CLIENT,
|
||||
strategy: "guest",
|
||||
});
|
||||
expect(account.address).toBeDefined();
|
||||
const message = await account.signMessage({
|
||||
message: "Hello, world!",
|
||||
});
|
||||
expect(message).toBeDefined();
|
||||
});
|
||||
|
||||
it("should sponsor gas for a 7702 smart account", async () => {
|
||||
const chain = sepolia;
|
||||
const wallet = inAppWallet({
|
||||
executionMode: {
|
||||
mode: "EIP7702",
|
||||
sponsorGas: true,
|
||||
},
|
||||
});
|
||||
const account = await wallet.connect({
|
||||
client: TEST_CLIENT,
|
||||
strategy: "guest",
|
||||
chain,
|
||||
});
|
||||
expect(account.address).toBeDefined();
|
||||
const tx = await sendAndConfirmTransaction({
|
||||
transaction: prepareTransaction({
|
||||
chain,
|
||||
client: TEST_CLIENT,
|
||||
to: account.address,
|
||||
value: 0n,
|
||||
}),
|
||||
account,
|
||||
});
|
||||
expect(tx.transactionHash).toBeDefined();
|
||||
const logs = parseEventLogs({
|
||||
logs: tx.logs,
|
||||
events: [executedEvent()],
|
||||
});
|
||||
const executedLog = logs[0];
|
||||
if (!executedLog) {
|
||||
throw new Error("No executed log found");
|
||||
}
|
||||
expect(executedLog.args.to).toBe(account.address);
|
||||
expect(executedLog.args.value).toBe(0n);
|
||||
});
|
||||
|
||||
it("should sponsor gas for a 4337 smart account", async () => {
|
||||
const chain = sepolia;
|
||||
const wallet = inAppWallet({
|
||||
executionMode: {
|
||||
mode: "EIP4337",
|
||||
smartAccount: {
|
||||
chain,
|
||||
sponsorGas: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const account = await wallet.connect({
|
||||
client: TEST_CLIENT,
|
||||
strategy: "guest",
|
||||
chain,
|
||||
});
|
||||
expect(account.address).toBeDefined();
|
||||
const tx = await sendAndConfirmTransaction({
|
||||
transaction: prepareTransaction({
|
||||
chain,
|
||||
client: TEST_CLIENT,
|
||||
to: account.address,
|
||||
value: 0n,
|
||||
}),
|
||||
account,
|
||||
});
|
||||
expect(tx.transactionHash).toBeDefined();
|
||||
const logs = parseEventLogs({
|
||||
logs: tx.logs,
|
||||
events: [userOperationEventEvent()],
|
||||
});
|
||||
const executedLog = logs[0];
|
||||
if (!executedLog) {
|
||||
throw new Error("No executed log found");
|
||||
}
|
||||
expect(executedLog.args.sender).toBe(account.address);
|
||||
expect(executedLog.args.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ThirdwebClient } from "../../../../client/client.js";
|
||||
import { getThirdwebBaseUrl } from "../../../../utils/domains.js";
|
||||
import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js";
|
||||
import { inMemoryStorage } from "../../../../utils/storage/inMemoryStorage.js";
|
||||
import { webLocalStorage } from "../../../../utils/storage/webStorage.js";
|
||||
import type { SocialAuthOption } from "../../../../wallets/types.js";
|
||||
import type { Account } from "../../../interfaces/wallet.js";
|
||||
@@ -86,7 +88,7 @@ export class InAppWebConnector implements InAppConnector {
|
||||
this.ecosystem = ecosystem;
|
||||
this.passkeyDomain = passkeyDomain;
|
||||
this.storage = new ClientScopedStorage({
|
||||
storage: storage ?? webLocalStorage,
|
||||
storage: storage ?? getDefaultStorage(),
|
||||
clientId: client.clientId,
|
||||
ecosystem: ecosystem,
|
||||
});
|
||||
@@ -489,3 +491,11 @@ export class InAppWebConnector implements InAppConnector {
|
||||
function assertUnreachable(x: never, message?: string): never {
|
||||
throw new Error(message ?? `Invalid param: ${x}`);
|
||||
}
|
||||
|
||||
function getDefaultStorage(): AsyncStorage {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
return webLocalStorage;
|
||||
}
|
||||
// default to in-memory storage if we're not in the browser
|
||||
return inMemoryStorage;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @thirdweb-dev/wagmi-adapter
|
||||
|
||||
## 0.2.77
|
||||
|
||||
## 0.2.76
|
||||
|
||||
## 0.2.75
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user