diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index 78531427e9d..58a70941593 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -115,6 +115,7 @@ const SENTRY_OPTIONS: SentryBuildOptions = { }; const baseNextConfig: NextConfig = { + serverExternalPackages: ["pino-pretty"], async headers() { return [ { diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 61b82ff70a3..2b22c8f197f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -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:*", diff --git a/apps/dashboard/src/@/actions/billing.ts b/apps/dashboard/src/@/actions/billing.ts new file mode 100644 index 00000000000..a3dc991138c --- /dev/null +++ b/apps/dashboard/src/@/actions/billing.ts @@ -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; +}; +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); +} diff --git a/apps/dashboard/src/@/api/team-subscription.ts b/apps/dashboard/src/@/api/team-subscription.ts new file mode 100644 index 00000000000..b5d91133a86 --- /dev/null +++ b/apps/dashboard/src/@/api/team-subscription.ts @@ -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; +} diff --git a/apps/dashboard/src/@/api/team.ts b/apps/dashboard/src/@/api/team.ts index e60af9f4615..b91fabbbd74 100644 --- a/apps/dashboard/src/@/api/team.ts +++ b/apps/dashboard/src/@/api/team.ts @@ -13,9 +13,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) { diff --git a/apps/dashboard/src/@/components/billing.tsx b/apps/dashboard/src/@/components/billing.tsx new file mode 100644 index 00000000000..5f2485ea978 --- /dev/null +++ b/apps/dashboard/src/@/components/billing.tsx @@ -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 ( + + ); +} + +type BillingPortalButtonProps = BillingPortalOptions & ButtonProps; +export function BillingPortalButton({ + onClick, + teamSlug, + redirectPath, + children, + ...restProps +}: BillingPortalButtonProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/@/constants/env.ts b/apps/dashboard/src/@/constants/env.ts index a174c2dc2b1..faf219c8263 100644 --- a/apps/dashboard/src/@/constants/env.ts +++ b/apps/dashboard/src/@/constants/env.ts @@ -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 function getAbsoluteUrlFromPath(path: string) { + const url = new 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", + ); + + url.pathname = path; + return url; +} diff --git a/apps/dashboard/src/@/lib/billing.ts b/apps/dashboard/src/@/lib/billing.ts new file mode 100644 index 00000000000..58dfeffcf13 --- /dev/null +++ b/apps/dashboard/src/@/lib/billing.ts @@ -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; diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts index 26317e53c94..f3b5f976741 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts @@ -30,7 +30,7 @@ export const accountPlan = { } as const; type AccountStatus = (typeof accountStatus)[keyof typeof accountStatus]; -export type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan]; +type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan]; export type AuthorizedWallet = { id: string; @@ -288,30 +288,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 +474,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 +506,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(); @@ -681,40 +582,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({ diff --git a/apps/dashboard/src/app/account/contracts/_components/DeployedContractsPage.tsx b/apps/dashboard/src/app/account/contracts/_components/DeployedContractsPage.tsx index 32c9cff669c..d7d137e31d1 100644 --- a/apps/dashboard/src/app/account/contracts/_components/DeployedContractsPage.tsx +++ b/apps/dashboard/src/app/account/contracts/_components/DeployedContractsPage.tsx @@ -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 (
-
}> diff --git a/apps/dashboard/src/app/account/devices/page.tsx b/apps/dashboard/src/app/account/devices/page.tsx index 9f368180eaf..225df43a100 100644 --- a/apps/dashboard/src/app/account/devices/page.tsx +++ b/apps/dashboard/src/app/account/devices/page.tsx @@ -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 (
-
diff --git a/apps/dashboard/src/app/account/page.tsx b/apps/dashboard/src/app/account/page.tsx index 45859311b43..f1ae3ab1b00 100644 --- a/apps/dashboard/src/app/account/page.tsx +++ b/apps/dashboard/src/app/account/page.tsx @@ -1,7 +1,6 @@ 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 { AccountTeamsUI } from "./overview/AccountTeamsUI"; import { getAccount } from "./settings/getAccount"; @@ -40,7 +39,6 @@ export default async function Page() { return (
- - { diff --git a/apps/dashboard/src/app/account/settings/getAccount.ts b/apps/dashboard/src/app/account/settings/getAccount.ts index 6eb240dca23..1e997495db4 100644 --- a/apps/dashboard/src/app/account/settings/getAccount.ts +++ b/apps/dashboard/src/app/account/settings/getAccount.ts @@ -4,11 +4,8 @@ import { getAuthToken } from "../../api/lib/getAuthToken"; export async function getAccount() { const authToken = await getAuthToken(); - const apiServerURL = new URL(API_SERVER_URL); - apiServerURL.pathname = "/v1/account/me"; - - const res = await fetch(apiServerURL, { + const res = await fetch(`${API_SERVER_URL}/v1/account/me`, { method: "GET", headers: { Authorization: `Bearer ${authToken}`, diff --git a/apps/dashboard/src/app/account/wallets/page.tsx b/apps/dashboard/src/app/account/wallets/page.tsx index 5c3e09c61b6..e7d2e7e88c4 100644 --- a/apps/dashboard/src/app/account/wallets/page.tsx +++ b/apps/dashboard/src/app/account/wallets/page.tsx @@ -1,10 +1,8 @@ -import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert"; import { LinkWalletUI } from "./LinkWalletUI"; export default async function Page() { return (
- + // 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(); + 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 ( + 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 + } + /> + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/page.tsx index 40e91d7e593..f17957d3bfd 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/page.tsx @@ -8,7 +8,6 @@ import { notFound, 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"; @@ -34,6 +28,7 @@ import { getTeamBySlug } from "@/api/team"; import { getAccount } from "app/account/settings/getAccount"; import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard"; import { Changelog, type ChangelogItem } from "components/dashboard/Changelog"; +import { TotalSponsoredChartCardUI } from "./_components/TotalSponsoredCard"; // revalidate every 5 minutes export const revalidate = 300; @@ -170,13 +165,12 @@ export default async function TeamOverviewPage(props: { )}
{userOpUsage.length > 0 ? ( -
- -
+ ) : ( ); } - -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(); - 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 ( - 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 - } - /> - ); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx index 0f197e8f03c..ceb8b62a3ed 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx @@ -154,8 +154,8 @@ export function EcosystemWalletUsersChartCard(props: { chartData.every((data) => data[authMethod] === 0), ) ? ( -
- +
+ Connect users to your app with social logins
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/layout.tsx index c40be11f46d..1947747134c 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/layout.tsx @@ -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`} > - {props.children} ); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/ConfirmEngineTierDialog.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/ConfirmEngineTierDialog.tsx deleted file mode 100644 index 03ec0d8cf72..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/ConfirmEngineTierDialog.tsx +++ /dev/null @@ -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 ( - - - - - Are you sure you want to deploy a{" "} - {tier === "STARTER" ? "Standard" : "Premium"} Engine? - - - - You will be charged ${MONTHLY_PRICE_USD[tier]} per month for the - subscription - - - - - - - - - - ); -}; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx index d4dfe384e13..df10460d08f 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx @@ -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 support@thirdweb.com 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 (
- {selectedTier && ( - { - 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); - } - }} - /> - )} - - - { - if (!selectedTier) { - return; - } - - await createEngineInstance(selectedTier); - setIsBillingModalOpen(false); - }} - onCancel={() => setIsBillingModalOpen(false)} - /> - -

Choose an Engine deployment

@@ -116,36 +12,9 @@ export default function Page() {

- { - setSelectedTier("STARTER"); - setIsConfirmationDialogOpen(true); - }} - /> - - { - setSelectedTier("PREMIUM"); - setIsConfirmationDialogOpen(true); - }} - /> - - { - trackEvent({ - category: "engine", - action: "click", - label: "clicked-cloud-hosted", - tier: "ENTERPRISE", - }); - router.push("/contact-us"); - }} - /> + + +
); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card.tsx index 126f6af86e3..f1912ddad57 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card.tsx @@ -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 = { }, }; -export const MONTHLY_PRICE_USD: Record = { - 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 (
{/* Name */} -

{name}

+

{name}

{/* Price */} {monthlyPriceUsd === "custom" ? ( @@ -114,11 +112,43 @@ export const EngineTierCard = ({ {/* CTA */} - + {tier === "ENTERPRISE" ? ( + + ) : ( + { + trackEvent({ + category: "engine", + action: "click", + label: "clicked-cloud-hosted", + tier, + }); + }} + variant={isPrimaryCta ? "default" : "outline"} + > + {ctaText ?? defaultCtaText} + + )}
); - - return card; }; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/layout.tsx index 4808a082b91..d64041c92b6 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/layout.tsx @@ -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 ( - - - {props.children} - + {props.children} ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/layout.tsx index 6c414773146..9cb3ab45fd2 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/layout.tsx @@ -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 ( - {props.children} ); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx index 27a941cab92..b6b2abc60cc 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx @@ -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 (
- {/* Filters + Add New */}
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/BillingSettingsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/BillingSettingsPage.tsx deleted file mode 100644 index a2e46a8c581..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/BillingSettingsPage.tsx +++ /dev/null @@ -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 ; - } - - return ; -}; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx new file mode 100644 index 00000000000..f2a52b0444b --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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 ( +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx new file mode 100644 index 00000000000..5278a84cb77 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx @@ -0,0 +1,197 @@ +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 ( +
+
+
+
+

+ {validPlan} Plan +

+ {trialEndsInFuture && Trial} +
+ {trialEndsAfterDays > 0 && ( +

+ Your trial ends in {trialEndsAfterDays} days +

+ )} +
+
+ + Manage Billing + +
+ + {isActualFreePlan && ( +
+ {/* manage team billing */} + + Manage Billing + + + +
+ )} +
+ + + +
+ {isActualFreePlan ? ( +
+ +

Your plan includes a fixed amount of free usage

+

+ To unlock additional usage, Upgrade your plan to Starer or Growth +

+
+ ) : ( + + )} +
+
+ ); +} + +function BillingInfo({ + subscriptions, +}: { + subscriptions: TeamSubscription[]; +}) { + const planSubscription = subscriptions.find( + (subscription) => subscription.type === "PLAN", + ); + + const usageSubscription = subscriptions.find( + (subscription) => subscription.type === "USAGE", + ); + + // only plan and usage subscriptions are considered for now + const totalUsd = getAllSubscriptionsTotal( + subscriptions.filter((sub) => sub.type === "PLAN" || sub.type === "USAGE"), + ); + + return ( +
+ {planSubscription && ( + + )} + + + + {usageSubscription && ( + + )} + + + +
+
Total Upcoming Bill
+

{totalUsd}

+
+
+ ); +} + +function SubscriptionOverview(props: { + subscription: TeamSubscription; + title: string; +}) { + const { subscription } = props; + + return ( +
+
+
+
{props.title}
+

+ {format( + new Date(props.subscription.currentPeriodStart), + "MMMM dd yyyy", + )}{" "} + -{" "} + {format( + new Date(props.subscription.currentPeriodEnd), + "MMMM dd yyyy", + )}{" "} +

+
+ +

+ {formatCurrencyAmount( + subscription.upcomingInvoice.amount || 0, + subscription.upcomingInvoice.currency, + )} +

+
+
+ ); +} + +function getAllSubscriptionsTotal(subscriptions: TeamSubscription[]) { + let totalCents = 0; + let currency = "USD"; + + for (const subscription of subscriptions) { + const amount = subscription.upcomingInvoice.amount; + currency = subscription.upcomingInvoice.currency; + if (amount) { + totalCents += amount; + } + } + + return formatCurrencyAmount(totalCents, currency); +} + +function formatCurrencyAmount(centsAmount: number, currency: string) { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency, + }).format(centsAmount / 100); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/page.tsx index 68de40cd4bd..4a50b76a257 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/page.tsx @@ -1,22 +1,38 @@ 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 { getAccount } 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 account = await getAccount(); + if (!account) { + redirect( + `/login?next=${encodeURIComponent(`/team/${params.team_slug}/settings/billing`)}`, + ); + } + + const team = await getTeamBySlug(params.team_slug); if (!team) { - notFound(); + redirect("/team"); + } + + const subscriptions = await getTeamSubscriptions(team.slug); + + if (!subscriptions) { + return ( +
+ Something went wrong. Please try again later. +
+ ); } - return ( - - - - ); + return ; } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/SettingsCreditsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/SettingsCreditsPage.tsx index c8856d4a166..b576c0adbd4 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/SettingsCreditsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/SettingsCreditsPage.tsx @@ -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 ( -
- -
- ); - } - +export const SettingsGasCreditsPage = (props: { + team: Team; + account: Account; +}) => { return (
@@ -33,7 +25,7 @@ export const SettingsGasCreditsPage = () => {
- +
); }; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/page.tsx index d10f6f4e3d4..54cfcc6a23a 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/page.tsx @@ -1,10 +1,33 @@ +import { getTeamBySlug } from "@/api/team"; import { ChakraProviderSetup } from "@/components/ChakraProviderSetup"; +import { redirect } from "next/navigation"; +import { getAccount } 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 getAccount(); + + if (!account) { + return redirect( + `/login?next=${encodeURIComponent(`/team/${params.team_slug}/~/settings/credits`)}`, + ); + } + + const team = await getTeamBySlug(params.team_slug); + + if (!team) { + return redirect("/team"); + } + return ( - + ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx index 6a8c22fa0f7..222d01b92a4 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx @@ -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: "foo@example.com", -}; +const testTeam = teamStub("foo", "free"); function Story() { return ( diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/layout.tsx index ed3fe9e5d3b..7d22fb821bb 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/layout.tsx @@ -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 ( - - - {props.children} - - ); + return {props.children}; } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/layout.tsx index c18d7134106..7bd6c02a61f 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/layout.tsx @@ -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 ( - - - {props.children} - +
+
+
+

+ Usage +

+
+
+ + {props.children} + +
); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/overview/components/Usage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/overview/components/Usage.tsx index c69825da0d4..624301c8287 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/overview/components/Usage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/overview/components/Usage.tsx @@ -1,29 +1,31 @@ -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 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 = { usage: UsageBillableByService; -} - -export const Usage: React.FC = ({ 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 = ({ + usage: usageData, + subscriptions, + account, + team, +}) => { const storageMetrics = useMemo(() => { if (!usageData) { return {}; @@ -46,35 +48,13 @@ export const Usage: React.FC = ({ usage: usageData }) => { }; }, [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, - } - : {}), - }; - }, [usageData]); - const rpcMetrics = useMemo(() => { if (!usageData) { return {}; } return { - title: "Unlimited requests", + title: "Unlimited Requests", total: ( {usageData.rateLimits.rpc} Requests Per Second @@ -89,7 +69,7 @@ export const Usage: React.FC = ({ usage: usageData }) => { } return { - title: "Unlimited requests", + title: "Unlimited Requests", total: ( {usageData.rateLimits.storage} Requests Per Second @@ -98,35 +78,170 @@ export const Usage: React.FC = ({ usage: usageData }) => { }; }, [usageData]); + const usageSub = subscriptions.find((sub) => sub.type === "USAGE"); + return ( -
-
- - - - - -
+
+ {usageSub && ( + <> + + + + + )} + + + + + + + +
); }; + +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 ( + + } + > + + + ); +} + +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 ( + + ); +} + +function TotalSponsoredCard(props: ChartCardProps) { + const title = "Total Sponsored"; + const description = + "Total amount of USD sponsored across all mainnets with account abstraction"; + + return ( + + } + > + + + ); +} + +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 ( + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/overview/components/UsageCard.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/overview/components/UsageCard.tsx index fe396c39301..4e3734d08dd 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/overview/components/UsageCard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/overview/components/UsageCard.tsx @@ -1,6 +1,4 @@ -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 +8,8 @@ interface UsageCardProps { title?: string; total?: string | number | JSX.Element; progress?: number; - tooltip?: string; + description: string; + children?: JSX.Element; } export const UsageCard: React.FC = ({ @@ -19,21 +18,18 @@ export const UsageCard: React.FC = ({ total, overage, progress, - tooltip, + description, + children, }) => { return ( -
-

{name}

- {tooltip && ( - - - - )} +
+

{name}

+

{description}

- {title &&

{title}

} + {title &&

{title}

} {total !== undefined && (

@@ -50,6 +46,8 @@ export const UsageCard: React.FC = ({

)}
+ + {children}
); }; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/page.tsx index 7530c6ec28b..ce12c42f57e 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/page.tsx @@ -1,14 +1,6 @@ -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 { getAccountUsage } from "./getAccountUsage"; import { Usage } from "./overview/components/Usage"; @@ -22,13 +14,23 @@ export default async function Page(props: { const account = await getAccount(); if (!account) { - return redirect( + redirect( `/login?next=${encodeURIComponent(`/team/${params.team_slug}/~/usage`)}`, ); } - const accountUsage = await getAccountUsage(); - if (!accountUsage) { + const team = await getTeamBySlug(params.team_slug); + + if (!team) { + redirect("/team"); + } + + const [accountUsage, subscriptions] = await Promise.all([ + getAccountUsage(), + getTeamSubscriptions(team.slug), + ]); + + if (!accountUsage || !subscriptions) { return (
Something went wrong. Please try again later. @@ -37,114 +39,11 @@ export default async function Page(props: { } return ( -
- - -
+ ); } - -function PlanInfoCard(props: { - account: Account; - accountUsage: UsageBillableByService; - team_slug: string; -}) { - const { account, accountUsage } = props; - - return ( -
-
-

- {PLANS[account.plan as keyof typeof PLANS].title} Plan -

- -
- - - -
-
- - - -
- -
-
- ); -} - -function BillingInfo({ - account, - usage, -}: { - account: Account; - usage: UsageBillableByService; -}) { - if ( - !account.currentBillingPeriodStartsAt || - !account.currentBillingPeriodEndsAt - ) { - return null; - } - - const totalUsd = getBillingAmountInUSD(usage); - - return ( -
-
-
Current Billing Cycle
-

- {format( - new Date(account.currentBillingPeriodStartsAt), - "MMMM dd yyyy", - )}{" "} - -{" "} - {format( - new Date(account.currentBillingPeriodEndsAt), - "MMMM dd yyyy", - )}{" "} -

-
- - - -
-
Total Upcoming Bill
-

{totalUsd}

-
-
- ); -} - -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); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/storage/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/storage/page.tsx index ea7da43ad1d..d563ce1578a 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/storage/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/storage/page.tsx @@ -6,16 +6,12 @@ import { YourFilesSection } from "./your-files"; export default function Page() { return ( -
-

Storage

-
-
- - - - - -
+
+ + + + +
); } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/layout.tsx index 81bf85e75eb..46963a67fdc 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/layout.tsx @@ -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 ( - - - {props.children} - - ); + return {props.children}; } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx index 404020300c8..d5b0f0e8e26 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx @@ -245,6 +245,7 @@ function UsersChartCard({ return ( - {props.children}
diff --git a/apps/dashboard/src/app/team/components/Analytics/CombinedBarChartCard.tsx b/apps/dashboard/src/app/team/components/Analytics/CombinedBarChartCard.tsx index 1af39675ce1..bd7c2d306f8 100644 --- a/apps/dashboard/src/app/team/components/Analytics/CombinedBarChartCard.tsx +++ b/apps/dashboard/src/app/team/components/Analytics/CombinedBarChartCard.tsx @@ -8,6 +8,8 @@ type CombinedBarChartConfig = { [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, @@ -27,6 +29,9 @@ export function CombinedBarChartCard< 1 : undefined, existingQueryParams, + className, + hideTabs, + description, }: { title?: string; chartConfig: CombinedBarChartConfig; @@ -37,51 +42,60 @@ 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; + hideTabs?: boolean; + description?: string; }) { return ( - + {title && (
{title} + {description && ( +

{description}

+ )}
)} -
-
- {Object.keys(chartConfig).map((chart: string) => { - const key = chart as K; - return ( - - -
+
+ {Object.keys(chartConfig).map((chart: string) => { + const key = chart as K; + return ( + - - ); - })} + className="relative z-30 flex min-w-[200px] flex-1 flex-col justify-center gap-1 border-l first:border-l-0 hover:bg-muted/50" + > + +
+ + ); + })} +
-
+ )} -
+
{children ?? "No data available"}
diff --git a/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.stories.tsx b/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.stories.tsx new file mode 100644 index 00000000000..4a7e4783f55 --- /dev/null +++ b/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.stories.tsx @@ -0,0 +1,131 @@ +import type { InAppWalletStats } from "@/api/analytics"; +import type { Meta, StoryObj } from "@storybook/react"; +import type { InAppWalletAuth } from "thirdweb/wallets"; +import { BadgeContainer, mobileViewport } from "../../../stories/utils"; +import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard"; + +const meta = { + title: "Charts/InAppWallets", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +function Component() { + const title = "This is Title"; + const description = + "This is an example of a description about in-app wallet usage chart"; + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ); +} + +const authMethodsToPickFrom: InAppWalletAuth[] = [ + "google", + "apple", + "facebook", + "discord", + "line", + "x", + "coinbase", + "farcaster", + "telegram", + "github", + "twitch", + "steam", + "guest", + "email", + "phone", + "passkey", + "wallet", +]; + +const pickRandomAuthMethod = () => { + const picked = + authMethodsToPickFrom[ + Math.floor(Math.random() * authMethodsToPickFrom.length) + ] || "google"; + + const capitalized = picked.charAt(0).toUpperCase() + picked.slice(1); + return capitalized; +}; + +function createInAppWalletStatsStub(days: number): InAppWalletStats[] { + const stubbedData: InAppWalletStats[] = []; + + let d = days; + while (d !== 0) { + const uniqueWallets = Math.floor(Math.random() * 100); + stubbedData.push({ + date: new Date(2024, 1, d).toLocaleString(), + uniqueWalletsConnected: uniqueWallets, + authenticationMethod: pickRandomAuthMethod(), + }); + + if (Math.random() > 0.7) { + d--; + } + } + + return stubbedData; +} diff --git a/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx b/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx index 6c775cddcab..18c261e9770 100644 --- a/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx +++ b/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx @@ -28,9 +28,11 @@ type ChartData = Record & { }; const defaultLabel = "Unknown Auth"; -export function InAppWalletUsersChartCard(props: { +export function InAppWalletUsersChartCardUI(props: { inAppWalletStats: InAppWalletStats[]; isPending: boolean; + title: string; + description: string; }) { const { inAppWalletStats } = props; const topChainsToShow = 10; @@ -113,32 +115,28 @@ export function InAppWalletUsersChartCard(props: { return (
-

- Unique Users +

+ {props.title}

-

- The total number of active in-app wallet users on your project. -

- -
- { - // Shows the number of each type of wallet connected on all dates - const header = ["Date", ...uniqueAuthMethods]; - const rows = chartData.map((data) => { - const { time, ...rest } = data; - return [ - time, - ...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()), - ]; - }); - return { header, rows }; - }} - /> -
+

{props.description}

+ + { + // Shows the number of each type of wallet connected on all dates + const header = ["Date", ...uniqueAuthMethods]; + const rows = chartData.map((data) => { + const { time, ...rest } = data; + return [ + time, + ...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()), + ]; + }); + return { header, rows }; + }} + /> {/* Chart */} data.sponsoredUsd === 0) ? ( -
- +
+ Connect users to your app with social logins
@@ -210,6 +208,7 @@ export function InAppWalletUsersChartCard(props: { tickLine={false} axisLine={false} tickFormatter={(value) => formatTickerNumber(value)} + tickMargin={10} />
-
diff --git a/apps/dashboard/src/components/homepage/sections/FeatureItem.tsx b/apps/dashboard/src/components/homepage/sections/FeatureItem.tsx index 6fb21364759..6f70f6a58f6 100644 --- a/apps/dashboard/src/components/homepage/sections/FeatureItem.tsx +++ b/apps/dashboard/src/components/homepage/sections/FeatureItem.tsx @@ -1,52 +1,29 @@ -import { Flex, Tooltip } from "@chakra-ui/react"; -import { CircleCheckIcon, CircleDollarSignIcon } from "lucide-react"; -import { Card, Text } from "tw-components"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { CheckIcon, CircleDollarSignIcon } from "lucide-react"; -interface FeatureItemProps { +type FeatureItemProps = { text: string | string[]; -} +}; -export const FeatureItem: React.FC = ({ text }) => { +export function FeatureItem({ text }: FeatureItemProps) { const titleStr = Array.isArray(text) ? text[0] : text; return ( - - +
+ {Array.isArray(text) ? ( - - {titleStr} - - - {text[1]} - - - } - p={0} - bg="transparent" - boxShadow="none" - > - - - - - - {text[1]} - - +
+

+ {titleStr}{" "} + {text[1]} +

+ + + +
) : ( - {titleStr} +

{titleStr}

)} - +
); -}; +} diff --git a/apps/dashboard/src/components/homepage/sections/PricingCard.tsx b/apps/dashboard/src/components/homepage/sections/PricingCard.tsx index f1fe0e2cf75..b7a05bd6665 100644 --- a/apps/dashboard/src/components/homepage/sections/PricingCard.tsx +++ b/apps/dashboard/src/components/homepage/sections/PricingCard.tsx @@ -1,185 +1,155 @@ +import type { Team } from "@/api/team"; import { Badge } from "@/components/ui/badge"; -import { type AccountPlan, accountPlan } from "@3rdweb-sdk/react/hooks/useApi"; -import { Box, type CardProps, Flex } from "@chakra-ui/react"; -import { - Card, - Heading, - Text, - TrackedLinkButton, - type TrackedLinkButtonProps, -} from "tw-components"; -import { PLANS } from "utils/pricing"; +import { Button } from "@/components/ui/button"; +import { TrackedLinkTW } from "@/components/ui/tracked-link"; +import { cn } from "@/lib/utils"; +import type React from "react"; +import { TEAM_PLANS } from "utils/pricing"; +import { CheckoutButton } from "../../../@/components/billing"; import { remainingDays } from "../../../utils/date-utils"; import { FeatureItem } from "./FeatureItem"; -import { UpgradeModal } from "./UpgradeModal"; -interface PricingCardProps { - name: AccountPlan; - ctaProps: TrackedLinkButtonProps; - ctaTitle?: string; +type ButtonProps = React.ComponentProps; + +type PricingCardProps = { + team?: Team; + billingPlan: Exclude; + cta?: { + hint?: string; + title: string; + href: string; + target?: "_blank"; + tracking: { + category: string; + label?: string; + }; + variant?: ButtonProps["variant"]; + }; ctaHint?: string; - onDashboard?: boolean; - cardProps?: CardProps; highlighted?: boolean; current?: boolean; canTrialGrowth?: boolean; - size?: "sm" | "lg"; activeTrialEndsAt?: string; -} +}; export const PricingCard: React.FC = ({ - name, - ctaTitle, - ctaHint, - ctaProps, - cardProps, - onDashboard, - size = "lg", + team, + billingPlan, + cta, highlighted = false, current = false, canTrialGrowth = false, activeTrialEndsAt, }) => { - const plan = PLANS[name]; + const plan = TEAM_PLANS[billingPlan]; const isCustomPrice = typeof plan.price === "string"; const remainingTrialDays = (activeTrialEndsAt ? remainingDays(activeTrialEndsAt) : 0) || 0; - const content = ( - - - -
- +
+ {/* Title + Desc */} +
+
+

{plan.title} - +

{current && Current plan}
- +

{plan.description} - - - - - +

+
+ + {/* Price */} +
+
+ {isCustomPrice ? ( plan.price ) : canTrialGrowth ? ( <> - + ${plan.price} - {" "} + {" "} $0 ) : ( `$${plan.price}` )} - + + + {!isCustomPrice && ( + / month + )} +
- {!isCustomPrice && / month} - {remainingTrialDays > 0 && ( - +

Your free trial will{" "} {remainingTrialDays > 1 ? `end in ${remainingTrialDays} days.` : "end today."} - +

)} - - - +
+
+ +
{plan.subTitle && ( - - {plan.subTitle} - +

{plan.subTitle}

)} {plan.features.map((f) => ( ))} - - {name === accountPlan.growth && onDashboard ? ( - - ) : ( - - {ctaTitle && ( - <> - + + {cta && ( +
+ {team && billingPlan !== "pro" ? ( + + {cta.title} + + ) : ( + )} - + + {cta.hint && ( +

+ {cta.hint} +

+ )} +
)} - +
); - - if (highlighted) { - return ( -
- - {content} -
- ); - } - - return content; }; diff --git a/apps/dashboard/src/components/homepage/sections/PricingSection.tsx b/apps/dashboard/src/components/homepage/sections/PricingSection.tsx deleted file mode 100644 index 049fc996f7a..00000000000 --- a/apps/dashboard/src/components/homepage/sections/PricingSection.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { accountPlan } from "@3rdweb-sdk/react/hooks/useApi"; -import { Box, Container, Flex, SimpleGrid } from "@chakra-ui/react"; -import { Heading, Text, TrackedLink } from "tw-components"; -import { CONTACT_US_URL } from "utils/pricing"; -import { PricingCard } from "./PricingCard"; - -interface PricingSectionProps { - trackingCategory: string; - onHomepage?: boolean; - canTrialGrowth?: boolean; -} - -export const PricingSection: React.FC = ({ - trackingCategory, - onHomepage, - canTrialGrowth, -}) => { - return ( - - - - - Simple, transparent & flexible{" "} - - pricing for every team. - - - {onHomepage && ( - - Learn more about{" "} - - pricing plans - - . - - )} - - - - - - - - - - - - ); -}; diff --git a/apps/dashboard/src/components/homepage/sections/UpgradeModal.tsx b/apps/dashboard/src/components/homepage/sections/UpgradeModal.tsx deleted file mode 100644 index 07c0d74ff87..00000000000 --- a/apps/dashboard/src/components/homepage/sections/UpgradeModal.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { - type AccountPlan, - accountPlan, - useAccount, -} from "@3rdweb-sdk/react/hooks/useApi"; -import { - Flex, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - useDisclosure, -} from "@chakra-ui/react"; -import { useTrack } from "hooks/analytics/useTrack"; -import { useEffect } from "react"; -import { - Button, - Heading, - Text, - type TrackedLinkButtonProps, -} from "tw-components"; -import { PLANS } from "utils/pricing"; -import { FeatureItem } from "./FeatureItem"; - -interface UpgradeModalProps { - name: AccountPlan; - ctaTitle?: string; - ctaHint?: string; - ctaProps: TrackedLinkButtonProps; - canTrialGrowth?: boolean; -} - -export const UpgradeModal: React.FC = ({ - name, - ctaTitle, - ctaHint, - ctaProps, - canTrialGrowth, -}) => { - const { isOpen, onOpen, onClose } = useDisclosure(); - const plan = PLANS[name]; - const account = useAccount(); - const trackEvent = useTrack(); - - // We close the modal when the user is on the Growth plan successfully - // FIXME: this needs to be re-worked - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (account.data?.plan === accountPlan.growth) { - onClose(); - } - }, [account.data?.plan, onClose]); - - return ( - <> - - {ctaTitle && ( - - )} - {ctaHint && ( - - {ctaHint} - - )} - - - - - - - Are you sure you want to upgrade to the Growth plan? - - - - - - This entails unlocking exclusive features below: - - - {plan.features.map((f) => ( - - ))} - - - You will be charged $99 per month for the subscription - {canTrialGrowth && " after the 30-day trial period"}. - - - - - - - - - - - ); -}; diff --git a/apps/dashboard/src/components/onboarding/ApplyForOpCreditsForm.tsx b/apps/dashboard/src/components/onboarding/ApplyForOpCreditsForm.tsx index 672f3c39f4c..bd131b3ed10 100644 --- a/apps/dashboard/src/components/onboarding/ApplyForOpCreditsForm.tsx +++ b/apps/dashboard/src/components/onboarding/ApplyForOpCreditsForm.tsx @@ -1,4 +1,4 @@ -import { accountPlan, useAccount } from "@3rdweb-sdk/react/hooks/useApi"; +import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import { Flex, FormControl, Input, Textarea } from "@chakra-ui/react"; import { Select as ChakraSelect } from "chakra-react-select"; import { ChakraNextImage } from "components/Image"; @@ -8,6 +8,7 @@ import { useTxNotifications } from "hooks/useTxNotifications"; import { useEffect, useMemo } from "react"; import { useForm } from "react-hook-form"; import { Button, FormHelperText, FormLabel } from "tw-components"; +import type { Team } from "../../@/api/team"; import { PlanToCreditsRecord } from "./ApplyForOpCreditsModal"; interface FormSchema { @@ -26,12 +27,15 @@ interface FormSchema { interface ApplyForOpCreditsFormProps { onClose: () => void; + plan: Team["billingPlan"]; + account: Account; } export const ApplyForOpCreditsForm: React.FC = ({ onClose, + account, + plan, }) => { - const { data: account } = useAccount(); const [, setHasAppliedForOpGrant] = useLocalStorage( `appliedForOpGrant-${account?.id}`, false, @@ -41,7 +45,7 @@ export const ApplyForOpCreditsForm: React.FC = ({ firstname: "", lastname: "", thirdweb_account_id: account?.id || "", - plan_type: PlanToCreditsRecord[account?.plan || accountPlan.free].title, + plan_type: PlanToCreditsRecord[plan].title, email: account?.email || "", company: "", website: "", @@ -50,7 +54,7 @@ export const ApplyForOpCreditsForm: React.FC = ({ superchain_chain: "", what_would_you_like_to_meet_about_: "", }), - [account], + [account, plan], ); const form = useForm({ diff --git a/apps/dashboard/src/components/onboarding/ApplyForOpCreditsModal.tsx b/apps/dashboard/src/components/onboarding/ApplyForOpCreditsModal.tsx index 05bc9d5d467..55f0c5076aa 100644 --- a/apps/dashboard/src/components/onboarding/ApplyForOpCreditsModal.tsx +++ b/apps/dashboard/src/components/onboarding/ApplyForOpCreditsModal.tsx @@ -1,10 +1,5 @@ import { Badge } from "@/components/ui/badge"; -import { - type AccountPlan, - accountPlan, - accountStatus, - useAccount, -} from "@3rdweb-sdk/react/hooks/useApi"; +import { type Account, accountPlan } from "@3rdweb-sdk/react/hooks/useApi"; import { Alert, AlertDescription, @@ -12,15 +7,15 @@ import { Box, Flex, SimpleGrid, - useDisclosure, } from "@chakra-ui/react"; import { useTrack } from "hooks/analytics/useTrack"; import { useLocalStorage } from "hooks/useLocalStorage"; -import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; import { Button, Card, Heading, Text } from "tw-components"; +import type { Team } from "../../@/api/team"; +import { getValidTeamPlan } from "../../app/team/components/TeamHeader/getValidTeamPlan"; import { ApplyForOpCreditsForm } from "./ApplyForOpCreditsForm"; -import { LazyOnboardingBilling } from "./LazyOnboardingBilling"; -import { OnboardingModal } from "./Modal"; import { PlanCard } from "./PlanCard"; export type CreditsRecord = { @@ -33,14 +28,20 @@ export type CreditsRecord = { ctaHref?: string; }; -export const PlanToCreditsRecord: Record = { - [accountPlan.free]: { +export const PlanToCreditsRecord: Record = { + free: { + title: "Free", + upTo: true, + credits: "$250", + color: "#3b394b", + }, + starter: { title: "Starter", upTo: true, credits: "$250", color: "#3b394b", }, - [accountPlan.growth]: { + growth: { title: "Growth", upTo: true, credits: "$2,500", @@ -54,7 +55,7 @@ export const PlanToCreditsRecord: Record = { ctaTitle: "Upgrade for $99", ctaHref: "/team/~/~/settings/billing", }, - [accountPlan.pro]: { + pro: { title: "Pro", credits: "$3,000+", color: "#282B6F", @@ -66,22 +67,23 @@ export const PlanToCreditsRecord: Record = { ctaTitle: "Contact Us", ctaHref: "https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro", }, - [accountPlan.enterprise]: { - title: "Enterprise", - credits: "Custom", - color: "#000000", - }, }; -export const ApplyForOpCreditsModal: React.FC = () => { - const paymentMethodModalState = useDisclosure(); +export function ApplyForOpCredits(props: { + team: Team; + account: Account; +}) { + const { account, team } = props; + const validTeamPlan = getValidTeamPlan(team); + const hasValidPaymentMethod = validTeamPlan !== "free"; + const [page, setPage] = useState<"eligible" | "form">("eligible"); - const [hasAddedPaymentMethod, setHasAddedPaymentMethod] = useState(false); - const account = useAccount(); + const [hasAppliedForOpGrant] = useLocalStorage( - `appliedForOpGrant-${account?.data?.id || ""}`, + `appliedForOpGrant-${team.id}`, false, ); + const trackEvent = useTrack(); // TODO: find better way to track impressions @@ -94,18 +96,9 @@ export const ApplyForOpCreditsModal: React.FC = () => { }); }, [trackEvent]); - const hasValidPayment = useMemo(() => { - return ( - !!(account?.data?.status === accountStatus.validPayment) || - hasAddedPaymentMethod - ); - }, [account?.data?.status, hasAddedPaymentMethod]); - - const isFreePlan = account.data?.plan === accountPlan.free; - const isProPlan = account.data?.plan === accountPlan.pro; - - const creditsRecord = - PlanToCreditsRecord[account.data?.plan || accountPlan.free]; + const isStarterPlan = validTeamPlan === "starter"; + const isProPlan = validTeamPlan === "pro"; + const creditsRecord = PlanToCreditsRecord[validTeamPlan]; return ( <> @@ -136,7 +129,7 @@ export const ApplyForOpCreditsModal: React.FC = () => { - {!hasValidPayment && ( + {!hasValidPaymentMethod && ( { You need to add a payment method to be able to claim credits. This is to prevent abuse, you will not be charged.{" "} - { - paymentMethodModalState.onOpen(); - trackEvent({ - category: "op-sponsorship", - action: "add-payment-method", - label: "open", - }); - }} - color="blue.500" - cursor="pointer" + - Add a payment method - + Upgrade to Starter plan to get started + . @@ -176,7 +160,7 @@ export const ApplyForOpCreditsModal: React.FC = () => { colorScheme="primary" onClick={() => setPage("form")} w="full" - isDisabled={!hasValidPayment || hasAppliedForOpGrant} + isDisabled={!hasValidPaymentMethod || hasAppliedForOpGrant} > {hasAppliedForOpGrant ? "Already applied" : "Apply Now"} @@ -187,10 +171,10 @@ export const ApplyForOpCreditsModal: React.FC = () => { Or upgrade and get access to more credits: - {isFreePlan && ( + {isStarterPlan && ( @@ -212,30 +196,10 @@ export const ApplyForOpCreditsModal: React.FC = () => { onClose={() => { setPage("eligible"); }} + plan={validTeamPlan} + account={account} /> )} - {/* // Add Payment Method Modal */} - - { - setHasAddedPaymentMethod(true); - paymentMethodModalState.onClose(); - trackEvent({ - category: "op-sponsorship", - action: "add-payment-method", - label: "success", - }); - }} - onCancel={() => { - paymentMethodModalState.onClose(); - trackEvent({ - category: "op-sponsorship", - action: "add-payment-method", - label: "cancel", - }); - }} - /> - ); -}; +} diff --git a/apps/dashboard/src/components/onboarding/Billing.tsx b/apps/dashboard/src/components/onboarding/Billing.tsx deleted file mode 100644 index 5477e91b942..00000000000 --- a/apps/dashboard/src/components/onboarding/Billing.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { accountKeys } from "@3rdweb-sdk/react/cache-keys"; -import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi"; -import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser"; -import { Elements } from "@stripe/react-stripe-js"; -import { loadStripe } from "@stripe/stripe-js"; -import { useQueryClient } from "@tanstack/react-query"; -import { useTrack } from "hooks/analytics/useTrack"; -import { useTheme } from "next-themes"; -import { OnboardingPaymentForm } from "./PaymentForm"; -import { TitleAndDescription } from "./Title"; - -// only load stripe if the key is available -const stripePromise = process.env.NEXT_PUBLIC_STRIPE_KEY - ? loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY) - : null; - -interface OnboardingBillingProps { - onSave: () => void; - onCancel: () => void; -} - -const OnboardingBilling: React.FC = ({ - onSave, - onCancel, -}) => { - const { theme } = useTheme(); - const trackEvent = useTrack(); - const queryClient = useQueryClient(); - const { user } = useLoggedInUser(); - - const mutation = useUpdateAccount(); - - const handleCancel = () => { - trackEvent({ - category: "account", - action: "onboardSkippedBilling", - label: "attempt", - }); - - mutation.mutate( - { - onboardSkipped: true, - }, - { - onSuccess: () => { - trackEvent({ - category: "account", - action: "onboardSkippedBilling", - label: "success", - }); - }, - onError: (error) => { - trackEvent({ - category: "account", - action: "onboardSkippedBilling", - label: "error", - error, - }); - }, - }, - ); - - onCancel(); - }; - - return ( -
- -
- - { - queryClient.invalidateQueries({ - queryKey: accountKeys.me(user?.address as string), - }); - onSave(); - }} - onCancel={handleCancel} - /> - -
- ); -}; - -export default OnboardingBilling; - -const appearance = { - variables: { - fontFamily: "Inter, system-ui, sans-serif", - fontSizeBase: "15px", - colorPrimary: "rgb(51, 133, 255)", - colorDanger: "#FCA5A5", - spacingUnit: "4px", - }, - rules: { - ".Input": { - boxShadow: "none", - backgroundColor: "transparent", - }, - ".Input:hover": { - borderColor: "rgb(51, 133, 255)", - boxShadow: "none", - }, - ".Label": { - marginBottom: "12px", - fontWeight: "500", - }, - }, -}; diff --git a/apps/dashboard/src/components/onboarding/ChoosePlan.tsx b/apps/dashboard/src/components/onboarding/ChoosePlan.tsx index 65689e58ce4..6b686bc4c77 100644 --- a/apps/dashboard/src/components/onboarding/ChoosePlan.tsx +++ b/apps/dashboard/src/components/onboarding/ChoosePlan.tsx @@ -1,118 +1,58 @@ -import { - type AccountPlan, - accountPlan, - useUpdateAccountPlan, -} from "@3rdweb-sdk/react/hooks/useApi"; +import { Button } from "@/components/ui/button"; import { PricingCard } from "components/homepage/sections/PricingCard"; -import { useTrack } from "hooks/analytics/useTrack"; +import { ArrowRightIcon } from "lucide-react"; import { TitleAndDescription } from "./Title"; -interface OnboardingChoosePlanProps { - onSave: () => void; -} - -const OnboardingChoosePlan: React.FC = ({ - onSave, -}) => { - const trackEvent = useTrack(); - const mutation = useUpdateAccountPlan(); - - const handleSave = (plan: AccountPlan) => { - trackEvent({ - category: "account", - action: "choosePlan", - label: "attempt", - }); - - // free is default, so no need to update account - if (plan === accountPlan.free) { - trackEvent({ - category: "account", - action: "choosePlan", - label: "success", - data: { - plan, - }, - }); - - onSave(); - return; - } - - mutation.mutate( - { - plan, - }, - { - onSuccess: () => { - onSave(); - - trackEvent({ - category: "account", - action: "choosePlan", - label: "success", - data: { - plan, - }, - }); - }, - onError: (error) => { - trackEvent({ - category: "account", - action: "choosePlan", - label: "error", - error, - }); - }, - }, - ); - }; - +function OnboardingChoosePlan(props: { + skipPlan: () => void; + canTrialGrowth: boolean; +}) { return ( - <> +
-
+ + + +
+ +
{ - e.preventDefault(); - handleSave(accountPlan.free); + billingPlan="starter" + cta={{ + title: "Get started for free", + href: "/team/~/billing/subscribe/plan:starter", + tracking: { + category: "account", }, - label: "freePlan", - href: "/", }} - onDashboard /> { - e.preventDefault(); - handleSave(accountPlan.growth); + billingPlan="growth" + cta={{ + title: "Claim your 1-month free", + hint: "Your free trial will end after 30 days.", + tracking: { + category: "account", + label: "growthPlan", }, - href: "/", - variant: "solid", - colorScheme: "blue", + href: "/team/~/billing/subscribe/plan:growth", + variant: "default", }} - onDashboard + canTrialGrowth={props.canTrialGrowth} />
- +
); -}; +} export default OnboardingChoosePlan; diff --git a/apps/dashboard/src/components/onboarding/LazyOnboardingBilling.tsx b/apps/dashboard/src/components/onboarding/LazyOnboardingBilling.tsx deleted file mode 100644 index b5d01a8a19a..00000000000 --- a/apps/dashboard/src/components/onboarding/LazyOnboardingBilling.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import dynamic from "next/dynamic"; - -export const LazyOnboardingBilling = dynamic(() => import("./Billing"), { - loading: () => ( -
- -
- ), -}); diff --git a/apps/dashboard/src/components/onboarding/Modal.tsx b/apps/dashboard/src/components/onboarding/Modal.tsx index 9c3e2c61467..48816c59d2d 100644 --- a/apps/dashboard/src/components/onboarding/Modal.tsx +++ b/apps/dashboard/src/components/onboarding/Modal.tsx @@ -3,14 +3,14 @@ import { cn } from "@/lib/utils"; import { IconLogo } from "components/logo"; import type { ComponentWithChildren } from "types/component-with-children"; -interface OnboardingModalProps { +interface TWModalProps { isOpen: boolean; wide?: boolean; // Pass this props to make the modal closable (it will enable backdrop + the "x" icon) onOpenChange?: (open: boolean) => void; } -export const OnboardingModal: ComponentWithChildren = ({ +export const TWModal: ComponentWithChildren = ({ children, isOpen, wide, diff --git a/apps/dashboard/src/components/onboarding/PaymentForm.tsx b/apps/dashboard/src/components/onboarding/PaymentForm.tsx deleted file mode 100644 index 6fe0b8e5a9a..00000000000 --- a/apps/dashboard/src/components/onboarding/PaymentForm.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Alert, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { useCreatePaymentMethod } from "@3rdweb-sdk/react/hooks/useApi"; -import { - PaymentElement, - useElements, - useStripe, -} from "@stripe/react-stripe-js"; -import { PaymentVerificationFailureAlert } from "components/settings/Account/Billing/alerts/PaymentVerificationFailureAlert"; -import { useErrorHandler } from "contexts/error-handler"; -import { useTrack } from "hooks/analytics/useTrack"; -import { type FormEvent, useState } from "react"; - -interface OnboardingPaymentForm { - onSave: () => void; - onCancel: () => void; -} - -export const OnboardingPaymentForm: React.FC = ({ - onSave, - onCancel, -}) => { - const stripe = useStripe(); - const elements = useElements(); - const trackEvent = useTrack(); - const { onError } = useErrorHandler(); - const [paymentFailureCode, setPaymentFailureCode] = useState(""); - - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - const mutation = useCreatePaymentMethod(); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - if (!stripe || !elements) { - return; - } - - setSaving(true); - - const { error: submitError } = await elements.submit(); - if (submitError) { - setSaving(false); - if (submitError.code) { - return setPaymentFailureCode(submitError.code); - } - return onError(submitError); - } - - const { error: createError, paymentMethod } = - await stripe.createPaymentMethod({ - elements, - }); - - if (createError) { - setSaving(false); - return onError(createError); - } - - trackEvent({ - category: "account", - action: "addPaymentMethod", - label: "attempt", - }); - - mutation.mutate(paymentMethod.id, { - onSuccess: () => { - onSave(); - setPaymentFailureCode(""); - setSaving(false); - - trackEvent({ - category: "account", - action: "addPaymentMethod", - label: "success", - }); - }, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - onError: (error: any) => { - const failureCode = error?.message; - setPaymentFailureCode(failureCode || "generic_decline"); - setSaving(false); - - trackEvent({ - category: "account", - action: "addPaymentMethod", - label: "error", - error: failureCode, - }); - }, - }); - }; - - return ( -
-
- setLoading(false)} - options={{ terms: { card: "never" } }} - /> - - {loading ? ( -
- -
- ) : ( -
- {paymentFailureCode ? ( - - ) : ( - - - A temporary hold will be placed and immediately released on - your payment method. - - - )} - -
- - - -
-
- )} -
-
- ); -}; diff --git a/apps/dashboard/src/components/onboarding/index.tsx b/apps/dashboard/src/components/onboarding/index.tsx index c5b4ca86f78..2bde307f48a 100644 --- a/apps/dashboard/src/components/onboarding/index.tsx +++ b/apps/dashboard/src/components/onboarding/index.tsx @@ -13,7 +13,6 @@ import { import { useActiveWallet } from "thirdweb/react"; import { useTrack } from "../../hooks/analytics/useTrack"; import type { OnboardingState } from "./types"; -import { skipBilling } from "./utils"; const LazyOnboardingUI = lazy(() => import("./on-boarding-ui.client")); @@ -73,7 +72,7 @@ export const Onboarding: React.FC<{ setState("skipped"); } // user hasn't skipped onboarding, has valid email and no valid payment yet - else if (!skipBilling(account)) { + else if (!account.onboardSkipped) { setState("plan"); } }, [account, state, wallet]); @@ -82,11 +81,6 @@ export const Onboarding: React.FC<{ return null; } - if (state === "billing" && !process.env.NEXT_PUBLIC_STRIPE_KEY) { - // can't do billing without stripe key - return null; - } - // if we somehow get into this state, do not render anything if (state === "onboarding" && account.emailConfirmedAt) { console.error("Onboarding state is invalid, skipping rendering"); @@ -99,17 +93,6 @@ export const Onboarding: React.FC<{ return null; } - if (state === "billing" && skipBilling(account)) { - console.error("Billing state is invalid, skipping rendering"); - trackEvent({ - category: "account", - action: "onboardingStateInvalid", - label: "billing", - data: { state, skipBilling }, - }); - return null; - } - return ( import("./ConfirmEmail")); const OnboardingLinkWallet = lazy(() => import("./LinkWallet")); @@ -24,6 +23,7 @@ function OnboardingUI(props: { const trackEvent = useTrack(); const { account, state, setState, onOpenChange } = props; const [updatedEmail, setUpdatedEmail] = useState(); + const skipOnboarding = useSkipOnboarding(); const handleSave = (email?: string) => { // if account is not ready yet we cannot do anything here @@ -41,8 +41,9 @@ function OnboardingUI(props: { nextStep = "confirmLinking"; break; case "confirming": + // after confirming, only show plan if user has not skipped onboarding earlier or trial period has ended nextStep = - skipBilling(account) || account?.trialPeriodEndedAt + account.onboardSkipped || account?.trialPeriodEndedAt ? "skipped" : "plan"; break; @@ -50,9 +51,6 @@ function OnboardingUI(props: { nextStep = "skipped"; break; case "plan": - nextStep = "billing"; - break; - case "billing": nextStep = "skipped"; break; default: @@ -95,7 +93,7 @@ function OnboardingUI(props: { return ( - }> - + { + setState("skipped"); + skipOnboarding(); + }} + canTrialGrowth={!account.trialPeriodEndedAt} + /> )} - - {state === "billing" && ( - setState("skipped")} - /> - )} - + ); } diff --git a/apps/dashboard/src/components/onboarding/types.ts b/apps/dashboard/src/components/onboarding/types.ts index 3bdad470925..ca814393d62 100644 --- a/apps/dashboard/src/components/onboarding/types.ts +++ b/apps/dashboard/src/components/onboarding/types.ts @@ -4,6 +4,5 @@ export type OnboardingState = | "confirming" | "confirmLinking" | "plan" - | "billing" | "skipped" | undefined; diff --git a/apps/dashboard/src/components/onboarding/useSkipOnboarding.tsx b/apps/dashboard/src/components/onboarding/useSkipOnboarding.tsx new file mode 100644 index 00000000000..eb0928d309d --- /dev/null +++ b/apps/dashboard/src/components/onboarding/useSkipOnboarding.tsx @@ -0,0 +1,40 @@ +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; +} diff --git a/apps/dashboard/src/components/onboarding/utils.ts b/apps/dashboard/src/components/onboarding/utils.ts deleted file mode 100644 index c8ca0e2899c..00000000000 --- a/apps/dashboard/src/components/onboarding/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type Account, accountStatus } from "@3rdweb-sdk/react/hooks/useApi"; - -export const skipBilling = (account: Account) => { - return ( - account.status === accountStatus.validPayment || - account.status === accountStatus.paymentVerification || - account.onboardSkipped - ); -}; diff --git a/apps/dashboard/src/components/settings/Account/AccountForm.tsx b/apps/dashboard/src/components/settings/Account/AccountForm.tsx index 02f9b31d32f..ca826d681c7 100644 --- a/apps/dashboard/src/components/settings/Account/AccountForm.tsx +++ b/apps/dashboard/src/components/settings/Account/AccountForm.tsx @@ -3,7 +3,6 @@ import { cn } from "@/lib/utils"; import { type Account, useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi"; import { Flex, FormControl } from "@chakra-ui/react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ManageBillingButton } from "components/settings/Account/Billing/ManageButton"; import { useTrack } from "hooks/analytics/useTrack"; import { useTxNotifications } from "hooks/useTxNotifications"; import { type ChangeEvent, useState } from "react"; @@ -26,7 +25,6 @@ interface AccountFormProps { account: Account; horizontal?: boolean; previewEnabled?: boolean; - showBillingButton?: boolean; showSubscription?: boolean; hideName?: boolean; buttonProps?: ButtonProps; @@ -47,7 +45,6 @@ export const AccountForm: React.FC = ({ horizontal = false, previewEnabled = false, hideName = false, - showBillingButton = false, showSubscription = false, disableUnchanged = false, padded = true, @@ -221,11 +218,9 @@ export const AccountForm: React.FC = ({
- {showBillingButton && } - {!previewEnabled && (