Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de2a7ba6f5 | ||
|
|
ac42c4538e | ||
|
|
adda60c1e2 | ||
|
|
eb728869eb | ||
|
|
7456422936 | ||
|
|
aaa2dda48a | ||
|
|
be3570b2bf | ||
|
|
8f98a08bb6 | ||
|
|
6b6f61705d | ||
|
|
91cea2d7b7 | ||
|
|
d9a63a6d35 | ||
|
|
24f4e5acf5 | ||
|
|
b5baeae821 | ||
|
|
c02104d843 | ||
|
|
c0ab87dd5e | ||
|
|
f0c077c259 | ||
|
|
6e6d90756a | ||
|
|
e9ea47630f | ||
|
|
d98c133604 | ||
|
|
63c5277629 | ||
|
|
d66568467d | ||
|
|
6bd968362a | ||
|
|
17488b8139 | ||
|
|
776f5aaeda | ||
|
|
ecaa304a81 | ||
|
|
377f46c303 | ||
|
|
b52049c20a | ||
|
|
a67e980ac3 | ||
|
|
4c8333d4a3 | ||
|
|
e95ddcdd3e | ||
|
|
7973d6450c | ||
|
|
017b13f444 | ||
|
|
901c3a102b | ||
|
|
3253a532f8 | ||
|
|
0e5d120909 | ||
|
|
f368793375 | ||
|
|
b5227c9a8f | ||
|
|
0998fc737b | ||
|
|
3df0541582 |
@@ -2,6 +2,7 @@
|
||||
module.exports = [
|
||||
// -- home
|
||||
"/",
|
||||
"/pricing",
|
||||
// -- product landing pages --
|
||||
// -- connect
|
||||
"/connect/sign-in",
|
||||
@@ -16,6 +17,7 @@ module.exports = [
|
||||
"/solutions/gaming",
|
||||
"/solutions/consumer-apps",
|
||||
"/solutions/defi",
|
||||
"/solutions/ecosystem",
|
||||
// -- campaigns --
|
||||
"/unlimited-wallets",
|
||||
];
|
||||
|
||||
@@ -115,6 +115,7 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
|
||||
};
|
||||
|
||||
const baseNextConfig: NextConfig = {
|
||||
serverExternalPackages: ["pino-pretty"],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -48,8 +48,6 @@
|
||||
"@radix-ui/react-tooltip": "1.1.4",
|
||||
"@sentry/nextjs": "8.38.0",
|
||||
"@shazow/whatsabi": "^0.16.0",
|
||||
"@stripe/react-stripe-js": "^2.8.1",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
"@tanstack/react-query": "5.60.2",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
|
||||
@@ -265,6 +265,12 @@ async function redirects() {
|
||||
destination: "/account-abstraction",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /solutions/chains to /solutions/ecosystem
|
||||
{
|
||||
source: "/solutions/chains",
|
||||
destination: "/solutions/ecosystem",
|
||||
permanent: false,
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { API_SERVER_URL, getAbsoluteUrlFromPath } from "@/constants/env";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import type { ProductSKU } from "../lib/billing";
|
||||
|
||||
export type RedirectCheckoutOptions = {
|
||||
teamSlug: string;
|
||||
sku: ProductSKU;
|
||||
redirectPath?: string;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
export async function redirectToCheckout(
|
||||
options: RedirectCheckoutOptions,
|
||||
): Promise<{ status: number }> {
|
||||
if (!options.teamSlug) {
|
||||
return {
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-link`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sku: options.sku,
|
||||
redirectTo: getAbsoluteUrlFromPath(
|
||||
options.redirectPath ||
|
||||
`/team/${options.teamSlug}/~/settings/billing`,
|
||||
).toString(),
|
||||
metadata: options.metadata || {},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.result) {
|
||||
return {
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
|
||||
// redirect to the stripe checkout session
|
||||
redirect(json.result);
|
||||
}
|
||||
|
||||
export type BillingPortalOptions = {
|
||||
teamSlug: string | undefined;
|
||||
redirectPath?: string;
|
||||
};
|
||||
export async function redirectToBillingPortal(
|
||||
options: BillingPortalOptions,
|
||||
): Promise<{ status: number }> {
|
||||
if (!options.teamSlug) {
|
||||
return {
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return {
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-session-link`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
redirectTo: getAbsoluteUrlFromPath(
|
||||
options.redirectPath ||
|
||||
`/team/${options.teamSlug}/~/settings/billing`,
|
||||
).toString(),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.result) {
|
||||
return {
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
|
||||
// redirect to the stripe billing portal
|
||||
redirect(json.result);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function confirmEmailWithOTP(otp: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/account/confirmEmail`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirmationToken: otp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
return {
|
||||
errorMessage: json.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage: "Failed to confirm email",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getRawAccount } from "../../app/account/settings/getAccount";
|
||||
|
||||
export async function getRawAccountAction() {
|
||||
return getRawAccount();
|
||||
}
|
||||
@@ -12,7 +12,9 @@ export async function joinTeamWaitlist(options: {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No Auth token");
|
||||
return {
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist`, {
|
||||
@@ -27,8 +29,12 @@ export async function joinTeamWaitlist(options: {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to join waitlist");
|
||||
return {
|
||||
errorMessage: "Failed to join waitlist",
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use server";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function updateAccount(values: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No Auth token");
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/account`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
return {
|
||||
errorMessage: json.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage: "Failed To Update Account",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
import type { ProductSKU } from "../lib/billing";
|
||||
|
||||
type InvoiceLine = {
|
||||
// amount for this line item
|
||||
amount: number;
|
||||
// statement descriptor
|
||||
description: string | null;
|
||||
// the thirdweb product sku or null if it is not recognized
|
||||
thirdwebSku: ProductSKU | null;
|
||||
};
|
||||
|
||||
type Invoice = {
|
||||
// total amount excluding tax
|
||||
amount: number | null;
|
||||
// the ISO currency code (e.g. USD)
|
||||
currency: string;
|
||||
// the line items on the invoice
|
||||
lines: InvoiceLine[];
|
||||
};
|
||||
|
||||
export type TeamSubscription = {
|
||||
id: string;
|
||||
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
|
||||
status:
|
||||
| "incomplete"
|
||||
| "incomplete_expired"
|
||||
| "trialing"
|
||||
| "active"
|
||||
| "past_due"
|
||||
| "canceled"
|
||||
| "unpaid"
|
||||
| "paused";
|
||||
currentPeriodStart: string;
|
||||
currentPeriodEnd: string;
|
||||
trialStart: string | null;
|
||||
trialEnd: string | null;
|
||||
upcomingInvoice: Invoice;
|
||||
};
|
||||
|
||||
export async function getTeamSubscriptions(slug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const teamRes = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${slug}/subscriptions`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (teamRes.ok) {
|
||||
return (await teamRes.json())?.result as TeamSubscription[];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import "server-only";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
|
||||
export type Team = {
|
||||
@@ -13,9 +11,10 @@ export type Team = {
|
||||
deletedAt?: string;
|
||||
bannedAt?: string;
|
||||
image?: string;
|
||||
billingPlan: "pro" | "growth" | "free";
|
||||
billingPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingStatus: "validPayment" | (string & {}) | null;
|
||||
billingEmail: string | null;
|
||||
growthTrialEligible: boolean | null;
|
||||
};
|
||||
|
||||
export async function getTeamBySlug(slug: string) {
|
||||
@@ -37,14 +36,9 @@ export async function getTeamBySlug(slug: string) {
|
||||
}
|
||||
|
||||
export async function getTeams() {
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
: null;
|
||||
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
const teamsRes = await fetch(`${API_SERVER_URL}/v1/teams`, {
|
||||
@@ -55,7 +49,7 @@ export async function getTeams() {
|
||||
if (teamsRes.ok) {
|
||||
return (await teamsRes.json())?.result as Team[];
|
||||
}
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
type TeamNebulWaitList = {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TextDivider(props: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center text-muted-foreground text-sm",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<span className="h-[1px] flex-1 bg-border" />
|
||||
<span className="mx-4">{props.text}</span>
|
||||
<span className="h-[1px] flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type BillingPortalOptions,
|
||||
type RedirectCheckoutOptions,
|
||||
redirectToBillingPortal,
|
||||
redirectToCheckout,
|
||||
} from "../actions/billing";
|
||||
import { Button, type ButtonProps } from "./ui/button";
|
||||
|
||||
type CheckoutButtonProps = RedirectCheckoutOptions & ButtonProps;
|
||||
export function CheckoutButton({
|
||||
onClick,
|
||||
teamSlug,
|
||||
sku,
|
||||
metadata,
|
||||
redirectPath,
|
||||
children,
|
||||
...restProps
|
||||
}: CheckoutButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
{...restProps}
|
||||
onClick={async (e) => {
|
||||
onClick?.(e);
|
||||
await redirectToCheckout({
|
||||
teamSlug,
|
||||
sku,
|
||||
metadata,
|
||||
redirectPath,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type BillingPortalButtonProps = BillingPortalOptions & ButtonProps;
|
||||
export function BillingPortalButton({
|
||||
onClick,
|
||||
teamSlug,
|
||||
redirectPath,
|
||||
children,
|
||||
...restProps
|
||||
}: BillingPortalButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
{...restProps}
|
||||
onClick={async (e) => {
|
||||
onClick?.(e);
|
||||
await redirectToBillingPortal({
|
||||
teamSlug,
|
||||
redirectPath,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import { CheckoutButton } from "@/components/billing";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CircleDollarSignIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { TEAM_PLANS } from "utils/pricing";
|
||||
import { remainingDays } from "../../../utils/date-utils";
|
||||
|
||||
type ButtonProps = React.ComponentProps<typeof Button>;
|
||||
|
||||
const PRO_CONTACT_US_URL =
|
||||
"https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro";
|
||||
|
||||
type PricingCardProps = {
|
||||
teamSlug: string;
|
||||
billingPlan: Exclude<Team["billingPlan"], "free">;
|
||||
cta?: {
|
||||
hint?: string;
|
||||
title: string;
|
||||
tracking: {
|
||||
category: string;
|
||||
label?: string;
|
||||
};
|
||||
variant?: ButtonProps["variant"];
|
||||
};
|
||||
ctaHint?: string;
|
||||
highlighted?: boolean;
|
||||
current?: boolean;
|
||||
canTrialGrowth?: boolean;
|
||||
activeTrialEndsAt?: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
teamSlug,
|
||||
billingPlan,
|
||||
cta,
|
||||
highlighted = false,
|
||||
current = false,
|
||||
canTrialGrowth = false,
|
||||
activeTrialEndsAt,
|
||||
redirectPath,
|
||||
}) => {
|
||||
const plan = TEAM_PLANS[billingPlan];
|
||||
const isCustomPrice = typeof plan.price === "string";
|
||||
|
||||
const remainingTrialDays =
|
||||
(activeTrialEndsAt ? remainingDays(activeTrialEndsAt) : 0) || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-[999] flex w-full flex-col gap-6 rounded-xl border border-border bg-muted/50 p-4 md:p-6",
|
||||
current && "border-blue-500",
|
||||
)}
|
||||
style={
|
||||
highlighted
|
||||
? {
|
||||
backgroundImage:
|
||||
"linear-gradient(to top, hsl(var(--muted)) 30%, transparent)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Title + Desc */}
|
||||
<div>
|
||||
<div className="mb-2 flex flex-row items-center gap-2">
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">
|
||||
{plan.title}
|
||||
</h3>
|
||||
{current && <Badge className="capitalize">Current plan</Badge>}
|
||||
</div>
|
||||
<p className="max-w-[320px] text-muted-foreground">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-3xl text-foreground tracking-tight">
|
||||
{isCustomPrice ? (
|
||||
plan.price
|
||||
) : canTrialGrowth ? (
|
||||
<>
|
||||
<span className="text-muted-foreground line-through">
|
||||
${plan.price}
|
||||
</span>{" "}
|
||||
$0
|
||||
</>
|
||||
) : (
|
||||
`$${plan.price}`
|
||||
)}
|
||||
</span>
|
||||
|
||||
{!isCustomPrice && (
|
||||
<span className="text-muted-foreground">/ month</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{remainingTrialDays > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your free trial will{" "}
|
||||
{remainingTrialDays > 1
|
||||
? `end in ${remainingTrialDays} days.`
|
||||
: "end today."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col items-start gap-2.5 text-foreground">
|
||||
{plan.subTitle && (
|
||||
<p className="font-medium text-foreground">{plan.subTitle}</p>
|
||||
)}
|
||||
|
||||
{plan.features.map((f) => (
|
||||
<FeatureItem key={Array.isArray(f) ? f[0] : f} text={f} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{cta && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{billingPlan !== "pro" ? (
|
||||
<CheckoutButton
|
||||
variant={cta.variant || "outline"}
|
||||
teamSlug={teamSlug}
|
||||
sku={billingPlan === "starter" ? "plan:starter" : "plan:growth"}
|
||||
redirectPath={redirectPath}
|
||||
>
|
||||
{cta.title}
|
||||
</CheckoutButton>
|
||||
) : (
|
||||
<Button variant={cta.variant || "outline"} asChild>
|
||||
<TrackedLinkTW
|
||||
href={PRO_CONTACT_US_URL}
|
||||
label={cta.tracking?.label}
|
||||
category={cta.tracking?.category}
|
||||
target="_blank"
|
||||
>
|
||||
{cta.title}
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{cta.hint && (
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
{cta.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type FeatureItemProps = {
|
||||
text: string | string[];
|
||||
};
|
||||
|
||||
function FeatureItem({ text }: FeatureItemProps) {
|
||||
const titleStr = Array.isArray(text) ? text[0] : text;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckIcon className="size-4 shrink-0 text-green-500" />
|
||||
{Array.isArray(text) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-muted-foreground">
|
||||
{titleStr}{" "}
|
||||
<span className="text-muted-foreground md:hidden">{text[1]}</span>
|
||||
</p>
|
||||
<ToolTipLabel label={text[1]}>
|
||||
<CircleDollarSignIcon className="hidden size-4 text-muted-foreground md:block" />
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{titleStr}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function InlineCode({
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"inline-block rounded bg-muted px-2 font-mono text-sm",
|
||||
"mx-0.5 inline rounded-lg border border-border px-1.5 py-[3px] font-mono text-[0.85em] text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -33,3 +33,16 @@ export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
// Comma-separated list of chain IDs to disable faucet for.
|
||||
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
|
||||
|
||||
export const BASE_URL = isProd
|
||||
? "https://thirdweb.com"
|
||||
: (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
|
||||
: "http://localhost:3000") || "https://thirdweb-dev.com";
|
||||
|
||||
export function getAbsoluteUrlFromPath(path: string) {
|
||||
const url = new URL(BASE_URL);
|
||||
|
||||
url.pathname = path;
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// keep in line with product SKUs in the backend
|
||||
export type ProductSKU =
|
||||
| "plan:starter"
|
||||
| "plan:growth"
|
||||
| "plan:custom"
|
||||
| "product:ecosystem_wallets"
|
||||
| "product:engine_standard"
|
||||
| "product:engine_premium"
|
||||
| "usage:storage"
|
||||
| "usage:in_app_wallet"
|
||||
| "usage:aa_sponsorship"
|
||||
| "usage:aa_sponsorship_op_grant"
|
||||
| null;
|
||||
@@ -4,6 +4,7 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSDKTheme } from "app/components/sdk-component-theme";
|
||||
import { CustomChainRenderer } from "components/selects/CustomChainRenderer";
|
||||
import { mapV4ChainToV5Chain } from "contexts/map-chains";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
useConnectModal,
|
||||
} from "thirdweb/react";
|
||||
import { useFavoriteChainIds } from "../../../../app/(dashboard)/(chain)/components/client/star-button";
|
||||
import { doLogout } from "../../../../app/login/auth-actions";
|
||||
import { LazyConfigureNetworkModal } from "../../../../components/configure-networks/LazyConfigureNetworkModal";
|
||||
import { useAllChainsData } from "../../../../hooks/chains/allChains";
|
||||
import {
|
||||
@@ -35,6 +37,7 @@ export const CustomConnectWallet = (props: {
|
||||
connectButtonClassName?: string;
|
||||
signInLinkButtonClassName?: string;
|
||||
detailsButtonClassName?: string;
|
||||
loadingButtonClassName?: string;
|
||||
chain?: Chain;
|
||||
}) => {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
@@ -123,7 +126,12 @@ export const CustomConnectWallet = (props: {
|
||||
if (isPending) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[48px] w-[144px] items-center justify-center rounded-lg border border-border bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[48px] w-[144px] items-center justify-center rounded-lg border border-border bg-muted",
|
||||
props.loadingButtonClassName,
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
</>
|
||||
@@ -167,10 +175,7 @@ export const CustomConnectWallet = (props: {
|
||||
}}
|
||||
onDisconnect={async () => {
|
||||
try {
|
||||
// log out the user
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
await doLogout();
|
||||
} catch (err) {
|
||||
console.error("Failed to log out", err);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UserOpStats } from "@/api/analytics";
|
||||
import type { Team } from "@/api/team";
|
||||
import {
|
||||
type Query,
|
||||
useMutation,
|
||||
@@ -29,9 +30,6 @@ export const accountPlan = {
|
||||
enterprise: "enterprise",
|
||||
} as const;
|
||||
|
||||
type AccountStatus = (typeof accountStatus)[keyof typeof accountStatus];
|
||||
export type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
|
||||
|
||||
export type AuthorizedWallet = {
|
||||
id: string;
|
||||
accountId: string;
|
||||
@@ -47,30 +45,17 @@ export type Account = {
|
||||
id: string;
|
||||
isStaff: boolean;
|
||||
creatorWalletAddress: string;
|
||||
status: AccountStatus;
|
||||
plan: AccountPlan;
|
||||
name?: string;
|
||||
email?: string;
|
||||
advancedEnabled: boolean;
|
||||
currentBillingPeriodStartsAt: string;
|
||||
currentBillingPeriodEndsAt: string;
|
||||
emailConfirmedAt?: string;
|
||||
unconfirmedEmail?: string;
|
||||
trialPeriodEndedAt?: string;
|
||||
emailConfirmationWalletAddress?: string;
|
||||
stripePaymentActionUrl?: string;
|
||||
onboardSkipped?: boolean;
|
||||
paymentAttemptCount?: number;
|
||||
notificationPreferences?: {
|
||||
billing: "email" | "none";
|
||||
updates: "email" | "none";
|
||||
};
|
||||
recurringPaymentFailures: {
|
||||
subscriptionId: string;
|
||||
subscriptionDescription: string;
|
||||
paymentFailureCode: string;
|
||||
serviceCutoffDate: string;
|
||||
}[];
|
||||
// TODO - add image URL
|
||||
};
|
||||
|
||||
@@ -194,33 +179,16 @@ export interface UpdateKeyInput {
|
||||
redirectUrls: string[];
|
||||
}
|
||||
|
||||
interface UsageBundler {
|
||||
chainId: number;
|
||||
sumTransactionFee: string;
|
||||
}
|
||||
|
||||
interface UsageStorage {
|
||||
sumFileSizeBytes: number;
|
||||
}
|
||||
|
||||
interface UsageEmbeddedWallets {
|
||||
countWalletAddresses: number;
|
||||
}
|
||||
|
||||
export interface UsageBillableByService {
|
||||
usage: {
|
||||
bundler: UsageBundler[];
|
||||
storage: UsageStorage;
|
||||
embeddedWallets: UsageEmbeddedWallets;
|
||||
};
|
||||
billableUsd: {
|
||||
bundler: number;
|
||||
storage: number;
|
||||
embeddedWallets: number;
|
||||
};
|
||||
limits: {
|
||||
storage: number;
|
||||
embeddedWallets: number;
|
||||
};
|
||||
rateLimits: {
|
||||
storage: number;
|
||||
@@ -288,30 +256,6 @@ export function useAccount({ refetchInterval }: UseAccountInput = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAccountUsage() {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: accountKeys.usage(user?.address as string),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/usage`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data as UsageBillableByService;
|
||||
},
|
||||
enabled: !!user?.address && isLoggedIn,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAccountCredits() {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
@@ -498,51 +442,6 @@ export function useUpdateAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccountPlan(waitForWebhook?: boolean) {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: { plan: string; feedback?: string }) => {
|
||||
invariant(user?.address, "walletAddress is required");
|
||||
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/plan`, {
|
||||
method: "PUT",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
// Wait for account plan to update via stripe webhook
|
||||
// TODO: find a better way to notify the client that the plan has been updated
|
||||
if (waitForWebhook) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * 10));
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
return Promise.all([
|
||||
// invalidate usage data as limits are different
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.me(user?.address as string),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.usage(user?.address as string),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNotifications() {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -575,36 +474,6 @@ export function useUpdateNotifications() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBillingSession(enabled = false) {
|
||||
const { user } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: accountKeys.billingSession(user?.address as string),
|
||||
queryFn: async () => {
|
||||
invariant(user?.address, "walletAddress is required");
|
||||
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/account/billingSession`,
|
||||
{
|
||||
method: "GET",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfirmEmail() {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -627,7 +496,7 @@ export function useConfirmEmail() {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
return json.data as { team: Team; account: Account };
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// invalidate related cache, since could be relinking account
|
||||
@@ -681,40 +550,6 @@ export function useResendEmailConfirmation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePaymentMethod() {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (paymentMethodId: string) => {
|
||||
invariant(user?.address, "walletAddress is required");
|
||||
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/paymentMethod`, {
|
||||
method: "POST",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paymentMethodId,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.me(user?.address as string),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useApiKeys() {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
return useQuery({
|
||||
|
||||
+14
-18
@@ -13,16 +13,17 @@ import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
|
||||
import { Onboarding } from "components/onboarding";
|
||||
import { mapV4ChainToV5Chain } from "contexts/map-chains";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { toUnits } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
@@ -52,6 +53,7 @@ export function FaucetButton({
|
||||
chain: ChainMetadata;
|
||||
amount: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const client = useThirdwebClient();
|
||||
const address = useActiveAccount()?.address;
|
||||
const chainId = chain.chainId;
|
||||
@@ -118,7 +120,6 @@ export function FaucetButton({
|
||||
|
||||
const accountQuery = useAccount();
|
||||
const userQuery = useLoggedInUser();
|
||||
const [showOnboarding, setShowOnBoarding] = useState(false);
|
||||
|
||||
const canClaimFaucetQuery = useQuery({
|
||||
queryKey: ["testnet-faucet-can-claim", chainId],
|
||||
@@ -145,7 +146,8 @@ export function FaucetButton({
|
||||
return (
|
||||
<CustomConnectWallet
|
||||
loginRequired={true}
|
||||
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
|
||||
loadingButtonClassName="!w-full"
|
||||
signInLinkButtonClassName="!w-full !h-auto !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm hover:!bg-primary/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -201,23 +203,17 @@ export function FaucetButton({
|
||||
);
|
||||
}
|
||||
|
||||
// Email verification is required to claim from the faucet
|
||||
if (
|
||||
!accountQuery.data.emailConfirmedAt &&
|
||||
!accountQuery.data.unconfirmedEmail
|
||||
) {
|
||||
if (!isOnboardingComplete(accountQuery.data)) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!opacity-100 w-full"
|
||||
onClick={() => setShowOnBoarding(true)}
|
||||
<Button asChild className="w-full">
|
||||
<Link
|
||||
href={
|
||||
pathname ? `/login?next=${encodeURIComponent(pathname)}` : "/login"
|
||||
}
|
||||
>
|
||||
Verify your Email
|
||||
</Button>
|
||||
{/* We will show the modal only if the user click on it, because this is a public page */}
|
||||
{showOnboarding && <Onboarding onOpenChange={setShowOnBoarding} />}
|
||||
</>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { LazyCreateAPIKeyDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import {
|
||||
type AccountHeaderCompProps,
|
||||
AccountHeaderDesktopUI,
|
||||
@@ -27,11 +28,8 @@ export function AccountHeader(props: {
|
||||
const { disconnect } = useDisconnect();
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
// log out the user
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
await doLogout();
|
||||
if (wallet) {
|
||||
disconnect(wallet);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { ClientOnly } from "components/ClientOnly/ClientOnly";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import { Suspense } from "react";
|
||||
import { DeployedContractsPageHeader } from "../DeployedContractsPageHeader";
|
||||
import { DeployedContractsTable } from "./DeployedContractsTable";
|
||||
@@ -13,7 +12,6 @@ export function DeployedContractsPage(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<BillingAlerts className="mb-10" />
|
||||
<DeployedContractsPageHeader />
|
||||
<div className="h-6" />
|
||||
<Suspense fallback={<Loading />}>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { useAuthorizedWallets } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import { AuthorizedWalletsTable } from "components/settings/AuthorizedWallets/AuthorizedWalletsTable";
|
||||
|
||||
// TODO - remove ChakraProviderSetup after migrating AuthorizedWalletsTable
|
||||
@@ -13,7 +12,6 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<BillingAlerts className="mb-10" />
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col justify-between gap-4">
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { type Team, getTeams } from "@/api/team";
|
||||
import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import type React from "react";
|
||||
import { TabPathLinks } from "../../@/components/ui/tabs";
|
||||
import { TWAutoConnect } from "../components/autoconnect";
|
||||
import { loginRedirect } from "../login/loginRedirect";
|
||||
import { AccountHeader } from "./components/AccountHeader";
|
||||
|
||||
export default async function AccountLayout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const teams = await getTeams();
|
||||
if (!teams) {
|
||||
loginRedirect("/account");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="flex grow flex-col">
|
||||
<HeaderAndNav />
|
||||
<HeaderAndNav teams={teams} />
|
||||
{props.children}
|
||||
</div>
|
||||
<TWAutoConnect />
|
||||
@@ -21,11 +27,11 @@ export default async function AccountLayout(props: {
|
||||
);
|
||||
}
|
||||
|
||||
async function HeaderAndNav() {
|
||||
const teams = await getTeams();
|
||||
|
||||
async function HeaderAndNav(props: {
|
||||
teams: Team[];
|
||||
}) {
|
||||
const teamsAndProjects = await Promise.all(
|
||||
teams.map(async (team) => ({
|
||||
props.teams.map(async (team) => ({
|
||||
team,
|
||||
projects: await getProjects(team.slug),
|
||||
})),
|
||||
@@ -47,12 +53,12 @@ async function HeaderAndNav() {
|
||||
name: "Contracts",
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
path: "/account/settings",
|
||||
name: "Settings",
|
||||
},
|
||||
// TODO - enable these links after they are functional
|
||||
// {
|
||||
// path: "/account/settings",
|
||||
// name: "Settings",
|
||||
// },
|
||||
// {
|
||||
// path: "/account/wallets",
|
||||
// name: "Wallets",
|
||||
// },
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getMembers } from "@/api/team-members";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import { redirect } from "next/navigation";
|
||||
import { loginRedirect } from "../login/loginRedirect";
|
||||
import { AccountTeamsUI } from "./overview/AccountTeamsUI";
|
||||
import { getAccount } from "./settings/getAccount";
|
||||
import { getValidAccount } from "./settings/getAccount";
|
||||
|
||||
export default async function Page() {
|
||||
const account = await getAccount();
|
||||
|
||||
if (!account) {
|
||||
redirect("/login?next=/account");
|
||||
}
|
||||
|
||||
const account = await getValidAccount("/account");
|
||||
const teams = await getTeams();
|
||||
if (!teams) {
|
||||
loginRedirect("/account");
|
||||
}
|
||||
|
||||
const teamsWithRole = (
|
||||
await Promise.all(
|
||||
@@ -40,7 +37,6 @@ export default async function Page() {
|
||||
|
||||
return (
|
||||
<div className="container grow py-8">
|
||||
<BillingAlerts className="mb-10" />
|
||||
<AccountTeamsUI
|
||||
teamsWithRole={teamsWithRole}
|
||||
client={getThirdwebClient()}
|
||||
|
||||
@@ -1,37 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
|
||||
import { updateAccount } from "@/actions/updateAccount";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
|
||||
export function AccountSettingsPage(props: {
|
||||
account: Account;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
return (
|
||||
<div>
|
||||
<BillingAlerts className="mb-10" />
|
||||
<AccountSettingsPageUI
|
||||
account={props.account}
|
||||
updateAccountImage={async (file) => {
|
||||
if (file) {
|
||||
// upload to IPFS
|
||||
const ipfsUri = await upload({
|
||||
client: props.client,
|
||||
files: [file],
|
||||
});
|
||||
|
||||
// TODO - Implement updating the account image with uri
|
||||
console.log(ipfsUri);
|
||||
} else {
|
||||
// TODO - Implement deleting the account image
|
||||
}
|
||||
|
||||
throw new Error("Not implemented");
|
||||
}}
|
||||
/>
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container max-w-[950px]">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Account Settings
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container max-w-[950px] grow pt-8 pb-20">
|
||||
<AccountSettingsPageUI
|
||||
// TODO - remove hide props these when these fields are functional
|
||||
hideAvatar
|
||||
hideDeleteAccount
|
||||
account={props.account}
|
||||
updateEmailWithOTP={async (otp) => {
|
||||
const res = await confirmEmailWithOTP(otp);
|
||||
if (res?.errorMessage) {
|
||||
throw new Error(res.errorMessage);
|
||||
}
|
||||
router.refresh();
|
||||
}}
|
||||
updateName={async (name) => {
|
||||
const res = await updateAccount({ name });
|
||||
if (res?.errorMessage) {
|
||||
throw new Error(res.errorMessage);
|
||||
}
|
||||
router.refresh();
|
||||
}}
|
||||
// yes, this is weird -
|
||||
// to send OTP to email, we use updateAccount
|
||||
sendEmail={async (email) => {
|
||||
const res = await updateAccount({ email });
|
||||
if (res?.errorMessage) {
|
||||
throw new Error(res.errorMessage);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { mobileViewport } from "../../../stories/utils";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
|
||||
@@ -33,20 +34,63 @@ export const Mobile: Story = {
|
||||
};
|
||||
|
||||
function Variants() {
|
||||
const [isVerifiedEmail, setIsVerifiedEmail] = useState(true);
|
||||
const [sendEmailFails, setSendEmailFails] = useState(false);
|
||||
const [emailConfirmationFails, setEmailConfirmationFails] = useState(false);
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container mx-auto flex w-full max-w-[1132px] flex-col gap-10 py-10">
|
||||
<AccountSettingsPageUI
|
||||
account={{
|
||||
name: "John Doe",
|
||||
email: "[email protected]",
|
||||
}}
|
||||
updateAccountImage={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
/>
|
||||
<div className="container flex max-w-[1132px] flex-col gap-10 py-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
checked={isVerifiedEmail}
|
||||
onCheckedChange={(v) => setIsVerifiedEmail(!!v)}
|
||||
/>
|
||||
is Verified Email
|
||||
</CheckboxWithLabel>
|
||||
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
checked={sendEmailFails}
|
||||
onCheckedChange={(v) => setSendEmailFails(!!v)}
|
||||
/>
|
||||
Sending Email Fails
|
||||
</CheckboxWithLabel>
|
||||
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
checked={emailConfirmationFails}
|
||||
onCheckedChange={(v) => setEmailConfirmationFails(!!v)}
|
||||
/>
|
||||
Email Confirmation Fails
|
||||
</CheckboxWithLabel>
|
||||
</div>
|
||||
|
||||
<AccountSettingsPageUI
|
||||
account={{
|
||||
name: "John Doe",
|
||||
email: "[email protected]",
|
||||
emailConfirmedAt: isVerifiedEmail
|
||||
? new Date().toISOString()
|
||||
: undefined,
|
||||
}}
|
||||
updateEmailWithOTP={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (emailConfirmationFails) {
|
||||
throw new Error("Invalid OTP");
|
||||
}
|
||||
}}
|
||||
updateName={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
sendEmail={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (sendEmailFails) {
|
||||
throw new Error("Email already exists");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Toaster richColors />
|
||||
</ThirdwebProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,17 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { EllipsisIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -42,36 +49,44 @@ type MinimalAccount = Pick<
|
||||
|
||||
export function AccountSettingsPageUI(props: {
|
||||
account: MinimalAccount;
|
||||
updateAccountImage: (file: File | undefined) => Promise<void>;
|
||||
// TODO - remove hide props these when these fields are functional
|
||||
hideAvatar?: boolean;
|
||||
hideDeleteAccount?: boolean;
|
||||
sendEmail: (email: string) => Promise<void>;
|
||||
updateName: (name: string) => Promise<void>;
|
||||
updateEmailWithOTP: (otp: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<AccountAvatarFormControl updateAccountImage={props.updateAccountImage} />
|
||||
<AccountNameFormControl name={props.account.name || ""} />
|
||||
{!props.hideAvatar && <AccountAvatarFormControl />}
|
||||
<AccountNameFormControl
|
||||
name={props.account.name || ""}
|
||||
updateName={(name) => props.updateName(name)}
|
||||
/>
|
||||
<AccountEmailFormControl
|
||||
email={props.account.email || ""}
|
||||
// TODO - is this correct way to check this?
|
||||
status={props.account.emailConfirmedAt ? "verified" : "unverified"}
|
||||
sendEmail={props.sendEmail}
|
||||
updateEmailWithOTP={props.updateEmailWithOTP}
|
||||
/>
|
||||
|
||||
<DeleteAccountCard />
|
||||
{!props.hideDeleteAccount && <DeleteAccountCard />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountAvatarFormControl(props: {
|
||||
updateAccountImage: (file: File | undefined) => Promise<void>;
|
||||
}) {
|
||||
const [avatar, setAvatar] = useState<File>(); // TODO: prefill with account avatar
|
||||
function AccountAvatarFormControl() {
|
||||
const [avatar, setAvatar] = useState<File>();
|
||||
|
||||
// TODO - implement
|
||||
const updateAvatarMutation = useMutation({
|
||||
mutationFn: async (_avatar: File | undefined) => {
|
||||
await props.updateAccountImage(_avatar);
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const promises = updateAvatarMutation.mutateAsync(avatar);
|
||||
const promises = updateAvatarMutation.mutateAsync();
|
||||
toast.promise(promises, {
|
||||
success: "Account avatar updated successfully",
|
||||
error: "Failed to update account avatar",
|
||||
@@ -111,17 +126,13 @@ function AccountAvatarFormControl(props: {
|
||||
|
||||
function AccountNameFormControl(props: {
|
||||
name: string;
|
||||
updateName: (name: string) => Promise<void>;
|
||||
}) {
|
||||
const [accountName, setAccountName] = useState(props.name);
|
||||
const maxAccountNameLength = 32;
|
||||
|
||||
// TODO - implement
|
||||
const updateAccountNameMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Updating account name to", name);
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
mutationFn: props.updateName,
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
@@ -203,9 +214,10 @@ function DeleteAccountCard() {
|
||||
function AccountEmailFormControl(props: {
|
||||
email: string;
|
||||
status: "unverified" | "verfication-sent" | "verified";
|
||||
sendEmail: (email: string) => Promise<void>;
|
||||
updateEmailWithOTP: (otp: string) => Promise<void>;
|
||||
}) {
|
||||
// TODO - query for account changes when the email is updated
|
||||
|
||||
const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);
|
||||
return (
|
||||
<SettingsCard
|
||||
header={{
|
||||
@@ -230,9 +242,8 @@ function AccountEmailFormControl(props: {
|
||||
</div>
|
||||
|
||||
{/* End */}
|
||||
<EmailUpdateDialog
|
||||
currentEmail={props.email}
|
||||
trigger={
|
||||
<Dialog open={isEmailModalOpen} onOpenChange={setIsEmailModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -240,8 +251,18 @@ function AccountEmailFormControl(props: {
|
||||
>
|
||||
<EllipsisIcon className="size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="p-0">
|
||||
<EmailUpdateDialogContent
|
||||
currentEmail={props.email}
|
||||
sendEmail={props.sendEmail}
|
||||
updateEmailWithOTP={props.updateEmailWithOTP}
|
||||
onSuccess={() => {
|
||||
setIsEmailModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
@@ -251,9 +272,36 @@ const emailUpdateFormSchema = z.object({
|
||||
email: z.string().min(1, "Email can not be empty").max(100),
|
||||
});
|
||||
|
||||
function EmailUpdateDialog(props: {
|
||||
function EmailUpdateDialogContent(props: {
|
||||
currentEmail: string;
|
||||
trigger: React.ReactNode;
|
||||
sendEmail: (email: string) => Promise<void>;
|
||||
updateEmailWithOTP: (otp: string) => Promise<void>;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [isEmailSent, setIsEmailSent] = useState(false);
|
||||
|
||||
if (isEmailSent) {
|
||||
return (
|
||||
<EnterEmailOTP
|
||||
updateEmailWithOTP={props.updateEmailWithOTP}
|
||||
onSuccess={props.onSuccess}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SendEmailOTP
|
||||
onEmailSent={() => setIsEmailSent(true)}
|
||||
currentEmail={props.currentEmail}
|
||||
sendEmail={props.sendEmail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SendEmailOTP(props: {
|
||||
onEmailSent: () => void;
|
||||
currentEmail: string;
|
||||
sendEmail: (email: string) => Promise<void>;
|
||||
}) {
|
||||
const form = useForm<z.infer<typeof emailUpdateFormSchema>>({
|
||||
resolver: zodResolver(emailUpdateFormSchema),
|
||||
@@ -262,71 +310,153 @@ function EmailUpdateDialog(props: {
|
||||
},
|
||||
});
|
||||
|
||||
// TODO - implement
|
||||
const updateEmailMutation = useMutation({
|
||||
mutationFn: async (_email: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Updating account email to", _email);
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
const [showSendError, setShowSendError] = useState(false);
|
||||
const sendEmail = useMutation({
|
||||
mutationFn: props.sendEmail,
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof emailUpdateFormSchema>) {
|
||||
const promises = updateEmailMutation.mutateAsync(values.email);
|
||||
toast.promise(promises, {
|
||||
success: "Email updated successfully",
|
||||
error: "Failed to update email",
|
||||
sendEmail.mutateAsync(values.email, {
|
||||
onSuccess: () => {
|
||||
props.onEmailSent();
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
setShowSendError(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{props.trigger}</DialogTrigger>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-4 p-6 pb-10">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="text-2xl">Update Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
A confirmation email will be sent to verify email address
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-4 p-6 pb-10">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="text-2xl">Update Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
A confirmation email will be sent to verify email address
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
{...field}
|
||||
className="selection:bg-foreground/20"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setShowSendError(false);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{showSendError && (
|
||||
<p className="text-destructive-text">
|
||||
{sendEmail.error?.message || "Failed to send email"}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-4 border-t bg-muted/50 p-6 lg:gap-1">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline"> Cancel </Button>
|
||||
</DialogClose>
|
||||
<Button className="min-w-24 gap-2" type="submit">
|
||||
{updateEmailMutation.isPending && (
|
||||
<Spinner className="size-4" />
|
||||
)}
|
||||
{updateEmailMutation.isPending ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogFooter className="gap-4 border-t bg-muted/50 p-6 lg:gap-1">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline"> Cancel </Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
className="min-w-24 gap-2"
|
||||
type="submit"
|
||||
disabled={!form.formState.isDirty}
|
||||
>
|
||||
{sendEmail.isPending && <Spinner className="size-4" />}
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function EnterEmailOTP(props: {
|
||||
updateEmailWithOTP: (otp: string) => Promise<void>;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [otp, setOtp] = useState("");
|
||||
const [showOTPError, setShowOTPError] = useState(false);
|
||||
const updateEmail = useMutation({
|
||||
mutationFn: props.updateEmailWithOTP,
|
||||
onSuccess: () => {
|
||||
props.onSuccess();
|
||||
toast.success("Email updated successfully");
|
||||
},
|
||||
onError: () => {
|
||||
setShowOTPError(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col p-6 pb-10">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="text-2xl">Update Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the OTP sent to new email address
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
value={otp}
|
||||
onChange={(v) => {
|
||||
setOtp(v);
|
||||
setShowOTPError(false);
|
||||
}}
|
||||
disabled={updateEmail.isPending}
|
||||
>
|
||||
<InputOTPGroup className="w-full">
|
||||
{new Array(6).fill(0).map((_, idx) => (
|
||||
<InputOTPSlot
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static list
|
||||
key={idx}
|
||||
index={idx}
|
||||
className={cn("h-12 grow text-lg", {
|
||||
"border-red-500": showOTPError,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
|
||||
{showOTPError && (
|
||||
<p className="mt-3 text-center text-destructive-text">
|
||||
Failed to verify email with this OTP
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-4 border-t bg-muted/50 p-6 lg:gap-1">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline"> Cancel </Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
className="min-w-24 gap-2"
|
||||
onClick={() => {
|
||||
updateEmail.mutate(otp);
|
||||
}}
|
||||
>
|
||||
{updateEmail.isPending && <Spinner className="size-4" />}
|
||||
Verify
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { getAuthToken } from "../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
import { isOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
export async function getAccount() {
|
||||
/**
|
||||
* Just get the account object without enforcing onboarding.
|
||||
* In most cases - you should just be using `getValidAccount`
|
||||
*/
|
||||
export async function getRawAccount() {
|
||||
const authToken = await getAuthToken();
|
||||
const apiServerURL = new URL(API_SERVER_URL);
|
||||
|
||||
apiServerURL.pathname = "/v1/account/me";
|
||||
if (!authToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const res = await fetch(apiServerURL, {
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/account/me`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -24,3 +31,18 @@ export async function getAccount() {
|
||||
|
||||
return json.data as Account;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there's no account or account onboarding not complete, redirect to login page
|
||||
* @param pagePath - the path of the current page to redirect back to after login/onboarding
|
||||
*/
|
||||
export async function getValidAccount(pagePath: string) {
|
||||
const account = await getRawAccount();
|
||||
|
||||
// enforce login & onboarding
|
||||
if (!account || !isOnboardingComplete(account)) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
import { AccountSettingsPage } from "./AccountSettingsPage";
|
||||
import { getAccount } from "./getAccount";
|
||||
import { getValidAccount } from "./getAccount";
|
||||
|
||||
export default async function Page() {
|
||||
const account = await getAccount();
|
||||
const pagePath = "/account";
|
||||
const account = await getValidAccount(pagePath);
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!account || !token) {
|
||||
redirect(`/login?next=${encodeURIComponent("/account")}`);
|
||||
if (!token) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import { LinkWalletUI } from "./LinkWalletUI";
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="grow">
|
||||
<BillingAlerts className="mb-10" />
|
||||
<LinkWalletUI
|
||||
// TODO - fetch the wallets of current user
|
||||
wallets={[]}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const POST = async () => {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
// delete all cookies that start with the token prefix
|
||||
const allCookies = cookieStore.getAll();
|
||||
for (const cookie of allCookies) {
|
||||
if (cookie.name.startsWith(COOKIE_PREFIX_TOKEN)) {
|
||||
cookieStore.delete(cookie.name);
|
||||
}
|
||||
}
|
||||
// also delete the active account cookie
|
||||
cookieStore.delete(COOKIE_ACTIVE_ACCOUNT);
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export const POST = async (req: NextRequest) => {
|
||||
const account: { data: Account } = await accountRes.json();
|
||||
|
||||
// Make sure the logged-in account has verified its email
|
||||
if (account.data.status === "noCustomer") {
|
||||
if (!account.data.email) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Account owner hasn't verified email",
|
||||
|
||||
@@ -39,6 +39,7 @@ export function SecondaryNavLinks() {
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TeamPlanBadge(props: {
|
||||
plan: "free" | "growth" | "pro";
|
||||
plan: Team["billingPlan"];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
props.plan === "free"
|
||||
props.plan === "free" || props.plan === "starter"
|
||||
? "secondary"
|
||||
: props.plan === "growth"
|
||||
? "success"
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { getRawAccountAction } from "@/actions/getAccount";
|
||||
import { ColorModeToggle } from "@/components/color-mode-toggle";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import { ConnectEmbed, useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import { createWallet, inAppWallet } from "thirdweb/wallets";
|
||||
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
|
||||
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
|
||||
import { getSDKTheme } from "../components/sdk-component-theme";
|
||||
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
|
||||
import { isOnboardingComplete } from "./onboarding/isOnboardingRequired";
|
||||
|
||||
const LazyOnboardingUI = lazy(
|
||||
() => import("./onboarding/on-boarding-ui.client"),
|
||||
);
|
||||
|
||||
const wallets = [
|
||||
inAppWallet({
|
||||
auth: {
|
||||
options: [
|
||||
"google",
|
||||
"apple",
|
||||
"facebook",
|
||||
"github",
|
||||
"email",
|
||||
"phone",
|
||||
"passkey",
|
||||
],
|
||||
},
|
||||
}),
|
||||
createWallet("io.metamask"),
|
||||
createWallet("com.coinbase.wallet"),
|
||||
createWallet("io.rabby"),
|
||||
createWallet("me.rainbow"),
|
||||
createWallet("io.zerion.wallet"),
|
||||
];
|
||||
|
||||
export function LoginAndOnboardingPage(props: {
|
||||
account: Account | undefined;
|
||||
nextPath: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col overflow-hidden bg-background">
|
||||
<div className="border-b bg-background">
|
||||
<header className="container flex w-full flex-row items-center justify-between px-6 py-4">
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<ThirdwebMiniLogo className="size-7 md:size-8" />
|
||||
<h1 className="font-medium text-lg tracking-tight md:text-xl">
|
||||
Get started <span className="max-sm:hidden">with thirdweb</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/support"
|
||||
target="_blank"
|
||||
className="px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/"
|
||||
className="px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<ColorModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main className="container z-10 flex grow flex-col items-center justify-center gap-6 py-12">
|
||||
<ClientOnly ssr={<LoadingCard />}>
|
||||
<PageContent nextPath={props.nextPath} account={props.account} />
|
||||
</ClientOnly>
|
||||
</main>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
src="/assets/login/background.svg"
|
||||
className="-bottom-12 -right-12 pointer-events-none fixed lg:right-0 lg:bottom-0"
|
||||
/>
|
||||
|
||||
<Aurora
|
||||
color="hsl(var(--foreground)/9%)"
|
||||
pos={{ top: "55%", left: "50%" }}
|
||||
size={{ width: "1400px", height: "1300px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingCard() {
|
||||
return (
|
||||
<div className="flex min-h-[450px] w-full items-center justify-center rounded-xl border border-border bg-background shadow-lg lg:w-[500px]">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageContent(props: {
|
||||
nextPath: string | undefined;
|
||||
account: Account | undefined;
|
||||
}) {
|
||||
const [screen, setScreen] = useState<
|
||||
| { id: "login" }
|
||||
| {
|
||||
id: "onboarding";
|
||||
account: Account;
|
||||
}
|
||||
| {
|
||||
id: "complete";
|
||||
}
|
||||
>(
|
||||
props.account
|
||||
? { id: "onboarding", account: props.account }
|
||||
: { id: "login" },
|
||||
);
|
||||
|
||||
const router = useDashboardRouter();
|
||||
const connectionStatus = useActiveWalletConnectionStatus();
|
||||
|
||||
function onComplete() {
|
||||
setScreen({ id: "complete" });
|
||||
if (props.nextPath && isValidRedirectPath(props.nextPath)) {
|
||||
router.replace(props.nextPath);
|
||||
} else {
|
||||
router.replace("/team");
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionStatus === "connecting") {
|
||||
return <LoadingCard />;
|
||||
}
|
||||
|
||||
if (connectionStatus !== "connected" || screen.id === "login") {
|
||||
return <CustomConnectEmbed onLogin={onLogin} />;
|
||||
}
|
||||
|
||||
if (screen.id === "onboarding") {
|
||||
return (
|
||||
<Suspense fallback={<LoadingCard />}>
|
||||
<LazyOnboardingUI
|
||||
account={screen.account}
|
||||
onComplete={onComplete}
|
||||
redirectPath={props.nextPath || "/team"}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
const account = await getRawAccountAction();
|
||||
|
||||
// shouldn't happen - but if account is not found, stay on login page
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOnboardingComplete(account)) {
|
||||
setScreen({
|
||||
id: "onboarding",
|
||||
account,
|
||||
});
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
|
||||
return <LoadingCard />;
|
||||
}
|
||||
|
||||
function CustomConnectEmbed(props: {
|
||||
onLogin: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const client = useThirdwebClient();
|
||||
|
||||
return (
|
||||
<ConnectEmbed
|
||||
auth={{
|
||||
getLoginPayload,
|
||||
doLogin: async (params) => {
|
||||
try {
|
||||
await doLogin(params);
|
||||
props.onLogin();
|
||||
} catch (e) {
|
||||
console.error("Failed to login", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
doLogout,
|
||||
isLoggedIn: async (x) => {
|
||||
const isLoggedInResult = await isLoggedIn(x);
|
||||
if (isLoggedInResult) {
|
||||
props.onLogin();
|
||||
}
|
||||
return isLoggedInResult;
|
||||
},
|
||||
}}
|
||||
wallets={wallets}
|
||||
client={client}
|
||||
modalSize="wide"
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
className="shadow-lg"
|
||||
privacyPolicyUrl="/privacy"
|
||||
termsOfServiceUrl="/tos"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isValidRedirectPath(encodedPath: string): boolean {
|
||||
try {
|
||||
// Decode the URI component
|
||||
const decodedPath = decodeURIComponent(encodedPath);
|
||||
// ensure the path always starts with a _single_ slash
|
||||
// double slash could be interpreted as `//example.com` which is not allowed
|
||||
return decodedPath.startsWith("/") && !decodedPath.startsWith("//");
|
||||
} catch {
|
||||
// If decoding fails, return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type AuroraProps = {
|
||||
size: { width: string; height: string };
|
||||
pos: { top: string; left: string };
|
||||
color: string;
|
||||
};
|
||||
|
||||
const Aurora: React.FC<AuroraProps> = ({ color, pos, size }) => {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
transform: "translate(-50%, -50%)",
|
||||
backgroundImage: `radial-gradient(ellipse at center, ${color}, transparent 60%)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -44,6 +44,18 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const utmCookies = cookieStore
|
||||
.getAll()
|
||||
.filter((cookie) => {
|
||||
return cookie.name.startsWith("utm_");
|
||||
})
|
||||
.reduce(
|
||||
(acc, cookie) => {
|
||||
acc[cookie.name] = cookie.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
// forward the request to the API server
|
||||
const res = await fetch(`${API_SERVER_URL}/v2/siwe/login`, {
|
||||
@@ -53,7 +65,7 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
"x-service-api-key": THIRDWEB_API_SECRET,
|
||||
},
|
||||
// set the createAccount flag to true to create a new account if it does not exist
|
||||
body: JSON.stringify({ ...payload, createAccount: true }),
|
||||
body: JSON.stringify({ ...payload, createAccount: true, utm: utmCookies }),
|
||||
});
|
||||
|
||||
// if the request failed, log the error and throw an error
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export function loginRedirect(path: string): never {
|
||||
redirect(`/login?next=${encodeURIComponent(path)}`);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type Account, useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
type AccountValidationSchema,
|
||||
accountValidationSchema,
|
||||
} from "./validations";
|
||||
|
||||
interface AccountFormProps {
|
||||
account: Account;
|
||||
horizontal?: boolean;
|
||||
showSubscription?: boolean;
|
||||
hideName?: boolean;
|
||||
buttonText?: string;
|
||||
padded?: boolean;
|
||||
trackingCategory?: string;
|
||||
disableUnchanged?: boolean;
|
||||
onSave?: (email: string) => void;
|
||||
onDuplicateError?: (email: string) => void;
|
||||
}
|
||||
|
||||
export const AccountForm: React.FC<AccountFormProps> = ({
|
||||
account,
|
||||
onSave,
|
||||
onDuplicateError,
|
||||
buttonText = "Save",
|
||||
hideName = false,
|
||||
showSubscription = false,
|
||||
disableUnchanged = false,
|
||||
}) => {
|
||||
const [isSubscribing, setIsSubscribing] = useState(true);
|
||||
const trackEvent = useTrack();
|
||||
const form = useForm<AccountValidationSchema>({
|
||||
resolver: zodResolver(accountValidationSchema),
|
||||
defaultValues: {
|
||||
name: account.name || "",
|
||||
email: account.unconfirmedEmail || account.email || "",
|
||||
},
|
||||
values: {
|
||||
name: account.name || "",
|
||||
email: account.unconfirmedEmail || account.email || "",
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useUpdateAccount();
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
const formData = {
|
||||
...values,
|
||||
...(showSubscription
|
||||
? {
|
||||
subscribeToUpdates: isSubscribing,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "update",
|
||||
label: "attempt",
|
||||
data: formData,
|
||||
});
|
||||
|
||||
updateMutation.mutate(formData, {
|
||||
onSuccess: (data) => {
|
||||
if (onSave) {
|
||||
onSave(values.email);
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "update",
|
||||
label: "success",
|
||||
data,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
|
||||
if (
|
||||
onDuplicateError &&
|
||||
error?.message.match(/email address already exists/)
|
||||
) {
|
||||
onDuplicateError(values.email);
|
||||
}
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "update",
|
||||
label: "error",
|
||||
error,
|
||||
fromOnboarding: !!onDuplicateError,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex w-full flex-col items-start gap-8 rounded-xl">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
htmlFor="email"
|
||||
errorMessage={
|
||||
form.getFieldState("email", form.formState).error?.message
|
||||
}
|
||||
label="Email"
|
||||
>
|
||||
<Input
|
||||
placeholder="[email protected]"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
id="email"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
{!hideName && (
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
form.getFieldState("name", form.formState).error?.message
|
||||
}
|
||||
label="Name"
|
||||
htmlFor="name"
|
||||
isRequired={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="Company Inc."
|
||||
type="text"
|
||||
{...form.register("name")}
|
||||
id="name"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
)}
|
||||
|
||||
{showSubscription && (
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
checked={isSubscribing}
|
||||
onCheckedChange={(v) => setIsSubscribing(!!v)}
|
||||
/>
|
||||
Subscribe to new features and key product updates
|
||||
</CheckboxWithLabel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
(disableUnchanged && !form.formState.isDirty)
|
||||
}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{updateMutation.isPending && <Spinner className="size-4" />}
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { TextDivider } from "@/components/TextDivider";
|
||||
import { PricingCard } from "@/components/blocks/pricing-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TitleAndDescription } from "./Title";
|
||||
|
||||
export function OnboardingChoosePlan(props: {
|
||||
skipPlan: () => Promise<void>;
|
||||
canTrialGrowth: boolean;
|
||||
teamSlug: string;
|
||||
redirectPath: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<TitleAndDescription
|
||||
heading="Choose your Plan"
|
||||
description="Get started for free with our Starter plan or subscribe to Growth plan to unlock higher rate limits and advanced features"
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-5">
|
||||
<PricingCard
|
||||
billingPlan="starter"
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get started for free",
|
||||
tracking: {
|
||||
category: "account",
|
||||
},
|
||||
}}
|
||||
redirectPath={props.redirectPath}
|
||||
/>
|
||||
|
||||
<PricingCard
|
||||
billingPlan="growth"
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Claim your 1-month free",
|
||||
hint: "Your free trial will end after 30 days.",
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "growthPlan",
|
||||
},
|
||||
variant: "default",
|
||||
}}
|
||||
canTrialGrowth={props.canTrialGrowth}
|
||||
highlighted
|
||||
redirectPath={props.redirectPath}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextDivider text="OR" className="my-4" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.skipPlan}
|
||||
className="relative h-auto w-full items-center gap-2 rounded-xl bg-muted/50 py-2.5"
|
||||
>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-base text-foreground">Skip for now</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
You will have limited access to services
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+87
-90
@@ -1,3 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
@@ -5,43 +10,48 @@ import {
|
||||
} from "@/components/ui/input-otp";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type Account,
|
||||
useConfirmEmail,
|
||||
useResendEmailConfirmation,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
type EmailConfirmationValidationSchema,
|
||||
emailConfirmationValidationSchema,
|
||||
} from "components/settings/Account/validations";
|
||||
import { useErrorHandler } from "contexts/error-handler";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button, Text } from "tw-components";
|
||||
import { toast } from "sonner";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
import { TitleAndDescription } from "./Title";
|
||||
import {
|
||||
type EmailConfirmationValidationSchema,
|
||||
emailConfirmationValidationSchema,
|
||||
} from "./validations";
|
||||
|
||||
interface OnboardingConfirmEmailProps {
|
||||
email: string;
|
||||
linking?: boolean;
|
||||
onSave: () => void;
|
||||
onEmailConfirm: (params: {
|
||||
team: Team;
|
||||
account: Account;
|
||||
}) => void;
|
||||
onComplete: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
// TODO - separate out "linking" and "confirmLinking" states into separate components
|
||||
|
||||
export const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
email,
|
||||
linking,
|
||||
onSave,
|
||||
onEmailConfirm,
|
||||
onBack,
|
||||
onComplete,
|
||||
}) => {
|
||||
const [token, setToken] = useState("");
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { onError } = useErrorHandler();
|
||||
const trackEvent = useTrack();
|
||||
const { user } = useLoggedInUser();
|
||||
|
||||
@@ -62,7 +72,7 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useConfirmEmail();
|
||||
const confirmEmail = useConfirmEmail();
|
||||
const resendMutation = useResendEmailConfirmation();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
@@ -81,10 +91,10 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
mutation.mutate(values, {
|
||||
onSuccess: () => {
|
||||
confirmEmail.mutate(values, {
|
||||
onSuccess: (response) => {
|
||||
if (!linking) {
|
||||
onSave();
|
||||
onEmailConfirm(response);
|
||||
} else {
|
||||
setCompleted(true);
|
||||
}
|
||||
@@ -103,7 +113,7 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
? error.message
|
||||
: "Couldn't verify your email address. Try later!";
|
||||
|
||||
onError(message);
|
||||
toast.error(message);
|
||||
form.reset();
|
||||
setToken("");
|
||||
setSaving(false);
|
||||
@@ -159,7 +169,7 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
<TitleAndDescription
|
||||
heading={
|
||||
!linking
|
||||
? "You're almost done! Verify your email."
|
||||
? "Verify your email"
|
||||
: completed
|
||||
? "Wallet linked"
|
||||
: "Linking Wallets"
|
||||
@@ -167,97 +177,84 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
description={
|
||||
!completed ? (
|
||||
<>
|
||||
We've sent a 6 letter confirmation code to{" "}
|
||||
<strong>{email}</strong>. Copy the code and paste it below.
|
||||
Enter the 6 letter confirmation code sent to{" "}
|
||||
<span className="text-medium">{email}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
We've linked{" "}
|
||||
<strong>{shortenString(user?.address ?? "")}</strong> wallet to{" "}
|
||||
<strong>{email}</strong> thirdweb account.
|
||||
We've linked{" "}
|
||||
<span className="font-mono">
|
||||
{shortenString(user?.address ?? "")}
|
||||
</span>{" "}
|
||||
wallet to <span className="font-medium">{email}</span> thirdweb
|
||||
account.
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
{completed && (
|
||||
<Button
|
||||
w="full"
|
||||
size="lg"
|
||||
fontSize="md"
|
||||
onClick={onSave}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<Button onClick={onComplete} className="w-full">
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!completed && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Flex gap={8} flexDir="column" w="full">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
value={token}
|
||||
onChange={handleChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<InputOTPGroup className="w-full">
|
||||
{new Array(6).fill(0).map((_, idx) => (
|
||||
<InputOTPSlot
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
key={idx}
|
||||
index={idx}
|
||||
className={cn("h-12 grow text-lg", {
|
||||
"border-red-500": form.getFieldState(
|
||||
"confirmationToken",
|
||||
form.formState,
|
||||
).error,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
value={token}
|
||||
onChange={handleChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<InputOTPGroup className="w-full">
|
||||
{new Array(6).fill(0).map((_, idx) => (
|
||||
<InputOTPSlot
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
key={idx}
|
||||
index={idx}
|
||||
className={cn("h-12 grow text-lg", {
|
||||
"border-red-500": form.getFieldState(
|
||||
"confirmationToken",
|
||||
form.formState,
|
||||
).error,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
|
||||
<Flex flexDir="column" gap={3}>
|
||||
<Button
|
||||
w="full"
|
||||
size="lg"
|
||||
fontSize="md"
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
isLoading={saving}
|
||||
isDisabled={saving}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
w="full"
|
||||
size="lg"
|
||||
fontSize="md"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
isDisabled={saving}
|
||||
>
|
||||
{!linking ? "Update email" : "Use another email"}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
fontSize="sm"
|
||||
variant="link"
|
||||
onClick={handleResend}
|
||||
colorScheme="blue"
|
||||
isDisabled={saving}
|
||||
>
|
||||
<Text color="blue.500">Resend verification code</Text>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="gap-2"
|
||||
>
|
||||
{saving && <Spinner className="size-4" />}
|
||||
Verify
|
||||
</Button>
|
||||
|
||||
<Button onClick={onBack} disabled={saving} variant="outline">
|
||||
{!linking ? "Update email" : "Use another email"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleResend}
|
||||
disabled={saving}
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Resend Verification Code
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingConfirmEmail;
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { doLogout } from "../auth-actions";
|
||||
import { AccountForm } from "./AccountForm";
|
||||
import { TitleAndDescription } from "./Title";
|
||||
|
||||
type OnboardingGeneralProps = {
|
||||
account: Account;
|
||||
onSave: (email: string) => void;
|
||||
onDuplicate: (email: string) => void;
|
||||
};
|
||||
|
||||
export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
|
||||
account,
|
||||
onSave,
|
||||
onDuplicate,
|
||||
}) => {
|
||||
const [existing, setExisting] = useState(false);
|
||||
const activeWallet = useActiveWallet();
|
||||
const { disconnect } = useDisconnect();
|
||||
|
||||
async function handleLogout() {
|
||||
await doLogout();
|
||||
if (activeWallet) {
|
||||
disconnect(activeWallet);
|
||||
}
|
||||
}
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: handleLogout,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TitleAndDescription
|
||||
heading={
|
||||
!existing
|
||||
? "Create your thirdweb account"
|
||||
: "Log in to your thirdweb account"
|
||||
}
|
||||
description="Start building web3 apps and games, faster."
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<AccountForm
|
||||
showSubscription={!existing}
|
||||
hideName={existing}
|
||||
account={account}
|
||||
buttonText={!existing ? "Get Started for Free" : "Login"}
|
||||
trackingCategory="onboarding"
|
||||
onSave={onSave}
|
||||
onDuplicateError={onDuplicate}
|
||||
/>
|
||||
|
||||
{!existing ? (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setExisting(true)}>
|
||||
I have a thirdweb account
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
logoutMutation.mutate();
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
{logoutMutation.isPending && <Spinner className="size-4" />}
|
||||
Log out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setExisting(false)}>
|
||||
I don't have an account
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+39
-36
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { Button, TrackedLink } from "tw-components";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
import { TitleAndDescription } from "./Title";
|
||||
|
||||
@@ -11,7 +15,7 @@ interface OnboardingLinkWalletProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
|
||||
export const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
|
||||
email,
|
||||
onSave,
|
||||
onBack,
|
||||
@@ -69,52 +73,51 @@ const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
|
||||
description={
|
||||
<>
|
||||
We've noticed that there is another account associated with{" "}
|
||||
<strong>{email}</strong>. Would you like to link your wallet{" "}
|
||||
<strong>{shortenString(user?.address ?? "")}</strong> to the
|
||||
existing account? Once you agree, we will email you the details.{" "}
|
||||
<TrackedLink
|
||||
<span className="text-foreground">{email}</span>. Would you like to
|
||||
link your wallet{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{shortenString(user?.address ?? "")}
|
||||
</span>{" "}
|
||||
to the existing account?
|
||||
<div className="h-2" />
|
||||
Once you agree, we will email you the details.{" "}
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/account/billing/account-info"
|
||||
color="blue.500"
|
||||
category="account"
|
||||
label="learn-wallet-linking"
|
||||
isExternal
|
||||
target="_blank"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
Learn more about wallet linking
|
||||
</TrackedLink>
|
||||
</TrackedLinkTW>
|
||||
.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<form>
|
||||
<div className="flex w-full flex-col gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
w="full"
|
||||
size="lg"
|
||||
fontSize="md"
|
||||
colorScheme="blue"
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
isLoading={updateMutation.isPending}
|
||||
isDisabled={updateMutation.isPending}
|
||||
>
|
||||
Yes, link them
|
||||
</Button>
|
||||
<Button
|
||||
w="full"
|
||||
size="lg"
|
||||
fontSize="md"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
isDisabled={updateMutation.isPending}
|
||||
>
|
||||
Use another email
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={updateMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{updateMutation.isPending && <Spinner className="size-4" />}
|
||||
{updateMutation.isPending ? "Linking" : "Yes, link them"}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Use another email
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingLinkWallet;
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
type TitleAndDescriptionProps = {
|
||||
heading: string | JSX.Element;
|
||||
description: string | JSX.Element;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
|
||||
export function isOnboardingComplete(account: Account) {
|
||||
// if email is confirmed, onboarding is considered complete
|
||||
return !!account.emailConfirmedAt;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
import type { Team } from "@/api/team";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useState } from "react";
|
||||
import { OnboardingChoosePlan } from "./ChoosePlan";
|
||||
import { OnboardingConfirmEmail } from "./ConfirmEmail";
|
||||
import { OnboardingGeneral } from "./General";
|
||||
import { OnboardingLinkWallet } from "./LinkWallet";
|
||||
import { useSkipOnboarding } from "./useSkipOnboarding";
|
||||
|
||||
type OnboardingScreen =
|
||||
| { id: "onboarding" }
|
||||
| { id: "linking" }
|
||||
| { id: "confirming" }
|
||||
| { id: "confirmLinking" }
|
||||
| { id: "plan"; team: Team };
|
||||
|
||||
function OnboardingUI(props: {
|
||||
account: Account;
|
||||
onComplete: () => void;
|
||||
// path to redirect from stripe
|
||||
redirectPath: string;
|
||||
}) {
|
||||
const { account } = props;
|
||||
const [screen, setScreen] = useState<OnboardingScreen>({ id: "onboarding" });
|
||||
|
||||
const trackEvent = useTrack();
|
||||
const [updatedEmail, setUpdatedEmail] = useState<string | undefined>();
|
||||
const skipOnboarding = useSkipOnboarding();
|
||||
|
||||
function trackOnboardingStep(params: {
|
||||
nextStep: OnboardingScreen["id"];
|
||||
email?: string;
|
||||
}) {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "onboardingStep",
|
||||
label: "next",
|
||||
data: {
|
||||
email: params.email || account.unconfirmedEmail || updatedEmail,
|
||||
currentStep: screen,
|
||||
nextStep: params.nextStep,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleDuplicateEmail = (email: string) => {
|
||||
setScreen({
|
||||
id: "linking",
|
||||
});
|
||||
trackOnboardingStep({
|
||||
nextStep: "linking",
|
||||
email,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full rounded-xl border border-border bg-background p-6 shadow-lg",
|
||||
screen.id === "plan" ? "lg:max-w-[750px]" : "lg:w-[500px]",
|
||||
)}
|
||||
>
|
||||
{screen.id === "onboarding" && (
|
||||
<OnboardingGeneral
|
||||
account={account}
|
||||
onSave={(email) => {
|
||||
setUpdatedEmail(email);
|
||||
setScreen({
|
||||
id: "confirming",
|
||||
});
|
||||
trackOnboardingStep({
|
||||
nextStep: "confirming",
|
||||
email,
|
||||
});
|
||||
}}
|
||||
onDuplicate={(email) => {
|
||||
setUpdatedEmail(email);
|
||||
handleDuplicateEmail(email);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen.id === "linking" && (
|
||||
<OnboardingLinkWallet
|
||||
onSave={() => {
|
||||
setScreen({
|
||||
id: "confirmLinking",
|
||||
});
|
||||
trackOnboardingStep({
|
||||
nextStep: "confirmLinking",
|
||||
});
|
||||
}}
|
||||
onBack={() => {
|
||||
setUpdatedEmail(undefined);
|
||||
setScreen({
|
||||
id: "onboarding",
|
||||
});
|
||||
}}
|
||||
email={updatedEmail as string}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TODO - separate the confirming and confirmLinking into separate components */}
|
||||
{(screen.id === "confirming" || screen.id === "confirmLinking") && (
|
||||
<OnboardingConfirmEmail
|
||||
linking={screen.id === "confirmLinking"}
|
||||
onComplete={props.onComplete}
|
||||
onEmailConfirm={(res) => {
|
||||
if (screen.id === "confirmLinking") {
|
||||
props.onComplete();
|
||||
} else if (screen.id === "confirming") {
|
||||
if (account.onboardSkipped) {
|
||||
props.onComplete();
|
||||
} else {
|
||||
setScreen({ id: "plan", team: res.team });
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBack={() =>
|
||||
setScreen({
|
||||
id: "onboarding",
|
||||
})
|
||||
}
|
||||
email={(account.unconfirmedEmail || updatedEmail) as string}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen.id === "plan" && (
|
||||
<OnboardingChoosePlan
|
||||
redirectPath={props.redirectPath}
|
||||
teamSlug={screen.team.slug}
|
||||
skipPlan={async () => {
|
||||
await skipOnboarding().catch(() => {});
|
||||
props.onComplete();
|
||||
}}
|
||||
canTrialGrowth={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OnboardingUI;
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
|
||||
export function useSkipOnboarding() {
|
||||
const mutation = useUpdateAccount();
|
||||
const trackEvent = useTrack();
|
||||
|
||||
async function skipOnboarding() {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "onboardSkippedBilling",
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
return mutation.mutateAsync(
|
||||
{
|
||||
onboardSkipped: true,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "onboardSkippedBilling",
|
||||
label: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "onboardSkippedBilling",
|
||||
label: "error",
|
||||
error,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return skipOnboarding;
|
||||
}
|
||||
@@ -1,139 +1,23 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getRawAccount } from "../account/settings/getAccount";
|
||||
import { LoginAndOnboardingPage } from "./LoginPage";
|
||||
import { isOnboardingComplete } from "./onboarding/isOnboardingRequired";
|
||||
|
||||
import { ColorModeToggle } from "@/components/color-mode-toggle";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { ConnectEmbed } from "thirdweb/react";
|
||||
import { createWallet, inAppWallet } from "thirdweb/wallets";
|
||||
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
|
||||
import { getSDKTheme } from "../components/sdk-component-theme";
|
||||
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
next?: string;
|
||||
}>;
|
||||
}) {
|
||||
const nextPath = (await props.searchParams).next;
|
||||
const account = await getRawAccount();
|
||||
|
||||
const wallets = [
|
||||
inAppWallet({
|
||||
auth: {
|
||||
options: [
|
||||
"google",
|
||||
"apple",
|
||||
"facebook",
|
||||
"github",
|
||||
"email",
|
||||
"phone",
|
||||
"passkey",
|
||||
],
|
||||
},
|
||||
}),
|
||||
createWallet("io.metamask"),
|
||||
createWallet("com.coinbase.wallet"),
|
||||
createWallet("io.rabby"),
|
||||
createWallet("me.rainbow"),
|
||||
createWallet("io.zerion.wallet"),
|
||||
];
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="relative grid h-screen place-items-center bg-muted/30">
|
||||
<nav className="absolute top-0 z-20 flex w-full flex-row items-center justify-between px-6 py-4">
|
||||
<ThirdwebMiniLogo className="max-h-7" />
|
||||
<ColorModeToggle />
|
||||
</nav>
|
||||
<main className="z-10 flex flex-col gap-6">
|
||||
<h1 className="text-center font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
Get started with thirdweb
|
||||
</h1>
|
||||
<Suspense>
|
||||
<CustomConnectEmmbed />
|
||||
</Suspense>
|
||||
</main>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
src="/assets/login/background.svg"
|
||||
className="-bottom-12 -right-12 fixed lg:right-0 lg:bottom-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomConnectEmmbed() {
|
||||
const isLG = useMediaQuery("(min-width: 1024px)");
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
const { theme } = useTheme();
|
||||
const nextSearchParam = searchParams?.get("next");
|
||||
const client = useThirdwebClient();
|
||||
|
||||
function onLoginSuccessful() {
|
||||
if (nextSearchParam && isValidRedirectPath(nextSearchParam)) {
|
||||
router.replace(nextSearchParam);
|
||||
if (account && isOnboardingComplete(account)) {
|
||||
if (nextPath) {
|
||||
redirect(nextPath);
|
||||
} else {
|
||||
router.replace("/team");
|
||||
redirect("/team");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectEmbed
|
||||
auth={{
|
||||
getLoginPayload,
|
||||
doLogin: async (params) => {
|
||||
try {
|
||||
await doLogin(params);
|
||||
onLoginSuccessful();
|
||||
} catch (e) {
|
||||
console.error("Failed to login", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
doLogout,
|
||||
isLoggedIn: async (x) => {
|
||||
const isLoggedInResult = await isLoggedIn(x);
|
||||
if (isLoggedInResult) {
|
||||
onLoginSuccessful();
|
||||
}
|
||||
return isLoggedInResult;
|
||||
},
|
||||
}}
|
||||
wallets={wallets}
|
||||
client={client}
|
||||
modalSize={isLG ? "wide" : "compact"}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
className="shadow-lg"
|
||||
privacyPolicyUrl="/privacy"
|
||||
termsOfServiceUrl="/tos"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isValidRedirectPath(encodedPath: string): boolean {
|
||||
try {
|
||||
// Decode the URI component
|
||||
const decodedPath = decodeURIComponent(encodedPath);
|
||||
// ensure the path always starts with a _single_ slash
|
||||
// dobule slash could be interpreted as `//example.com` which is not allowed
|
||||
return decodedPath.startsWith("/") && !decodedPath.startsWith("//");
|
||||
} catch {
|
||||
// If decoding fails, return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
|
||||
// required here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
return <LoginAndOnboardingPage account={account} nextPath={nextPath} />;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ThemeProvider } from "next-themes";
|
||||
import { useMemo } from "react";
|
||||
import { ThirdwebProvider, useActiveAccount } from "thirdweb/react";
|
||||
import { CustomConnectWallet } from "../@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { Onboarding } from "../components/onboarding";
|
||||
import { OpCreditsGrantedModalWrapper } from "../components/onboarding/OpCreditsGrantedModalWrapper";
|
||||
import { PosthogIdentifier } from "../components/wallets/PosthogIdentifier";
|
||||
import { isSanctionedAddress } from "../data/eth-sanctioned-addresses";
|
||||
@@ -23,7 +22,6 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
<TWAutoConnect />
|
||||
<OpCreditsGrantedModalWrapper />
|
||||
<PosthogIdentifier />
|
||||
<Onboarding />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { UserOpStats } from "@/api/analytics";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
|
||||
import { EmptyAccountAbstractionChartContent } from "../../../../../components/smart-wallets/AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
|
||||
import { BarChart } from "../../../components/Analytics/BarChart";
|
||||
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
|
||||
|
||||
export async function TotalSponsoredChartCardUI({
|
||||
data,
|
||||
aggregatedData,
|
||||
searchParams,
|
||||
className,
|
||||
onlyMainnet,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
data: UserOpStats[];
|
||||
aggregatedData: UserOpStats[];
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
className?: string;
|
||||
onlyMainnet?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const chains = await Promise.all(
|
||||
data.map(
|
||||
(item) =>
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
item.chainId && getChainMetadata(defineChain(Number(item.chainId))),
|
||||
),
|
||||
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
|
||||
|
||||
// Process data to combine by date and chain type
|
||||
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
|
||||
for (const item of data) {
|
||||
const chain = chains.find((c) => c.chainId === Number(item.chainId));
|
||||
|
||||
const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 };
|
||||
if (chain?.testnet) {
|
||||
existing.testnet += item.sponsoredUsd;
|
||||
} else {
|
||||
existing.mainnet += item.sponsoredUsd;
|
||||
}
|
||||
dateMap.set(item.date, existing);
|
||||
}
|
||||
|
||||
// Convert to array and sort by date
|
||||
const timeSeriesData = Array.from(dateMap.entries())
|
||||
.map(([date, values]) => ({
|
||||
date,
|
||||
mainnet: values.mainnet,
|
||||
testnet: values.testnet,
|
||||
total: values.mainnet + values.testnet,
|
||||
}))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
const processedAggregatedData = {
|
||||
mainnet: aggregatedData
|
||||
.filter(
|
||||
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
|
||||
testnet: aggregatedData
|
||||
.filter(
|
||||
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
|
||||
total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
mainnet: {
|
||||
label: "Mainnet Chains",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
testnet: {
|
||||
label: "Testnet Chains",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
total: {
|
||||
label: "All Chains",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
};
|
||||
|
||||
if (onlyMainnet) {
|
||||
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
|
||||
return (
|
||||
<div className={cn("rounded-lg border p-4 lg:p-6", className)}>
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
{title || "Total Sponsored"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground"> {description}</p>
|
||||
<BarChart
|
||||
isCurrency
|
||||
chartConfig={chartConfig}
|
||||
data={filteredData}
|
||||
activeKey="mainnet"
|
||||
emptyChartContent={<EmptyAccountAbstractionChartContent />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
isCurrency
|
||||
title={title || "Total Sponsored"}
|
||||
chartConfig={chartConfig}
|
||||
data={timeSeriesData}
|
||||
activeChart={
|
||||
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
|
||||
}
|
||||
queryKey="totalSponsored"
|
||||
existingQueryParams={searchParams}
|
||||
aggregateFn={(_data, key) => processedAggregatedData[key]}
|
||||
className={className}
|
||||
// Get the trend from the last two COMPLETE periods
|
||||
trendFn={(data, key) =>
|
||||
data.filter((d) => (d[key] as number) > 0).length >= 3
|
||||
? ((data[data.length - 2]?.[key] as number) ?? 0) /
|
||||
((data[data.length - 3]?.[key] as number) ?? 0) -
|
||||
1
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
|
||||
|
||||
export default async function TeamLayout(props: {
|
||||
@@ -10,6 +10,11 @@ export default async function TeamLayout(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const teams = await getTeams();
|
||||
|
||||
if (!teams) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const team = teams.find((t) => t.slug === params.team_slug);
|
||||
const teamsAndProjects = await Promise.all(
|
||||
teams.map(async (team) => ({
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
getWalletConnections,
|
||||
getWalletUsers,
|
||||
} from "@/api/analytics";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
} from "@/api/analytics";
|
||||
@@ -19,11 +18,6 @@ import {
|
||||
getLastNDaysRange,
|
||||
} from "components/analytics/date-range-selector";
|
||||
|
||||
import {
|
||||
type ChainMetadata,
|
||||
defineChain,
|
||||
getChainMetadata,
|
||||
} from "thirdweb/chains";
|
||||
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
|
||||
import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader";
|
||||
import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard";
|
||||
@@ -31,22 +25,28 @@ import { EmptyState } from "../../components/Analytics/EmptyState";
|
||||
import { PieChartCard } from "../../components/Analytics/PieChartCard";
|
||||
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getAccount } from "app/account/settings/getAccount";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { getValidAccount } from "app/account/settings/getAccount";
|
||||
import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard";
|
||||
import { Changelog, type ChangelogItem } from "components/dashboard/Changelog";
|
||||
import { Suspense } from "react";
|
||||
import { TotalSponsoredChartCardUI } from "./_components/TotalSponsoredCard";
|
||||
|
||||
// revalidate every 5 minutes
|
||||
export const revalidate = 300;
|
||||
|
||||
type SearchParams = {
|
||||
usersChart?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
type?: string;
|
||||
interval?: string;
|
||||
};
|
||||
|
||||
export default async function TeamOverviewPage(props: {
|
||||
params: { team_slug: string };
|
||||
searchParams: {
|
||||
usersChart?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
type?: string;
|
||||
interval?: string;
|
||||
};
|
||||
params: Promise<{ team_slug: string }>;
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const changelog = await getChangelog();
|
||||
const [params, searchParams] = await Promise.all([
|
||||
@@ -54,8 +54,13 @@ export default async function TeamOverviewPage(props: {
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
const account = await getValidAccount(`/team/${params.team_slug}`);
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
const account = await getAccount();
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
const interval = (searchParams.interval as "day" | "week") ?? "week";
|
||||
const rangeType = (searchParams.type as DurationId) || "last-120";
|
||||
const range: Range = {
|
||||
@@ -64,13 +69,42 @@ export default async function TeamOverviewPage(props: {
|
||||
type: rangeType,
|
||||
};
|
||||
|
||||
if (!team) {
|
||||
notFound();
|
||||
}
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-b">
|
||||
<AnalyticsHeader
|
||||
title="Team Overview"
|
||||
interval={interval}
|
||||
range={range}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex grow flex-col justify-between gap-10 md:container md:pt-8 md:pb-16 xl:flex-row">
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
<OverviewPageContent
|
||||
account={account}
|
||||
range={range}
|
||||
interval={interval}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</Suspense>
|
||||
<div className="shrink-0 max-md:container max-xl:hidden lg:w-[320px]">
|
||||
<h2 className="mb-4 font-semibold text-lg tracking-tight">
|
||||
Latest changes
|
||||
</h2>
|
||||
<Changelog changelog={changelog} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
redirect("/login");
|
||||
}
|
||||
async function OverviewPageContent(props: {
|
||||
account: Account;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const { account, range, interval, searchParams } = props;
|
||||
|
||||
const [
|
||||
walletConnections,
|
||||
@@ -121,78 +155,56 @@ export default async function TeamOverviewPage(props: {
|
||||
inAppWalletUsage.length === 0 &&
|
||||
userOpUsage.length === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full border-border-800 border-b px-6 dark:bg-muted/50">
|
||||
<AnalyticsHeader
|
||||
title="Team Overview"
|
||||
interval={interval}
|
||||
range={range}
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
|
||||
<div className="">
|
||||
<UsersChartCard
|
||||
userStats={walletUserStatsTimeSeries}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="Connect"
|
||||
link="https://portal.thirdweb.com/connect/quickstart"
|
||||
/>
|
||||
)}
|
||||
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
|
||||
{walletConnections.length > 0 ? (
|
||||
<WalletDistributionCard data={walletConnections} />
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="Connect"
|
||||
link="https://portal.thirdweb.com/connect/quickstart"
|
||||
/>
|
||||
)}
|
||||
{inAppWalletUsage.length > 0 ? (
|
||||
<AuthMethodDistributionCard data={inAppWalletUsage} />
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="In-App Wallets"
|
||||
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-16 md:container md:pt-8 md:pb-16 xl:flex-row">
|
||||
<div className="grow">
|
||||
{isEmpty ? (
|
||||
<div className="container p-6">
|
||||
<EmptyState />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
|
||||
<div className="">
|
||||
<UsersChartCard
|
||||
userStats={walletUserStatsTimeSeries}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="Connect"
|
||||
link="https://portal.thirdweb.com/connect/quickstart"
|
||||
/>
|
||||
)}
|
||||
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
|
||||
{walletConnections.length > 0 ? (
|
||||
<WalletDistributionCard data={walletConnections} />
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="Connect"
|
||||
link="https://portal.thirdweb.com/connect/quickstart"
|
||||
/>
|
||||
)}
|
||||
{inAppWalletUsage.length > 0 ? (
|
||||
<AuthMethodDistributionCard data={inAppWalletUsage} />
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="In-App Wallets"
|
||||
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{userOpUsage.length > 0 ? (
|
||||
<div className="">
|
||||
<TotalSponsoredCard
|
||||
searchParams={searchParams}
|
||||
data={userOpUsageTimeSeries}
|
||||
aggregatedData={userOpUsage}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="Sponsored Transactions"
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 max-md:container max-xl:hidden lg:w-[320px]">
|
||||
<h2 className="mb-4 font-semibold text-lg tracking-tight">
|
||||
Latest changes
|
||||
</h2>
|
||||
<Changelog changelog={changelog} />
|
||||
</div>
|
||||
</div>
|
||||
{userOpUsage.length > 0 ? (
|
||||
<TotalSponsoredChartCardUI
|
||||
searchParams={searchParams}
|
||||
data={userOpUsageTimeSeries}
|
||||
aggregatedData={userOpUsage}
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="Sponsored Transactions"
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -257,6 +269,7 @@ function UsersChartCard({
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
title="Users"
|
||||
chartConfig={chartConfig}
|
||||
activeChart={
|
||||
@@ -321,97 +334,3 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function TotalSponsoredCard({
|
||||
data,
|
||||
aggregatedData,
|
||||
searchParams,
|
||||
}: {
|
||||
data: UserOpStats[];
|
||||
aggregatedData: UserOpStats[];
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const chains = await Promise.all(
|
||||
data.map(
|
||||
(item) =>
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
item.chainId && getChainMetadata(defineChain(Number(item.chainId))),
|
||||
),
|
||||
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
|
||||
|
||||
// Process data to combine by date and chain type
|
||||
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
|
||||
for (const item of data) {
|
||||
const chain = chains.find((c) => c.chainId === Number(item.chainId));
|
||||
|
||||
const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 };
|
||||
if (chain?.testnet) {
|
||||
existing.testnet += item.sponsoredUsd;
|
||||
} else {
|
||||
existing.mainnet += item.sponsoredUsd;
|
||||
}
|
||||
dateMap.set(item.date, existing);
|
||||
}
|
||||
|
||||
// Convert to array and sort by date
|
||||
const timeSeriesData = Array.from(dateMap.entries())
|
||||
.map(([date, values]) => ({
|
||||
date,
|
||||
mainnet: values.mainnet,
|
||||
testnet: values.testnet,
|
||||
total: values.mainnet + values.testnet,
|
||||
}))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
const processedAggregatedData = {
|
||||
mainnet: aggregatedData
|
||||
.filter(
|
||||
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
|
||||
testnet: aggregatedData
|
||||
.filter(
|
||||
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
|
||||
total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
mainnet: {
|
||||
label: "Mainnet Chains",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
testnet: {
|
||||
label: "Testnet Chains",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
total: {
|
||||
label: "All Chains",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
isCurrency
|
||||
title="Total Sponsored"
|
||||
chartConfig={chartConfig}
|
||||
data={timeSeriesData}
|
||||
activeChart={
|
||||
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
|
||||
}
|
||||
queryKey="totalSponsored"
|
||||
existingQueryParams={searchParams}
|
||||
aggregateFn={(_data, key) => processedAggregatedData[key]}
|
||||
// Get the trend from the last two COMPLETE periods
|
||||
trendFn={(data, key) =>
|
||||
data.filter((d) => (d[key] as number) > 0).length >= 3
|
||||
? ((data[data.length - 2]?.[key] as number) ?? 0) /
|
||||
((data[data.length - 3]?.[key] as number) ?? 0) -
|
||||
1
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -154,8 +154,8 @@ export function EcosystemWalletUsersChartCard(props: {
|
||||
chartData.every((data) => data[authMethod] === 0),
|
||||
) ? (
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="mb-6 text-center text-lg">
|
||||
Connect users to your app with social logins
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import { EcosystemLayoutSlug } from "./components/EcosystemSlugLayout";
|
||||
|
||||
export default async function Layout(props: {
|
||||
@@ -11,7 +10,6 @@ export default async function Layout(props: {
|
||||
params={await props.params}
|
||||
ecosystemLayoutPath={`/team/${team_slug}/~/ecosystem`}
|
||||
>
|
||||
<BillingAlerts className="mb-10" />
|
||||
{props.children}
|
||||
</EcosystemLayoutSlug>
|
||||
);
|
||||
|
||||
+2
-6
@@ -1,9 +1,7 @@
|
||||
import { CreateEcosystemForm } from "./components/client/create-ecosystem-form.client";
|
||||
import { EcosystemWalletPricingCard } from "./components/pricing-card";
|
||||
|
||||
export function EcosystemCreatePage(props: {
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
export async function EcosystemCreatePage(props: { teamSlug: string }) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6 md:mx-auto md:max-w-lg lg:max-w-4xl">
|
||||
<header className="flex flex-col gap-1">
|
||||
@@ -19,9 +17,7 @@ export function EcosystemCreatePage(props: {
|
||||
<EcosystemWalletPricingCard />
|
||||
</section>
|
||||
<section className="mb-12 lg:px-4">
|
||||
<CreateEcosystemForm
|
||||
ecosystemLayoutPath={props.ecosystemLayoutPath}
|
||||
/>
|
||||
<CreateEcosystemForm teamSlug={props.teamSlug} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
import { API_SERVER_URL, BASE_URL } from "@/constants/env";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
|
||||
|
||||
export async function createEcosystem(options: {
|
||||
teamSlug: string;
|
||||
name: string;
|
||||
logo: File;
|
||||
permission: "PARTNER_WHITELIST" | "ANYONE";
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return {
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const { teamSlug, logo, ...data } = options;
|
||||
|
||||
const imageUrl = await upload({
|
||||
client: getThirdwebClient(token),
|
||||
files: [logo],
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamSlug}/checkout/create-link`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
baseUrl: BASE_URL,
|
||||
sku: "product:ecosystem_wallets",
|
||||
metadata: {
|
||||
...data,
|
||||
imageUrl,
|
||||
// not something we pass in today during creation, but required to be there
|
||||
authOptions: [],
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.result) {
|
||||
return {
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
|
||||
// redirect to the stripe billing portal
|
||||
redirect(json.result);
|
||||
}
|
||||
+43
-73
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -14,18 +13,13 @@ import {
|
||||
import { ImageUpload } from "@/components/ui/image-upload";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { useCreateEcosystem } from "../../hooks/use-create-ecosystem";
|
||||
import { createEcosystem } from "../../actions/create-ecosystem";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
@@ -42,16 +36,7 @@ const formSchema = z.object({
|
||||
permission: z.union([z.literal("PARTNER_WHITELIST"), z.literal("ANYONE")]),
|
||||
});
|
||||
|
||||
export function CreateEcosystemForm(props: {
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
// When set, the confirmation modal is open the this contains the form data to be submitted
|
||||
const [formDataToBeConfirmed, setFormDataToBeConfirmed] = useState<
|
||||
z.infer<typeof formSchema> | undefined
|
||||
>();
|
||||
const { data: billingAccountInfo } = useAccount();
|
||||
|
||||
const router = useDashboardRouter();
|
||||
export function CreateEcosystemForm(props: { teamSlug: string }) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -59,25 +44,39 @@ export function CreateEcosystemForm(props: {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createEcosystem, isPending } = useCreateEcosystem({
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to create ecosystem";
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: (slug: string) => {
|
||||
form.reset();
|
||||
router.push(`${props.ecosystemLayoutPath}/${slug}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
setFormDataToBeConfirmed(values),
|
||||
)}
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
const res = await createEcosystem({
|
||||
teamSlug: props.teamSlug,
|
||||
...values,
|
||||
});
|
||||
switch (res.status) {
|
||||
case 401: {
|
||||
toast.error("Please login to create an ecosystem");
|
||||
break;
|
||||
}
|
||||
case 403:
|
||||
{
|
||||
toast.error(
|
||||
"You are not authorized to create an ecosystem, please ask your team owner to create it.",
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 409: {
|
||||
toast.error("An ecosystem with that name already exists.");
|
||||
break;
|
||||
}
|
||||
// any other status code treat as a random failure
|
||||
default: {
|
||||
toast.error(
|
||||
"Failed to create ecosystem, please try again later.",
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
className="flex flex-col items-stretch gap-8"
|
||||
>
|
||||
<div className="grid gap-6">
|
||||
@@ -164,49 +163,20 @@ export function CreateEcosystemForm(props: {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{billingAccountInfo?.status !== accountStatus.validPayment ? (
|
||||
<ToolTipLabel label="Please update your payment method to create an ecosystem">
|
||||
{/* Allows the button to be disabled but the tooltip still works */}
|
||||
<div className="w-full">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={true}
|
||||
variant="primary"
|
||||
className="w-full opacity-50"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting && (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<ConfirmationDialog
|
||||
open={formDataToBeConfirmed !== undefined}
|
||||
onOpenChange={(open) =>
|
||||
!open ? setFormDataToBeConfirmed(undefined) : null
|
||||
}
|
||||
title={`Are you sure you want to create ecosystem ${form.getValues().name}?`}
|
||||
description="Your account will be charged $250 per month."
|
||||
onSubmit={() => {
|
||||
invariant(formDataToBeConfirmed, "Form data not found");
|
||||
createEcosystem({
|
||||
name: formDataToBeConfirmed.name,
|
||||
logo: formDataToBeConfirmed.logo,
|
||||
permission: formDataToBeConfirmed.permission,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import {
|
||||
type UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { THIRDWEB_API_HOST } from "constants/urls";
|
||||
import { upload } from "thirdweb/storage";
|
||||
|
||||
type CreateEcosystemParams = {
|
||||
name: string;
|
||||
logo: File;
|
||||
permission: "PARTNER_WHITELIST" | "ANYONE";
|
||||
};
|
||||
|
||||
export function useCreateEcosystem(
|
||||
options?: Omit<
|
||||
UseMutationOptions<string, unknown, CreateEcosystemParams>,
|
||||
"mutationFn"
|
||||
>,
|
||||
) {
|
||||
const { onSuccess, ...queryOptions } = options ?? {};
|
||||
const { isLoggedIn } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const client = useThirdwebClient();
|
||||
|
||||
return useMutation({
|
||||
// Returns the created ecosystem slug
|
||||
mutationFn: async (params: CreateEcosystemParams): Promise<string> => {
|
||||
if (!isLoggedIn) {
|
||||
throw new Error("Please login to create an ecosystem");
|
||||
}
|
||||
|
||||
const imageUri = await upload({
|
||||
client,
|
||||
files: [params.logo],
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/ecosystem-wallet/add-cloud-hosted`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: params.name,
|
||||
imageUrl: imageUri,
|
||||
permission: params.permission,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json();
|
||||
console.error(body);
|
||||
if (res.status === 401) {
|
||||
throw new Error(
|
||||
"You're not authorized to create an ecosystem, are you logged in?",
|
||||
);
|
||||
}
|
||||
if (res.status === 406) {
|
||||
throw new Error(
|
||||
"Please setup billing on your account to create an ecosystem",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
body.message ?? body?.error?.message ?? "Failed to create ecosystem",
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
return data.result.slug;
|
||||
},
|
||||
onSuccess: async (id, variables, context) => {
|
||||
if (onSuccess) {
|
||||
await onSuccess(id, variables, context);
|
||||
}
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["ecosystems"],
|
||||
});
|
||||
},
|
||||
...queryOptions,
|
||||
});
|
||||
}
|
||||
@@ -4,9 +4,5 @@ export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
}) {
|
||||
const { team_slug } = await props.params;
|
||||
return (
|
||||
<EcosystemCreatePage
|
||||
ecosystemLayoutPath={`/team/${team_slug}/~/ecosystem`}
|
||||
/>
|
||||
);
|
||||
return <EcosystemCreatePage teamSlug={team_slug} />;
|
||||
}
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RocketIcon } from "lucide-react";
|
||||
import { MONTHLY_PRICE_USD } from "./tier-card";
|
||||
|
||||
export const ConfirmEngineTierDialog = (props: {
|
||||
tier: "STARTER" | "PREMIUM";
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
open: boolean;
|
||||
}) => {
|
||||
const { tier, onConfirm, open, onOpenChange } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] md:max-w-[400px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="pr-4 leading-snug">
|
||||
Are you sure you want to deploy a{" "}
|
||||
{tier === "STARTER" ? "Standard" : "Premium"} Engine?
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
You will be charged ${MONTHLY_PRICE_USD[tier]} per month for the
|
||||
subscription
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-5">
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} variant="primary" className="gap-2">
|
||||
<RocketIcon className="size-3" />
|
||||
Deploy now
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,112 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ConfirmEngineTierDialog } from "app/team/[team_slug]/(team)/~/engine/(general)/create/ConfirmEngineTierDialog";
|
||||
import { EngineTierCard } from "app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card";
|
||||
import { LazyOnboardingBilling } from "components/onboarding/LazyOnboardingBilling";
|
||||
import { OnboardingModal } from "components/onboarding/Modal";
|
||||
import { THIRDWEB_API_HOST } from "constants/urls";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { EngineTierCard } from "./tier-card";
|
||||
|
||||
export default function Page() {
|
||||
const trackEvent = useTrack();
|
||||
const router = useDashboardRouter();
|
||||
const [selectedTier, setSelectedTier] = useState<"STARTER" | "PREMIUM">();
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const accountQuery = useAccount();
|
||||
const [isBillingModalOpen, setIsBillingModalOpen] = useState(false);
|
||||
|
||||
async function createEngineInstance(tier: "STARTER" | "PREMIUM") {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-cloud-hosted",
|
||||
tier: tier,
|
||||
});
|
||||
|
||||
try {
|
||||
toast.info("Starting Engine deployment");
|
||||
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/engine/add-cloud-hosted`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tier: tier,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
toast.warning(
|
||||
"There is a pending Engine deployment. Please contact [email protected] if this takes longer than 2 hours.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const error =
|
||||
json.error?.message ??
|
||||
"Unexpected error. Please try again or visit https://thirdweb.com/support.";
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
"Thank you! Your Engine deployment will begin shortly. You can view the progress in Overview page.",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedTier && (
|
||||
<ConfirmEngineTierDialog
|
||||
tier={selectedTier}
|
||||
onOpenChange={setIsConfirmationDialogOpen}
|
||||
open={isConfirmationDialogOpen}
|
||||
onConfirm={async () => {
|
||||
setIsConfirmationDialogOpen(false);
|
||||
|
||||
// If Payment is already setup, deploy the Engine
|
||||
if (accountQuery.data?.status === accountStatus.validPayment) {
|
||||
await createEngineInstance(selectedTier);
|
||||
} else {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-add-payment-method",
|
||||
});
|
||||
setIsBillingModalOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OnboardingModal isOpen={isBillingModalOpen}>
|
||||
<LazyOnboardingBilling
|
||||
onSave={async () => {
|
||||
if (!selectedTier) {
|
||||
return;
|
||||
}
|
||||
|
||||
await createEngineInstance(selectedTier);
|
||||
setIsBillingModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsBillingModalOpen(false)}
|
||||
/>
|
||||
</OnboardingModal>
|
||||
|
||||
<h1 className="mb-2 font-semibold text-2xl tracking-tight">
|
||||
Choose an Engine deployment
|
||||
</h1>
|
||||
@@ -116,36 +12,9 @@ export default function Page() {
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<EngineTierCard
|
||||
tier="STARTER"
|
||||
onClick={() => {
|
||||
setSelectedTier("STARTER");
|
||||
setIsConfirmationDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EngineTierCard
|
||||
tier="PREMIUM"
|
||||
isPrimaryCta
|
||||
onClick={() => {
|
||||
setSelectedTier("PREMIUM");
|
||||
setIsConfirmationDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EngineTierCard
|
||||
tier="ENTERPRISE"
|
||||
previousTier="Premium Engine"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-cloud-hosted",
|
||||
tier: "ENTERPRISE",
|
||||
});
|
||||
router.push("/contact-us");
|
||||
}}
|
||||
/>
|
||||
<EngineTierCard tier="STARTER" />
|
||||
<EngineTierCard tier="PREMIUM" isPrimaryCta />
|
||||
<EngineTierCard tier="ENTERPRISE" previousTier="Premium Engine" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+48
-18
@@ -3,7 +3,11 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EngineTier } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { Flex, Spacer } from "@chakra-ui/react";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CheckoutButton } from "../../../../../../../../@/components/billing";
|
||||
|
||||
interface EngineTierCardConfig {
|
||||
name: string;
|
||||
@@ -40,25 +44,19 @@ const ENGINE_TIER_CARD_CONFIG: Record<EngineTier, EngineTierCardConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MONTHLY_PRICE_USD: Record<EngineTier, number> = {
|
||||
STARTER: 99,
|
||||
PREMIUM: 299,
|
||||
ENTERPRISE: 0,
|
||||
};
|
||||
|
||||
export const EngineTierCard = ({
|
||||
tier,
|
||||
previousTier,
|
||||
isPrimaryCta = false,
|
||||
onClick,
|
||||
ctaText,
|
||||
}: {
|
||||
tier: EngineTier;
|
||||
previousTier?: string;
|
||||
isPrimaryCta?: boolean;
|
||||
onClick: () => void;
|
||||
ctaText?: string;
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
const params = useParams<{ team_slug: string }>();
|
||||
const { name, monthlyPriceUsd } = ENGINE_TIER_CARD_CONFIG[tier];
|
||||
let features = ENGINE_TIER_CARD_CONFIG[tier].features;
|
||||
if (tier === "PREMIUM") {
|
||||
@@ -68,18 +66,18 @@ export const EngineTierCard = ({
|
||||
const defaultCtaText =
|
||||
monthlyPriceUsd === "custom" ? "Contact us" : "Deploy now";
|
||||
|
||||
const card = (
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-6 rounded-xl border border-border bg-muted/20 p-6"
|
||||
className="flex flex-col gap-6 rounded-xl border border-border bg-muted/50 p-6"
|
||||
style={{
|
||||
background: isPrimaryCta
|
||||
? "linear-gradient(to top, hsl(var(--muted)), hsl(var(--muted)/50%))"
|
||||
backgroundImage: isPrimaryCta
|
||||
? "linear-gradient(to top, hsl(var(--muted)) 40%, transparent)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
{/* Name */}
|
||||
<h3 className="font-semibold text-3xl tracking-tight">{name}</h3>
|
||||
<h3 className="font-semibold text-2xl tracking-tight">{name}</h3>
|
||||
|
||||
{/* Price */}
|
||||
{monthlyPriceUsd === "custom" ? (
|
||||
@@ -114,11 +112,43 @@ export const EngineTierCard = ({
|
||||
<Spacer />
|
||||
|
||||
{/* CTA */}
|
||||
<Button onClick={onClick} variant={isPrimaryCta ? "default" : "outline"}>
|
||||
{ctaText ?? defaultCtaText}
|
||||
</Button>
|
||||
{tier === "ENTERPRISE" ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-cloud-hosted",
|
||||
tier,
|
||||
});
|
||||
}}
|
||||
variant={isPrimaryCta ? "default" : "outline"}
|
||||
asChild
|
||||
>
|
||||
<Link href="/contact-us">{ctaText ?? defaultCtaText}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<CheckoutButton
|
||||
sku={
|
||||
tier === "STARTER"
|
||||
? "product:engine_standard"
|
||||
: "product:engine_premium"
|
||||
}
|
||||
redirectPath={`/team/${params?.team_slug}/~/engine`}
|
||||
teamSlug={params?.team_slug || "~"}
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "click",
|
||||
label: "clicked-cloud-hosted",
|
||||
tier,
|
||||
});
|
||||
}}
|
||||
variant={isPrimaryCta ? "default" : "outline"}
|
||||
>
|
||||
{ctaText ?? defaultCtaText}
|
||||
</CheckoutButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return card;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
@@ -27,9 +26,6 @@ export default async function Layout(props: {
|
||||
];
|
||||
|
||||
return (
|
||||
<SidebarLayout sidebarLinks={sidebarLinks}>
|
||||
<BillingAlerts className="mb-10" />
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
<SidebarLayout sidebarLinks={sidebarLinks}>{props.children}</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
-2
@@ -1,4 +1,3 @@
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import { EngineSidebarLayout } from "./_components/EnginePageLayout";
|
||||
|
||||
export default async function Layout(props: {
|
||||
@@ -11,7 +10,6 @@ export default async function Layout(props: {
|
||||
const params = await props.params;
|
||||
return (
|
||||
<EngineSidebarLayout engineId={params.engineId} teamSlug={params.team_slug}>
|
||||
<BillingAlerts className="mb-10" />
|
||||
{props.children}
|
||||
</EngineSidebarLayout>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
|
||||
type SortyById = "name" | "createdAt";
|
||||
@@ -62,7 +61,6 @@ export function TeamProjectsPage(props: {
|
||||
return (
|
||||
<div className="container ">
|
||||
<div className="h-10" />
|
||||
<BillingAlerts className="mb-10" />
|
||||
|
||||
{/* Filters + Add New */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { Billing } from "components/settings/Account/Billing";
|
||||
|
||||
export const SettingsBillingPage = (props: {
|
||||
teamId: string | undefined;
|
||||
}) => {
|
||||
const meQuery = useAccount({
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state?.status as string;
|
||||
const isInvalidPayment =
|
||||
status === accountStatus.invalidPayment ||
|
||||
status === accountStatus.invalidPaymentMethod;
|
||||
|
||||
return isInvalidPayment ? 1000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: account } = meQuery;
|
||||
|
||||
if (!account) {
|
||||
return <GenericLoadingPage />;
|
||||
}
|
||||
|
||||
return <Billing account={account} teamId={props.teamId} />;
|
||||
};
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { addDays } from "date-fns";
|
||||
import { teamStub, teamSubscriptionsStub } from "stories/stubs";
|
||||
import {
|
||||
BadgeContainer,
|
||||
mobileViewport,
|
||||
} from "../../../../../../../../stories/utils";
|
||||
import { PlanInfoCard } from "./PlanInfoCard";
|
||||
|
||||
const meta = {
|
||||
title: "Billing/PlanInfoCard",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
const team = teamStub("foo", "growth");
|
||||
const zeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth");
|
||||
const trialPlanZeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth", {
|
||||
trialEnd: addDays(new Date(), 7).toISOString(),
|
||||
});
|
||||
|
||||
const subsWith1Usage = teamSubscriptionsStub("plan:growth", {
|
||||
usage: {
|
||||
storage: {
|
||||
amount: 10000,
|
||||
quantity: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const subsWith4Usage = teamSubscriptionsStub("plan:growth", {
|
||||
usage: {
|
||||
storage: {
|
||||
amount: 10000,
|
||||
quantity: 4,
|
||||
},
|
||||
aaSponsorshipAmount: {
|
||||
amount: 7500,
|
||||
quantity: 4,
|
||||
},
|
||||
aaSponsorshipOpGrantAmount: {
|
||||
amount: 2500,
|
||||
quantity: 4,
|
||||
},
|
||||
inAppWalletAmount: {
|
||||
amount: 40000,
|
||||
quantity: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container flex max-w-[1130px] flex-col gap-12 lg:p-10">
|
||||
<BadgeContainer label="On-demand Subscriptions with 0 usage">
|
||||
<PlanInfoCard team={team} subscriptions={zeroUsageOnDemandSubs} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Trial Plan - On-demand Subscriptions with 0 usage">
|
||||
<PlanInfoCard
|
||||
team={team}
|
||||
subscriptions={trialPlanZeroUsageOnDemandSubs}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="On-demand Subscriptions with 1 usage">
|
||||
<PlanInfoCard team={team} subscriptions={subsWith1Usage} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="On-demand Subscriptions with 4 usage">
|
||||
<PlanInfoCard team={team} subscriptions={subsWith4Usage} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamSubscription } from "@/api/team-subscription";
|
||||
import { BillingPortalButton } from "@/components/billing";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { differenceInDays, isAfter } from "date-fns";
|
||||
import { format } from "date-fns/format";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";
|
||||
|
||||
export function PlanInfoCard(props: {
|
||||
subscriptions: TeamSubscription[];
|
||||
team: Team;
|
||||
}) {
|
||||
const { subscriptions, team } = props;
|
||||
const validPlan = getValidTeamPlan(team);
|
||||
const isActualFreePlan = team.billingPlan === "free";
|
||||
|
||||
const planSub = subscriptions.find(
|
||||
(subscription) => subscription.type === "PLAN",
|
||||
);
|
||||
|
||||
// considers hours, mins ... etc as well
|
||||
const trialEndsInFuture =
|
||||
planSub?.trialEnd && isAfter(new Date(planSub.trialEnd), new Date());
|
||||
|
||||
const trialEndsAfterDays = planSub?.trialEnd
|
||||
? differenceInDays(new Date(planSub.trialEnd), new Date())
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/50">
|
||||
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">
|
||||
{validPlan} Plan
|
||||
</h3>
|
||||
{trialEndsInFuture && <Badge>Trial</Badge>}
|
||||
</div>
|
||||
{trialEndsAfterDays > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your trial ends in {trialEndsAfterDays} days
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* manage team billing */}
|
||||
<BillingPortalButton teamSlug={team.slug} variant="outline">
|
||||
Manage Billing
|
||||
</BillingPortalButton>
|
||||
|
||||
{isActualFreePlan && (
|
||||
<Button asChild variant="outline">
|
||||
<TrackedLinkTW
|
||||
category="account"
|
||||
href="/pricing"
|
||||
label="pricing-plans"
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View Pricing
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="p-4 lg:p-6">
|
||||
{isActualFreePlan ? (
|
||||
<div className="flex flex-col items-center py-8 text-center max-sm:gap-4">
|
||||
<CircleAlertIcon className="mb-3 text-muted-foreground lg:size-6" />
|
||||
<p>Your plan includes a fixed amount of free usage.</p>
|
||||
<p>
|
||||
To unlock additional usage, upgrade your plan to Starter or
|
||||
Growth.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<BillingInfo subscriptions={subscriptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingInfo({
|
||||
subscriptions,
|
||||
}: {
|
||||
subscriptions: TeamSubscription[];
|
||||
}) {
|
||||
const planSubscription = subscriptions.find(
|
||||
(subscription) => subscription.type === "PLAN",
|
||||
);
|
||||
|
||||
const usageSubscription = subscriptions.find(
|
||||
(subscription) => subscription.type === "USAGE",
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{planSubscription && (
|
||||
<SubscriptionOverview subscription={planSubscription} />
|
||||
)}
|
||||
|
||||
{usageSubscription && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<SubscriptionOverview
|
||||
subscription={usageSubscription}
|
||||
title="On-Demand Charges"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionOverview(props: {
|
||||
subscription: TeamSubscription;
|
||||
title?: string;
|
||||
}) {
|
||||
const { subscription } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div>
|
||||
{props.title && (
|
||||
<h5 className="font-medium text-lg">{props.title}</h5>
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm lg:text-base">
|
||||
{format(
|
||||
new Date(props.subscription.currentPeriodStart),
|
||||
"MMMM dd yyyy",
|
||||
)}{" "}
|
||||
-{" "}
|
||||
{format(
|
||||
new Date(props.subscription.currentPeriodEnd),
|
||||
"MMMM dd yyyy",
|
||||
)}{" "}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground">
|
||||
{formatCurrencyAmount(
|
||||
subscription.upcomingInvoice.amount || 0,
|
||||
subscription.upcomingInvoice.currency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCurrencyAmount(centsAmount: number, currency: string) {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
}).format(centsAmount / 100);
|
||||
}
|
||||
@@ -1,22 +1,35 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SettingsBillingPage } from "./BillingSettingsPage";
|
||||
import { getTeamSubscriptions } from "@/api/team-subscription";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Billing } from "../../../../../../../components/settings/Account/Billing";
|
||||
import { getValidAccount } from "../../../../../../account/settings/getAccount";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const team = await getTeamBySlug((await props.params).team_slug);
|
||||
const params = await props.params;
|
||||
const pagePath = `/team/${params.team_slug}/settings/billing`;
|
||||
|
||||
// ensure that user is logged in and onboarded
|
||||
await getValidAccount(pagePath);
|
||||
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
notFound();
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<SettingsBillingPage teamId={team.id} />
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
const subscriptions = await getTeamSubscriptions(team.slug);
|
||||
|
||||
if (!subscriptions) {
|
||||
return (
|
||||
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
|
||||
Something went wrong. Please try again later.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Billing team={team} subscriptions={subscriptions} />;
|
||||
}
|
||||
|
||||
+8
-16
@@ -1,21 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { ApplyForOpCreditsModal } from "components/onboarding/ApplyForOpCreditsModal";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ApplyForOpCredits } from "components/onboarding/ApplyForOpCreditsModal";
|
||||
import { Heading, LinkButton } from "tw-components";
|
||||
|
||||
export const SettingsGasCreditsPage = () => {
|
||||
const { isPending } = useLoggedInUser();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="grid min-h-[400px] w-full place-items-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SettingsGasCreditsPage = (props: {
|
||||
team: Team;
|
||||
account: Account;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
@@ -33,7 +25,7 @@ export const SettingsGasCreditsPage = () => {
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<ApplyForOpCreditsModal />
|
||||
<ApplyForOpCredits team={props.team} account={props.account} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getValidAccount } from "../../../../../../account/settings/getAccount";
|
||||
import { SettingsGasCreditsPage } from "./SettingsCreditsPage";
|
||||
|
||||
export default function Page() {
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const account = await getValidAccount(
|
||||
`/team/${params.team_slug}/~/settings/credits`,
|
||||
);
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
return redirect("/team");
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<SettingsGasCreditsPage />
|
||||
<SettingsGasCreditsPage team={team} account={account} />
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-11
@@ -1,7 +1,7 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { teamStub } from "../../../../../../../stories/stubs";
|
||||
import { mobileViewport } from "../../../../../../../stories/utils";
|
||||
import {
|
||||
DeleteTeamCard,
|
||||
@@ -33,16 +33,7 @@ export const Mobile: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const testTeam: Team = {
|
||||
id: "team-id-foo-bar",
|
||||
name: "Team XYZ",
|
||||
slug: "team-slug-foo-bar",
|
||||
createdAt: "2023-07-07T19:21:33.604Z",
|
||||
updatedAt: "2024-07-11T00:01:02.241Z",
|
||||
billingStatus: "validPayment",
|
||||
billingPlan: "free",
|
||||
billingEmail: "[email protected]",
|
||||
};
|
||||
const testTeam = teamStub("foo", "free");
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SettingsLayout } from "./SettingsLayout";
|
||||
|
||||
@@ -15,10 +14,5 @@ export default async function Layout(props: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout team={team}>
|
||||
<BillingAlerts className="mb-10" />
|
||||
{props.children}
|
||||
</SettingsLayout>
|
||||
);
|
||||
return <SettingsLayout team={team}>{props.children}</SettingsLayout>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getMembers } from "@/api/team-members";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAccount } from "../../../../../../account/settings/getAccount";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getValidAccount } from "../../../../../../account/settings/getAccount";
|
||||
import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
@@ -9,18 +9,26 @@ export default async function Page(props: {
|
||||
team_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const [account, team, members] = await Promise.all([
|
||||
getAccount(),
|
||||
getTeamBySlug((await props.params).team_slug),
|
||||
getMembers((await props.params).team_slug),
|
||||
getValidAccount(`/team/${params.team_slug}/~/settings/members`),
|
||||
getTeamBySlug(params.team_slug),
|
||||
getMembers(params.team_slug),
|
||||
]);
|
||||
|
||||
if (!team || !account || !members) {
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
// unexpected error
|
||||
if (!members) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const accountMemberInfo = members.find((m) => m.accountId === account.id);
|
||||
|
||||
// unexpected error
|
||||
if (!accountMemberInfo) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -9,22 +8,30 @@ export default async function Layout(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
return (
|
||||
<SidebarLayout
|
||||
sidebarLinks={[
|
||||
{
|
||||
href: `/team/${params.team_slug}/~/usage`,
|
||||
exactMatch: true,
|
||||
label: "Overview",
|
||||
},
|
||||
{
|
||||
href: `/team/${params.team_slug}/~/usage/storage`,
|
||||
exactMatch: true,
|
||||
label: "Storage",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<BillingAlerts className="mb-10" />
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container">
|
||||
<h1 className="font-semibold text-3xl tracking-tight lg:px-2">
|
||||
Usage
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarLayout
|
||||
sidebarLinks={[
|
||||
{
|
||||
href: `/team/${params.team_slug}/~/usage`,
|
||||
exactMatch: true,
|
||||
label: "Overview",
|
||||
},
|
||||
{
|
||||
href: `/team/${params.team_slug}/~/usage/storage`,
|
||||
exactMatch: true,
|
||||
label: "Storage",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+202
-82
@@ -1,29 +1,34 @@
|
||||
import type { UsageBillableByService } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useMemo } from "react";
|
||||
import { toNumber, toPercent, toSize } from "utils/number";
|
||||
import { getInAppWalletUsage, getUserOpUsage } from "@/api/analytics";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamSubscription } from "@/api/team-subscription";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
Account,
|
||||
UsageBillableByService,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { InAppWalletUsersChartCardUI } from "components/embedded-wallets/Analytics/InAppWalletUsersChartCard";
|
||||
import { subMonths } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { toPercent, toSize } from "utils/number";
|
||||
import { TotalSponsoredChartCardUI } from "../../../../_components/TotalSponsoredCard";
|
||||
import { UsageCard } from "./UsageCard";
|
||||
|
||||
interface UsageProps {
|
||||
type UsageProps = {
|
||||
// TODO - remove when we have all the data available from team
|
||||
usage: UsageBillableByService;
|
||||
}
|
||||
|
||||
export const Usage: React.FC<UsageProps> = ({ usage: usageData }) => {
|
||||
const bundlerMetrics = useMemo(() => {
|
||||
const metric = {
|
||||
title: "Total sponsored fees",
|
||||
total: 0,
|
||||
};
|
||||
|
||||
if (!usageData) {
|
||||
return metric;
|
||||
}
|
||||
|
||||
return {
|
||||
title: metric.title,
|
||||
total: usageData.billableUsd.bundler,
|
||||
};
|
||||
}, [usageData]);
|
||||
subscriptions: TeamSubscription[];
|
||||
account: Account;
|
||||
team: Team;
|
||||
};
|
||||
|
||||
export const Usage: React.FC<UsageProps> = ({
|
||||
usage: usageData,
|
||||
subscriptions,
|
||||
account,
|
||||
team,
|
||||
}) => {
|
||||
// TODO - get this from team instead of account
|
||||
const storageMetrics = useMemo(() => {
|
||||
if (!usageData) {
|
||||
return {};
|
||||
@@ -34,47 +39,20 @@ export const Usage: React.FC<UsageProps> = ({ usage: usageData }) => {
|
||||
const percent = toPercent(consumedBytes, limitBytes);
|
||||
|
||||
return {
|
||||
total: `${toSize(consumedBytes, "MB")} / ${toSize(
|
||||
limitBytes,
|
||||
)} (${percent}%)`,
|
||||
total: `${toSize(Math.min(consumedBytes, limitBytes), "MB")} of ${toSize(limitBytes)} included storage used`,
|
||||
progress: percent,
|
||||
...(usageData.billableUsd.storage > 0
|
||||
? {
|
||||
overage: usageData.billableUsd.storage,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}, [usageData]);
|
||||
|
||||
const walletsMetrics = useMemo(() => {
|
||||
if (!usageData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const numOfWallets = usageData.usage.embeddedWallets.countWalletAddresses;
|
||||
const limitWallets = usageData.limits.embeddedWallets;
|
||||
const percent = toPercent(numOfWallets, limitWallets);
|
||||
|
||||
return {
|
||||
total: `${toNumber(numOfWallets)} / ${toNumber(
|
||||
limitWallets,
|
||||
)} (${percent}%)`,
|
||||
progress: percent,
|
||||
...(usageData.billableUsd.embeddedWallets > 0
|
||||
? {
|
||||
overage: usageData.billableUsd.embeddedWallets,
|
||||
}
|
||||
: {}),
|
||||
totalUsage: `Total Usage: ${toSize(consumedBytes, "MB")}`,
|
||||
};
|
||||
}, [usageData]);
|
||||
|
||||
// TODO - get this from team instead of account
|
||||
const rpcMetrics = useMemo(() => {
|
||||
if (!usageData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Unlimited requests",
|
||||
title: "Unlimited Requests",
|
||||
total: (
|
||||
<span className="text-muted-foreground">
|
||||
{usageData.rateLimits.rpc} Requests Per Second
|
||||
@@ -89,7 +67,7 @@ export const Usage: React.FC<UsageProps> = ({ usage: usageData }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Unlimited requests",
|
||||
title: "Unlimited Requests",
|
||||
total: (
|
||||
<span className="text-muted-foreground">
|
||||
{usageData.rateLimits.storage} Requests Per Second
|
||||
@@ -98,35 +76,177 @@ export const Usage: React.FC<UsageProps> = ({ usage: usageData }) => {
|
||||
};
|
||||
}, [usageData]);
|
||||
|
||||
const usageSub = subscriptions.find((sub) => sub.type === "USAGE");
|
||||
|
||||
// we don't have `usageSub` for free plan - so we use "1 month ago" as the period
|
||||
// even free plan can have usage data
|
||||
|
||||
const currentPeriodStart = usageSub?.currentPeriodStart
|
||||
? new Date(usageSub.currentPeriodStart)
|
||||
: subMonths(new Date(), 1);
|
||||
|
||||
const currentPeriodEnd = usageSub?.currentPeriodEnd
|
||||
? new Date(usageSub.currentPeriodEnd)
|
||||
: new Date();
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-12">
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
<UsageCard
|
||||
{...rpcMetrics}
|
||||
name="RPC"
|
||||
tooltip="RPC usage is calculated by requests per second."
|
||||
/>
|
||||
<UsageCard
|
||||
{...gatewayMetrics}
|
||||
name="Storage Gateway"
|
||||
tooltip="Storage gateway usage is calculated by GB per file size."
|
||||
/>
|
||||
<UsageCard
|
||||
{...storageMetrics}
|
||||
name="Storage Pinning"
|
||||
tooltip="Storage pinning usage is calculated by GB per file size."
|
||||
/>
|
||||
<UsageCard
|
||||
{...walletsMetrics}
|
||||
name="Email Wallets"
|
||||
tooltip="Email wallet (with managed recovery code) usage is calculated by monthly active wallets (i.e. active as defined by at least 1 user log-in via email or social within the billing period month)."
|
||||
/>
|
||||
<UsageCard
|
||||
{...bundlerMetrics}
|
||||
name="Account Abstraction"
|
||||
tooltip="(Gasless, Paymaster, Bundler) usage is calculated by sponsored network fees."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-8">
|
||||
<InAppWalletUsersChartCard
|
||||
accountId={account.id}
|
||||
from={currentPeriodStart}
|
||||
to={currentPeriodEnd}
|
||||
/>
|
||||
|
||||
<TotalSponsoredCard
|
||||
accountId={account.id}
|
||||
from={currentPeriodStart}
|
||||
to={currentPeriodEnd}
|
||||
/>
|
||||
|
||||
<UsageCard
|
||||
{...rpcMetrics}
|
||||
name="RPC"
|
||||
description="Amount of RPC requests allowed per second in your plan"
|
||||
/>
|
||||
|
||||
<UsageCard
|
||||
{...gatewayMetrics}
|
||||
name="Storage Gateway"
|
||||
description="Amount of storage gateway requests allowed per second in your plan"
|
||||
/>
|
||||
|
||||
<UsageCard
|
||||
{...storageMetrics}
|
||||
name="Storage Pinning"
|
||||
description="Amount of IPFS Storage pinning allowed in your plan"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="top-6 right-6 mt-6 md:absolute md:mt-0"
|
||||
asChild
|
||||
size="sm"
|
||||
>
|
||||
<Link href={`/team/${team.slug}/~/usage/storage`}>
|
||||
View Pinned Files
|
||||
</Link>
|
||||
</Button>
|
||||
</UsageCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ChartCardProps = {
|
||||
from: Date;
|
||||
to: Date;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
function InAppWalletUsersChartCard(props: ChartCardProps) {
|
||||
const title = "In-App Wallets";
|
||||
const description =
|
||||
"Number of unique users interacting with your apps using in-app wallets every day";
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<InAppWalletUsersChartCardUI
|
||||
inAppWalletStats={[]}
|
||||
isPending={true}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncInAppWalletUsersChartCard
|
||||
{...props}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncInAppWalletUsersChartCard(
|
||||
props: ChartCardProps & {
|
||||
title: string;
|
||||
description: string;
|
||||
},
|
||||
) {
|
||||
const inAppWalletUsage = await getInAppWalletUsage({
|
||||
period: "day",
|
||||
from: props.from,
|
||||
to: props.to,
|
||||
accountId: props.accountId,
|
||||
}).catch(() => null);
|
||||
|
||||
return (
|
||||
<InAppWalletUsersChartCardUI
|
||||
inAppWalletStats={inAppWalletUsage || []}
|
||||
isPending={false}
|
||||
title={props.title}
|
||||
description={props.description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalSponsoredCard(props: ChartCardProps) {
|
||||
const title = "Total Sponsored";
|
||||
const description =
|
||||
"Total amount of USD sponsored across all mainnets with account abstraction";
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<TotalSponsoredChartCardUI
|
||||
data={[]}
|
||||
aggregatedData={[]}
|
||||
className="bg-muted/50"
|
||||
onlyMainnet
|
||||
description={description}
|
||||
title={title}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncTotalSponsoredChartCard
|
||||
{...props}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncTotalSponsoredChartCard(
|
||||
props: ChartCardProps & {
|
||||
description: string;
|
||||
title: string;
|
||||
},
|
||||
) {
|
||||
const { accountId, from, to } = props;
|
||||
const [userOpUsageTimeSeries, userOpUsage] = await Promise.all([
|
||||
// User operations usage
|
||||
getUserOpUsage({
|
||||
accountId: accountId,
|
||||
from: from,
|
||||
to: to,
|
||||
period: "week",
|
||||
}),
|
||||
getUserOpUsage({
|
||||
accountId: accountId,
|
||||
from: from,
|
||||
to: to,
|
||||
period: "all",
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<TotalSponsoredChartCardUI
|
||||
data={userOpUsageTimeSeries}
|
||||
aggregatedData={userOpUsage}
|
||||
className="bg-muted/50"
|
||||
onlyMainnet
|
||||
description={props.description}
|
||||
title={props.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-22
@@ -1,6 +1,3 @@
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { toUSD } from "utils/number";
|
||||
|
||||
@@ -10,7 +7,9 @@ interface UsageCardProps {
|
||||
title?: string;
|
||||
total?: string | number | JSX.Element;
|
||||
progress?: number;
|
||||
tooltip?: string;
|
||||
description: string;
|
||||
children?: JSX.Element;
|
||||
totalUsage?: string;
|
||||
}
|
||||
|
||||
export const UsageCard: React.FC<UsageCardProps> = ({
|
||||
@@ -19,29 +18,28 @@ export const UsageCard: React.FC<UsageCardProps> = ({
|
||||
total,
|
||||
overage,
|
||||
progress,
|
||||
tooltip,
|
||||
description,
|
||||
children,
|
||||
totalUsage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative flex min-h-[150px] flex-col rounded-lg border border-border bg-muted/50 p-4">
|
||||
<h3 className="pr-10 font-medium text-lg">{name}</h3>
|
||||
{tooltip && (
|
||||
<ToolTipLabel label={tooltip}>
|
||||
<CircleHelpIcon className="absolute top-4 right-4 size-5 text-muted-foreground hover:text-foreground" />
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
<div className="relative flex min-h-[190px] flex-col rounded-lg border border-border bg-muted/50 p-4 lg:p-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight">{name}</h3>
|
||||
<p className="text-muted-foreground"> {description}</p>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1.5">
|
||||
{title && <p className="text-foreground text-sm">{title}</p>}
|
||||
{title && <p className="text-foreground">{title}</p>}
|
||||
|
||||
{total !== undefined && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{typeof total === "number" ? toUSD(total) : total}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{progress !== undefined && <Progress value={progress} />}
|
||||
{totalUsage && <p className="mt-2 text-foreground">{totalUsage}</p>}
|
||||
|
||||
{overage && (
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
@@ -50,6 +48,8 @@ export const UsageCard: React.FC<UsageCardProps> = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -60,14 +60,7 @@ function Progress(props: {
|
||||
return (
|
||||
<div className="rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 rounded-full",
|
||||
props.value > 90
|
||||
? "bg-red-600"
|
||||
: props.value > 50
|
||||
? "bg-yellow-600"
|
||||
: "bg-blue-600",
|
||||
)}
|
||||
className={"h-2 rounded-full bg-blue-600"}
|
||||
style={{
|
||||
width: `${Math.min(props.value, 100)}%`,
|
||||
}}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import type {
|
||||
Account,
|
||||
UsageBillableByService,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { format } from "date-fns/format";
|
||||
import Link from "next/link";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getTeamSubscriptions } from "@/api/team-subscription";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PLANS } from "utils/pricing";
|
||||
import { getAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getAccountUsage } from "./getAccountUsage";
|
||||
import { Usage } from "./overview/components/Usage";
|
||||
|
||||
@@ -19,16 +11,19 @@ export default async function Page(props: {
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const account = await getAccount();
|
||||
const account = await getValidAccount(`/team/${params.team_slug}/~/usage`);
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!account) {
|
||||
return redirect(
|
||||
`/login?next=${encodeURIComponent(`/team/${params.team_slug}/~/usage`)}`,
|
||||
);
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
const accountUsage = await getAccountUsage();
|
||||
if (!accountUsage) {
|
||||
const [accountUsage, subscriptions] = await Promise.all([
|
||||
getAccountUsage(),
|
||||
getTeamSubscriptions(team.slug),
|
||||
]);
|
||||
|
||||
if (!accountUsage || !subscriptions) {
|
||||
return (
|
||||
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
|
||||
Something went wrong. Please try again later.
|
||||
@@ -37,114 +32,12 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-10">
|
||||
<PlanInfoCard
|
||||
account={account}
|
||||
accountUsage={accountUsage}
|
||||
team_slug={params.team_slug}
|
||||
/>
|
||||
<Usage usage={accountUsage} />
|
||||
</div>
|
||||
<Usage
|
||||
// TODO - remove accountUsage when we have all the data available from team
|
||||
usage={accountUsage}
|
||||
subscriptions={subscriptions}
|
||||
account={account}
|
||||
team={team}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanInfoCard(props: {
|
||||
account: Account;
|
||||
accountUsage: UsageBillableByService;
|
||||
team_slug: string;
|
||||
}) {
|
||||
const { account, accountUsage } = props;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/50">
|
||||
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
|
||||
<h3 className="font-semibold text-2xl tracking-tight">
|
||||
{PLANS[account.plan as keyof typeof PLANS].title} Plan
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild variant="outline" className="gap-2">
|
||||
<Link href={`/team/${props.team_slug}/~/settings/billing`}>
|
||||
Manage Billing
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild variant="outline" className="gap-2">
|
||||
<TrackedLinkTW
|
||||
href="/pricing"
|
||||
category="billingAccount"
|
||||
label="learn-more-pricing"
|
||||
target="_blank"
|
||||
>
|
||||
View all plans
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="p-6 lg:p-6">
|
||||
<BillingInfo account={account} usage={accountUsage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingInfo({
|
||||
account,
|
||||
usage,
|
||||
}: {
|
||||
account: Account;
|
||||
usage: UsageBillableByService;
|
||||
}) {
|
||||
if (
|
||||
!account.currentBillingPeriodStartsAt ||
|
||||
!account.currentBillingPeriodEndsAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalUsd = getBillingAmountInUSD(usage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h5 className="font-medium">Current Billing Cycle</h5>
|
||||
<p className="text-muted-foreground">
|
||||
{format(
|
||||
new Date(account.currentBillingPeriodStartsAt),
|
||||
"MMMM dd yyyy",
|
||||
)}{" "}
|
||||
-{" "}
|
||||
{format(
|
||||
new Date(account.currentBillingPeriodEndsAt),
|
||||
"MMMM dd yyyy",
|
||||
)}{" "}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<h5 className="font-medium">Total Upcoming Bill</h5>
|
||||
<p className="text-lg text-muted-foreground">{totalUsd}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getBillingAmountInUSD(usage: UsageBillableByService) {
|
||||
let total = 0;
|
||||
|
||||
if (usage.billableUsd) {
|
||||
for (const amount of Object.values(usage.billableUsd)) {
|
||||
total += amount;
|
||||
}
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,12 @@ import { YourFilesSection } from "./your-files";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Storage</h1>
|
||||
<div className="h-6" />
|
||||
<div className="flex flex-col gap-14">
|
||||
<YourFilesSection />
|
||||
<GatewaySection />
|
||||
<CLISection />
|
||||
<SDKSection />
|
||||
<GuidesSection />
|
||||
</div>
|
||||
<div className="flex flex-col gap-14">
|
||||
<YourFilesSection />
|
||||
<GatewaySection />
|
||||
<CLISection />
|
||||
<SDKSection />
|
||||
<GuidesSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-9
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import {
|
||||
type ApiKeyService,
|
||||
accountStatus,
|
||||
useAccount,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
|
||||
@@ -26,24 +24,20 @@ export function AccountAbstractionPage(props: {
|
||||
teamSlug: string;
|
||||
projectKey: string;
|
||||
apiKeyServices: ApiKeyService[];
|
||||
billingStatus: "validPayment" | (string & {}) | null;
|
||||
tab?: string;
|
||||
}) {
|
||||
const { apiKeyServices } = props;
|
||||
const looggedInUserQuery = useLoggedInUser();
|
||||
const accountQuery = useAccount();
|
||||
const chain = useActiveWalletChain();
|
||||
|
||||
const hasSmartWalletsWithoutBilling = useMemo(() => {
|
||||
if (!accountQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
return apiKeyServices.find(
|
||||
(s) =>
|
||||
accountQuery.data.status !== accountStatus.validPayment &&
|
||||
props.billingStatus !== accountStatus.validPayment &&
|
||||
s.name === "bundler",
|
||||
);
|
||||
}, [apiKeyServices, accountQuery.data]);
|
||||
}, [apiKeyServices, props.billingStatus]);
|
||||
|
||||
const isOpChain = chain?.id ? isOpChainId(chain.id) : false;
|
||||
|
||||
|
||||
+12
-2
@@ -1,7 +1,8 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTeamBySlug } from "../../../../../../@/api/team";
|
||||
import { getAbsoluteUrl } from "../../../../../../lib/vercel-utils";
|
||||
import { getAPIKeyForProjectId } from "../../../../../api/lib/getAPIKeys";
|
||||
import { AccountAbstractionPage } from "./AccountAbstractionPage";
|
||||
@@ -11,7 +12,15 @@ export default async function Page(props: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const { team_slug, project_slug } = await props.params;
|
||||
const project = await getProject(team_slug, project_slug);
|
||||
|
||||
const [team, project] = await Promise.all([
|
||||
getTeamBySlug(team_slug),
|
||||
getProject(team_slug, project_slug),
|
||||
]);
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
notFound();
|
||||
@@ -28,6 +37,7 @@ export default async function Page(props: {
|
||||
<AccountAbstractionPage
|
||||
projectSlug={project.slug}
|
||||
teamSlug={team_slug}
|
||||
billingStatus={team.billingStatus}
|
||||
projectKey={project.publishableKey}
|
||||
apiKeyServices={apiKey.services || []}
|
||||
tab={(await props.searchParams).tab}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
@@ -30,10 +29,5 @@ export default async function Layout(props: {
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SidebarLayout sidebarLinks={links}>
|
||||
<BillingAlerts className="mb-10" />
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
);
|
||||
return <SidebarLayout sidebarLinks={links}>{props.children}</SidebarLayout>;
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import {} from "@/components/ui/dropdown-menu";
|
||||
import {} from "@/components/ui/select";
|
||||
import { Layers3 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export type Blueprint = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function BlueprintsExplorer(props: {
|
||||
blueprints: Blueprint[];
|
||||
}) {
|
||||
const { blueprints } = props;
|
||||
return (
|
||||
<div className="container">
|
||||
{/* Blueprints */}
|
||||
{blueprints.length === 0 ? (
|
||||
<div className="flex h-[450px] items-center justify-center rounded-lg border border-border ">
|
||||
No blueprints found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{blueprints.map((blueprint) => {
|
||||
return <BlueprintCard key={blueprint.id} blueprint={blueprint} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlueprintCard(props: {
|
||||
blueprint: Blueprint;
|
||||
}) {
|
||||
const { blueprint } = props;
|
||||
return (
|
||||
<div
|
||||
key={blueprint.id}
|
||||
className="relative flex items-center gap-4 rounded-lg border border-border bg-muted/50 p-4 transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Layers3 className="size-10" />
|
||||
|
||||
<div>
|
||||
<Link
|
||||
className="group static before:absolute before:top-0 before:right-0 before:bottom-0 before:left-0 before:z-0"
|
||||
href={`https://portal.thirdweb.com/insight/blueprints#${blueprint.slug}`}
|
||||
>
|
||||
<h2 className="font-medium text-base">{blueprint.name}</h2>
|
||||
</Link>
|
||||
|
||||
<p className="my-1 text-muted-foreground/70 text-xs">
|
||||
{blueprint.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { type Blueprint, BlueprintsExplorer } from "./BlueprintsExplorer";
|
||||
import { BlueprintsPageHeader } from "./BlueprintsPageHeader";
|
||||
|
||||
export function BlueprintsPage() {
|
||||
return (
|
||||
<div>
|
||||
<BlueprintsPageHeader />
|
||||
<div className="h-6" />
|
||||
<BlueprintsPageContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getProjectBlueprints() {
|
||||
return [
|
||||
{
|
||||
id: "1",
|
||||
name: "Transactions",
|
||||
slug: "transactions-blueprint",
|
||||
description: "Query transaction data",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Events",
|
||||
slug: "events-blueprint",
|
||||
description: "Query event data",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Tokens",
|
||||
slug: "tokens-blueprint",
|
||||
description: "Query ERC-20, ERC-721, and ERC-1155 tokens",
|
||||
},
|
||||
] as Blueprint[];
|
||||
}
|
||||
|
||||
function BlueprintsPageContent() {
|
||||
const blueprints = getProjectBlueprints();
|
||||
|
||||
return <BlueprintsExplorer blueprints={blueprints} />;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
export function BlueprintsPageHeader() {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="font-semibold text-2xl tracking-tight sm:text-3xl">
|
||||
Insight
|
||||
</h1>
|
||||
<Button
|
||||
className="w-full cursor-not-allowed gap-2 opacity-50 sm:w-auto"
|
||||
disabled
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Create Blueprint (Coming Soon)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken";
|
||||
import { BlueprintsPage } from "./components/BlueprintsPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const accountAddress = await getAuthTokenWalletAddress();
|
||||
|
||||
if (!accountAddress) {
|
||||
const { team_slug, project_slug } = await props.params;
|
||||
return redirect(
|
||||
`/login?next=${encodeURIComponent(`/team/${team_slug}/${project_slug}/insight`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return <BlueprintsPage />;
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export default async function TeamLayout(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const teams = await getTeams();
|
||||
|
||||
if (!teams) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const team = teams.find((t) => t.slug === params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
@@ -69,6 +74,10 @@ export default async function TeamLayout(props: {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/insight`,
|
||||
name: "Insight",
|
||||
},
|
||||
{
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/settings`,
|
||||
name: "Settings",
|
||||
|
||||
+11
-3
@@ -3,7 +3,7 @@ import { OrbitIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ShareButton } from "./share-button.client";
|
||||
|
||||
export function NebulaWaitListPageUI() {
|
||||
export function NebulaWaitListPageUI(props: { teamId: string }) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
{/* Header */}
|
||||
@@ -22,7 +22,15 @@ export function NebulaWaitListPageUI() {
|
||||
<CenteredCard
|
||||
title="You're on the waitlist"
|
||||
description="You should receive access to Nebula soon!"
|
||||
footer={<ShareButton />}
|
||||
footer={
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<ShareButton teamId={props.teamId} />
|
||||
<p className="text-balance text-center text-muted-foreground">
|
||||
Share this invite link and get moved up the list when your
|
||||
friends sign up!
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +90,7 @@ function CenteredCard(props: {
|
||||
{props.description}
|
||||
</p>
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="h-10" />
|
||||
|
||||
{props.footer}
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -26,20 +26,20 @@ export async function NebulaWaitListPage(props: {
|
||||
|
||||
// if not already on the waitlist, join the waitlist
|
||||
if (!nebulaWaitList.onWaitlist) {
|
||||
const joined = await joinTeamWaitlist({
|
||||
const res = await joinTeamWaitlist({
|
||||
scope: "nebula",
|
||||
teamSlug: team.slug,
|
||||
}).catch(() => null);
|
||||
|
||||
// this should never happen
|
||||
if (!joined) {
|
||||
if (!res?.success) {
|
||||
return (
|
||||
<UnexpectedErrorPage message="Failed to join Nebula waitlist status for your team" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <NebulaWaitListPageUI />;
|
||||
return <NebulaWaitListPageUI teamId={team.id} />;
|
||||
}
|
||||
|
||||
function UnexpectedErrorPage(props: {
|
||||
|
||||
+1
-1
@@ -29,5 +29,5 @@ export const Mobile: Story = {
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return <NebulaWaitListPageUI />;
|
||||
return <NebulaWaitListPageUI teamId="foo" />;
|
||||
}
|
||||
|
||||
+8
-3
@@ -5,16 +5,21 @@ import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { CheckIcon, ShareIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ShareButton() {
|
||||
export function ShareButton(props: {
|
||||
teamId: string;
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<ToolTipLabel label="Copy link for joining waitlist">
|
||||
<ToolTipLabel label="Copy Invite Link">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText("https://thirdweb.com/team/~/~/nebula");
|
||||
const url = new URL("https://thirdweb.com/nebula");
|
||||
url.searchParams.append("utm_content", props.teamId);
|
||||
url.searchParams.append("utm_campaign", "nebula");
|
||||
navigator.clipboard.writeText(url.href);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
}}
|
||||
|
||||
@@ -245,6 +245,7 @@ function UsersChartCard({
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
title="Users"
|
||||
chartConfig={chartConfig}
|
||||
activeChart={chartKey}
|
||||
@@ -383,6 +384,7 @@ async function TotalSponsoredCard({
|
||||
isCurrency
|
||||
title="Total Sponsored"
|
||||
chartConfig={chartConfig}
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
data={timeSeriesData}
|
||||
activeChart={
|
||||
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
|
||||
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -41,7 +40,6 @@ export default async function Layout(props: {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<BillingAlerts className="mb-10" />
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,11 @@ export function AnalyticsHeader(props: {
|
||||
const { title, interval, range } = props;
|
||||
|
||||
return (
|
||||
<div className="container flex flex-col items-start gap-6 px-2 py-6 md:h-[120px] md:flex-row md:items-center md:p-6 md:py-0">
|
||||
<div className="container flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
|
||||
<div className="flex-1">
|
||||
<h1 className="font-semibold text-3xl text-foreground">{title}</h1>
|
||||
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<RangeSelector interval={interval} range={range} />
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { toUSD } from "utils/number";
|
||||
import { EmptyChartState } from "../../../../components/analytics/empty-chart-state";
|
||||
|
||||
export function BarChart({
|
||||
chartConfig,
|
||||
@@ -21,12 +22,14 @@ export function BarChart({
|
||||
activeKey,
|
||||
tooltipLabel,
|
||||
isCurrency = false,
|
||||
emptyChartContent,
|
||||
}: {
|
||||
chartConfig: ChartConfig;
|
||||
data: { [key in string]: number | string }[];
|
||||
activeKey: string;
|
||||
tooltipLabel?: string;
|
||||
isCurrency?: boolean;
|
||||
emptyChartContent?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ChartContainer
|
||||
@@ -38,62 +41,66 @@ export function BarChart({
|
||||
}}
|
||||
className="aspect-auto h-[250px] w-full pt-6"
|
||||
>
|
||||
<RechartsBarChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
{data.length === 0 ? (
|
||||
<EmptyChartState> {emptyChartContent} </EmptyChartState>
|
||||
) : (
|
||||
<RechartsBarChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
width={48}
|
||||
dataKey={activeKey}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: number) => formatTickerNumber(value)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[200px]"
|
||||
nameKey={activeKey}
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}}
|
||||
valueFormatter={(v: unknown) =>
|
||||
isCurrency
|
||||
? toUSD(v as number)
|
||||
: formatTickerNumber(v as number)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey={activeKey}
|
||||
radius={4}
|
||||
fill={chartConfig[activeKey]?.color ?? "hsl(var(--chart-1))"}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
width={48}
|
||||
dataKey={activeKey}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: number) => formatTickerNumber(value)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[200px]"
|
||||
nameKey={activeKey}
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}}
|
||||
valueFormatter={(v: unknown) =>
|
||||
isCurrency
|
||||
? toUSD(v as number)
|
||||
: formatTickerNumber(v as number)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey={activeKey}
|
||||
radius={4}
|
||||
fill={chartConfig[activeKey]?.color ?? "hsl(var(--chart-1))"}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ type CombinedBarChartConfig<K extends string> = {
|
||||
[key in K]: { label: string; color: string };
|
||||
};
|
||||
|
||||
// TODO - don't reload page on tab change -> make this a client component, load all the charts at once on server and switch between them on client
|
||||
|
||||
export function CombinedBarChartCard<
|
||||
T extends string,
|
||||
K extends Exclude<T, "date">,
|
||||
@@ -27,6 +29,7 @@ export function CombinedBarChartCard<
|
||||
1
|
||||
: undefined,
|
||||
existingQueryParams,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
chartConfig: CombinedBarChartConfig<K>;
|
||||
@@ -37,15 +40,17 @@ export function CombinedBarChartCard<
|
||||
aggregateFn?: (d: typeof data, key: K) => number | undefined;
|
||||
trendFn?: (d: typeof data, key: K) => number | undefined;
|
||||
existingQueryParams?: { [key: string]: string | string[] | undefined };
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0">
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
|
||||
{title && (
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
|
||||
<CardTitle className="font-semibold text-lg">{title}</CardTitle>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-md:no-scrollbar overflow-x-auto border-t">
|
||||
<div className="flex flex-nowrap">
|
||||
{Object.keys(chartConfig).map((chart: string) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import walletsIcon from "../../../../../public/assets/tw-icons/wallets.svg";
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<section className="flex items-start justify-center md:min-h-[500px]">
|
||||
<section className="flex w-full items-start justify-center md:min-h-[500px]">
|
||||
<div className="group container flex flex-col items-center justify-center gap-8 rounded-lg border bg-card p-6 py-24">
|
||||
<div className="flex max-w-[500px] flex-col items-center justify-center gap-6">
|
||||
<AnimatedIcons />
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import type { Team } from "@/api/team";
|
||||
|
||||
export function getValidTeamPlan(team: Team): Team["billingPlan"] {
|
||||
switch (true) {
|
||||
case team.billingPlan === "pro" && team.billingStatus === "validPayment": {
|
||||
return "pro";
|
||||
}
|
||||
case team.billingPlan === "growth" &&
|
||||
team.billingStatus === "validPayment": {
|
||||
return "growth";
|
||||
}
|
||||
|
||||
default: {
|
||||
return "free";
|
||||
}
|
||||
if (team.billingStatus !== "validPayment") {
|
||||
return "free";
|
||||
}
|
||||
|
||||
return team.billingPlan;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { LazyCreateAPIKeyDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { doLogout } from "../../../login/auth-actions";
|
||||
import {
|
||||
type TeamHeaderCompProps,
|
||||
TeamHeaderDesktopUI,
|
||||
@@ -30,9 +31,7 @@ export function TeamHeaderLoggedIn(props: {
|
||||
const logout = useCallback(async () => {
|
||||
// log out the user
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
await doLogout();
|
||||
if (activeWallet) {
|
||||
disconnect(activeWallet);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user