diff --git a/.changeset/chilly-trams-wash.md b/.changeset/chilly-trams-wash.md new file mode 100644 index 00000000000..a1f7cf7f553 --- /dev/null +++ b/.changeset/chilly-trams-wash.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/service-utils": patch +--- + +Update `TeamResponse` type \ No newline at end of file diff --git a/apps/dashboard/.storybook/preview.tsx b/apps/dashboard/.storybook/preview.tsx index bfba8033060..9e6261d8c8a 100644 --- a/apps/dashboard/.storybook/preview.tsx +++ b/apps/dashboard/.storybook/preview.tsx @@ -7,6 +7,7 @@ import { Inter as interFont } from "next/font/google"; // biome-ignore lint/style/useImportType: import React from "react"; import { useEffect } from "react"; +import { Toaster } from "sonner"; import { Button } from "../src/@/components/ui/button"; const queryClient = new QueryClient(); @@ -16,8 +17,30 @@ const fontSans = interFont({ variable: "--font-sans", }); +const customViewports = { + xs: { + // Regular sized phones (iphone 15 / 15 pro) + name: "iPhone", + styles: { + width: "390px", + height: "844px", + }, + }, + sm: { + // Larger phones (iphone 15 plus / 15 pro max) + name: "iPhone Plus", + styles: { + width: "430px", + height: "932px", + }, + }, +}; + const preview: Preview = { parameters: { + viewport: { + viewports: customViewports, + }, controls: { matchers: { color: /(background|color)$/i, @@ -57,13 +80,13 @@ function StoryLayout(props: { return ( -
+
@@ -72,14 +95,20 @@ function StoryLayout(props: { onClick={() => setTheme("light")} size="sm" variant={theme === "light" ? "default" : "outline"} - className="h-auto w-auto rounded-full p-2" + className="h-auto w-auto shrink-0 rounded-full p-2" >
{props.children}
+
); } + +function ToasterSetup() { + const { theme } = useTheme(); + return ; +} diff --git a/apps/dashboard/src/@/actions/billing.ts b/apps/dashboard/src/@/actions/billing.ts index f6eded82195..7acc0baa66e 100644 --- a/apps/dashboard/src/@/actions/billing.ts +++ b/apps/dashboard/src/@/actions/billing.ts @@ -2,20 +2,19 @@ import "server-only"; import { API_SERVER_URL } from "@/constants/env"; -import { redirect } from "next/navigation"; import { getAuthToken } from "../../app/api/lib/getAuthToken"; import type { ProductSKU } from "../lib/billing"; -export type RedirectCheckoutOptions = { +export type GetBillingCheckoutUrlOptions = { teamSlug: string; sku: ProductSKU; redirectUrl: string; metadata?: Record; }; -export async function redirectToCheckout( - options: RedirectCheckoutOptions, -): Promise<{ status: number }> { +export async function getBillingCheckoutUrl( + options: GetBillingCheckoutUrlOptions, +): Promise<{ status: number; url?: string }> { if (!options.teamSlug) { return { status: 400, @@ -49,6 +48,7 @@ export async function redirectToCheckout( status: res.status, }; } + const json = await res.json(); if (!json.result) { return { @@ -56,20 +56,22 @@ export async function redirectToCheckout( }; } - // redirect to the stripe checkout session - redirect(json.result); + return { + status: 200, + url: json.result as string, + }; } -export type RedirectBillingCheckoutAction = typeof redirectToCheckout; +export type GetBillingCheckoutUrlAction = typeof getBillingCheckoutUrl; -export type BillingPortalOptions = { +export type GetBillingPortalUrlOptions = { teamSlug: string | undefined; redirectUrl: string; }; -export async function redirectToBillingPortal( - options: BillingPortalOptions, -): Promise<{ status: number }> { +export async function getBillingPortalUrl( + options: GetBillingPortalUrlOptions, +): Promise<{ status: number; url?: string }> { if (!options.teamSlug) { return { status: 400, @@ -110,8 +112,10 @@ export async function redirectToBillingPortal( }; } - // redirect to the stripe billing portal - redirect(json.result); + return { + status: 200, + url: json.result as string, + }; } -export type BillingBillingPortalAction = typeof redirectToBillingPortal; +export type GetBillingPortalUrlAction = typeof getBillingPortalUrl; diff --git a/apps/dashboard/src/@/components/TextDivider.tsx b/apps/dashboard/src/@/components/TextDivider.tsx deleted file mode 100644 index 7a0730f23fa..00000000000 --- a/apps/dashboard/src/@/components/TextDivider.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { cn } from "@/lib/utils"; - -export function TextDivider(props: { - text: string; - className?: string; -}) { - return ( -
- - {props.text} - -
- ); -} diff --git a/apps/dashboard/src/@/components/billing.tsx b/apps/dashboard/src/@/components/billing.tsx index de2842de5e2..8aa011b7d2a 100644 --- a/apps/dashboard/src/@/components/billing.tsx +++ b/apps/dashboard/src/@/components/billing.tsx @@ -1,78 +1,136 @@ "use client"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; import type { - BillingBillingPortalAction, - BillingPortalOptions, - RedirectBillingCheckoutAction, - RedirectCheckoutOptions, + GetBillingCheckoutUrlAction, + GetBillingCheckoutUrlOptions, + GetBillingPortalUrlAction, + GetBillingPortalUrlOptions, } from "../actions/billing"; +import { cn } from "../lib/utils"; +import { Spinner } from "./ui/Spinner/Spinner"; import { Button, type ButtonProps } from "./ui/button"; -type CheckoutButtonProps = Omit & - ButtonProps & { - redirectPath: string; - redirectToCheckout: RedirectBillingCheckoutAction; - }; +type CheckoutButtonProps = Omit & { + getBillingCheckoutUrl: GetBillingCheckoutUrlAction; + buttonProps?: Omit; + children: React.ReactNode; +}; export function CheckoutButton({ - onClick, teamSlug, sku, metadata, - redirectPath, + getBillingCheckoutUrl, children, - redirectToCheckout, - ...restProps + buttonProps, }: CheckoutButtonProps) { + const getUrlMutation = useMutation({ + mutationFn: async () => { + return getBillingCheckoutUrl({ + teamSlug, + sku, + metadata, + redirectUrl: getAbsoluteUrl("/stripe-redirect"), + }); + }, + }); + + const errorMessage = "Failed to open checkout page"; + return ( ); } -type BillingPortalButtonProps = Omit & - ButtonProps & { - redirectPath: string; - redirectToBillingPortal: BillingBillingPortalAction; - }; +type BillingPortalButtonProps = Omit< + GetBillingPortalUrlOptions, + "redirectUrl" +> & { + getBillingPortalUrl: GetBillingPortalUrlAction; + buttonProps?: Omit; + children: React.ReactNode; +}; export function BillingPortalButton({ - onClick, teamSlug, - redirectPath, children, - redirectToBillingPortal, - ...restProps + getBillingPortalUrl, + buttonProps, }: BillingPortalButtonProps) { + const getUrlMutation = useMutation({ + mutationFn: async () => { + return getBillingPortalUrl({ + teamSlug, + redirectUrl: getAbsoluteUrl("/stripe-redirect"), + }); + }, + }); + + const errorMessage = "Failed to open billing portal"; + return ( ); } -function getRedirectUrl(path: string) { +function getAbsoluteUrl(path: string) { const url = new URL(window.location.origin); url.pathname = path; return url.toString(); diff --git a/apps/dashboard/src/@/components/blocks/pricing-card.tsx b/apps/dashboard/src/@/components/blocks/pricing-card.tsx index fe84e0a2410..dcb01de9529 100644 --- a/apps/dashboard/src/@/components/blocks/pricing-card.tsx +++ b/apps/dashboard/src/@/components/blocks/pricing-card.tsx @@ -1,3 +1,4 @@ +"use client"; import type { Team } from "@/api/team"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -8,7 +9,7 @@ import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react"; import type React from "react"; import { TEAM_PLANS } from "utils/pricing"; import { remainingDays } from "../../../utils/date-utils"; -import type { RedirectBillingCheckoutAction } from "../../actions/billing"; +import type { GetBillingCheckoutUrlAction } from "../../actions/billing"; import { CheckoutButton } from "../billing"; type ButtonProps = React.ComponentProps; @@ -27,14 +28,13 @@ type PricingCardProps = { label?: string; }; variant?: ButtonProps["variant"]; + onClick?: () => void; }; ctaHint?: string; highlighted?: boolean; current?: boolean; - canTrialGrowth?: boolean; activeTrialEndsAt?: string; - redirectPath: string; - redirectToCheckout: RedirectBillingCheckoutAction; + getBillingCheckoutUrl: GetBillingCheckoutUrlAction; }; export const PricingCard: React.FC = ({ @@ -43,10 +43,8 @@ export const PricingCard: React.FC = ({ cta, highlighted = false, current = false, - canTrialGrowth = false, activeTrialEndsAt, - redirectPath, - redirectToCheckout, + getBillingCheckoutUrl, }) => { const plan = TEAM_PLANS[billingPlan]; const isCustomPrice = typeof plan.price === "string"; @@ -88,18 +86,7 @@ export const PricingCard: React.FC = ({
- {isCustomPrice ? ( - plan.price - ) : canTrialGrowth ? ( - <> - - ${plan.price} - {" "} - $0 - - ) : ( - `$${plan.price}` - )} + ${plan.price} {!isCustomPrice && ( @@ -135,7 +122,7 @@ export const PricingCard: React.FC = ({
-
+
{plan.subTitle && (

{plan.subTitle}

)} @@ -149,11 +136,14 @@ export const PricingCard: React.FC = ({
{billingPlan !== "pro" ? ( {cta.title} @@ -189,7 +179,7 @@ function FeatureItem({ text }: FeatureItemProps) { const titleStr = Array.isArray(text) ? text[0] : text; return ( -
+
{Array.isArray(text) ? (
diff --git a/apps/dashboard/src/@/components/ui/input.tsx b/apps/dashboard/src/@/components/ui/input.tsx index 736e5146c24..37529a8f2df 100644 --- a/apps/dashboard/src/@/components/ui/input.tsx +++ b/apps/dashboard/src/@/components/ui/input.tsx @@ -11,7 +11,7 @@ const Input = React.forwardRef( { - type Result = { - data: object; - error?: { message: string }; - }; +type UpdateAccountParams = { + name?: string; + email?: string; + linkWallet?: boolean; + subscribeToUpdates?: boolean; + onboardSkipped?: boolean; +}; - const res = await apiServerProxy({ - pathname: "/v1/account", - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(input), - }); +export async function updateAccountClient(input: UpdateAccountParams) { + type Result = { + data: object; + error?: { message: string }; + }; - if (!res.ok) { - throw new Error(res.error); - } + const res = await apiServerProxy({ + pathname: "/v1/account", + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); - const json = res.data; + if (!res.ok) { + throw new Error(res.error); + } - if (json.error) { - throw new Error(json.error.message); - } + const json = res.data; - return json.data; - }, + if (json.error) { + throw new Error(json.error.message); + } - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: accountKeys.me(address || ""), - }); - }, - }); + return json.data; } export function useUpdateNotifications() { @@ -221,77 +208,61 @@ export function useUpdateNotifications() { }); } -export function useConfirmEmail() { - return useMutation({ - mutationFn: async (input: ConfirmEmailInput) => { - type Result = { - error?: { message: string }; - data: { team: Team; account: Account }; - }; - - const res = await apiServerProxy({ - pathname: "/v1/account/confirmEmail", - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(input), - }); - - if (!res.ok) { - throw new Error(res.error); - } - - const json = res.data; - - if (json.error) { - throw new Error(json.error.message); - } +export const verifyEmailClient = async (input: ConfirmEmailInput) => { + type Result = { + error?: { message: string }; + data: { team: Team; account: Account }; + }; - return json.data; + const res = await apiServerProxy({ + pathname: "/v1/account/confirmEmail", + method: "PUT", + headers: { + "Content-Type": "application/json", }, + body: JSON.stringify(input), }); -} -export function useResendEmailConfirmation() { - const address = useActiveAccount()?.address; - const queryClient = useQueryClient(); + if (!res.ok) { + throw new Error(res.error); + } - return useMutation({ - mutationFn: async () => { - type Result = { - error?: { message: string }; - data: object; - }; + const json = res.data; - const res = await apiServerProxy({ - pathname: "/v1/account/resendEmailConfirmation", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }); - - if (!res.ok) { - throw new Error(res.error); - } + if (json.error) { + throw new Error(json.error.message); + } - const json = res.data; + return json.data; +}; - if (json.error) { - throw new Error(json.error.message); - } +export const resendEmailClient = async () => { + type Result = { + error?: { message: string }; + data: object; + }; - return json.data; - }, - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: accountKeys.me(address || ""), - }); + const res = await apiServerProxy({ + pathname: "/v1/account/resendEmailConfirmation", + method: "POST", + headers: { + "Content-Type": "application/json", }, + body: JSON.stringify({}), }); -} + + if (!res.ok) { + throw new Error(res.error); + } + + const json = res.data; + + if (json.error) { + throw new Error(json.error.message); + } + + return json.data; +}; export async function createProjectClient( teamId: string, diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx index c5605f59d13..c762a0fe766 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx @@ -52,7 +52,7 @@ import { useWalletBalance, } from "thirdweb/react"; import { z } from "zod"; -import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired"; +import { isAccountOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired"; function formatTime(seconds: number) { const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); @@ -210,7 +210,7 @@ export function FaucetButton({ ); } - if (!isOnboardingComplete(twAccount)) { + if (!isAccountOnboardingComplete(twAccount)) { return (
@@ -208,7 +208,7 @@ function DeleteAccountCard(props: { onAccountDeleted: () => void; defaultTeamSlug: string; defaultTeamName: string; - redirectToBillingPortal: BillingBillingPortalAction; + getBillingPortalUrl: GetBillingPortalUrlAction; cancelSubscriptions: () => Promise; }) { const title = "Delete Account"; @@ -328,11 +328,12 @@ function DeleteAccountCard(props: { account Manage Billing diff --git a/apps/dashboard/src/app/account/settings/getAccount.ts b/apps/dashboard/src/app/account/settings/getAccount.ts index fe1ea5f1612..5f1abaaf32c 100644 --- a/apps/dashboard/src/app/account/settings/getAccount.ts +++ b/apps/dashboard/src/app/account/settings/getAccount.ts @@ -2,7 +2,7 @@ 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"; +import { isAccountOnboardingComplete } from "../../login/onboarding/isOnboardingRequired"; /** * Just get the account object without enforcing onboarding. @@ -40,7 +40,7 @@ export async function getValidAccount(pagePath?: string) { const account = await getRawAccount(); // enforce login & onboarding - if (!account || !isOnboardingComplete(account)) { + if (!account || !isAccountOnboardingComplete(account)) { loginRedirect(pagePath); } diff --git a/apps/dashboard/src/app/get-started/team/[team_slug]/add-members/page.tsx b/apps/dashboard/src/app/get-started/team/[team_slug]/add-members/page.tsx new file mode 100644 index 00000000000..ecd24614f1d --- /dev/null +++ b/apps/dashboard/src/app/get-started/team/[team_slug]/add-members/page.tsx @@ -0,0 +1,21 @@ +import { getTeamBySlug } from "@/api/team"; +import { notFound } from "next/navigation"; +import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout"; +import { InviteTeamMembers } from "../../../../login/onboarding/team-onboarding/team-onboarding"; + +export default async function Page(props: { + params: Promise<{ team_slug: string }>; +}) { + const params = await props.params; + const team = await getTeamBySlug(params.team_slug); + + if (!team) { + notFound(); + } + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/get-started/team/[team_slug]/layout.tsx b/apps/dashboard/src/app/get-started/team/[team_slug]/layout.tsx new file mode 100644 index 00000000000..fc9ed44658b --- /dev/null +++ b/apps/dashboard/src/app/get-started/team/[team_slug]/layout.tsx @@ -0,0 +1,60 @@ +import { getProjects } from "@/api/projects"; +import { getTeamBySlug, getTeams } from "@/api/team"; +import { AppFooter } from "@/components/blocks/app-footer"; +import { notFound } from "next/navigation"; +import { getValidAccount } from "../../../account/settings/getAccount"; +import { + getAuthToken, + getAuthTokenWalletAddress, +} from "../../../api/lib/getAuthToken"; +import { loginRedirect } from "../../../login/loginRedirect"; +import { TeamHeaderLoggedIn } from "../../../team/components/TeamHeader/team-header-logged-in.client"; + +export default async function Layout(props: { + params: Promise<{ team_slug: string }>; + children: React.ReactNode; +}) { + const params = await props.params; + const [team, account, accountAddress, authToken, teams] = await Promise.all([ + getTeamBySlug(params.team_slug), + getValidAccount(`/team/${params.team_slug}`), + getAuthTokenWalletAddress(), + getAuthToken(), + getTeams(), + ]); + + if (!accountAddress || !account || !teams || !authToken) { + loginRedirect(`/get-started/team/${params.team_slug}`); + } + + if (!team) { + notFound(); + } + + // Note: + // Do not check that team is already onboarded or not and redirect away from /get-started pages + // because the team is marked as onboarded in the first step- instead of after completing all the steps + + const teamsAndProjects = await Promise.all( + teams.map(async (team) => ({ + team, + projects: await getProjects(team.slug), + })), + ); + + return ( +
+
+ +
+ {props.children} + +
+ ); +} diff --git a/apps/dashboard/src/app/get-started/team/[team_slug]/page.tsx b/apps/dashboard/src/app/get-started/team/[team_slug]/page.tsx new file mode 100644 index 00000000000..88d76745948 --- /dev/null +++ b/apps/dashboard/src/app/get-started/team/[team_slug]/page.tsx @@ -0,0 +1,35 @@ +import { getTeamBySlug } from "@/api/team"; +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { notFound } from "next/navigation"; +import { getAuthToken } from "../../../api/lib/getAuthToken"; +import { loginRedirect } from "../../../login/loginRedirect"; +import { TeamOnboardingLayout } from "../../../login/onboarding/onboarding-layout"; +import { TeamInfoForm } from "../../../login/onboarding/team-onboarding/team-onboarding"; + +export default async function Page(props: { + params: Promise<{ team_slug: string }>; +}) { + const params = await props.params; + const [team, authToken] = await Promise.all([ + getTeamBySlug(params.team_slug), + getAuthToken(), + ]); + + if (!authToken) { + loginRedirect(`/get-started/team/${params.team_slug}`); + } + + if (!team) { + notFound(); + } + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/login/LoginPage.tsx b/apps/dashboard/src/app/login/LoginPage.tsx index cfba0ac2f09..f7f4f2a10d6 100644 --- a/apps/dashboard/src/app/login/LoginPage.tsx +++ b/apps/dashboard/src/app/login/LoginPage.tsx @@ -1,7 +1,7 @@ "use client"; -import { redirectToCheckout } from "@/actions/billing"; import { getRawAccountAction } from "@/actions/getAccount"; +import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; import { ToggleThemeButton } from "@/components/color-mode-toggle"; import { Spinner } from "@/components/ui/Spinner/Spinner"; import { TURNSTILE_SITE_KEY } from "@/constants/env"; @@ -12,16 +12,20 @@ import { Turnstile } from "@marsidev/react-turnstile"; import { useTheme } from "next-themes"; import Link from "next/link"; import { Suspense, lazy, useEffect, useState } from "react"; -import { ConnectEmbed, useActiveWalletConnectionStatus } from "thirdweb/react"; +import { + ConnectEmbed, + useActiveAccount, + 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"; +import { isAccountOnboardingComplete } from "./onboarding/isOnboardingRequired"; -const LazyOnboardingUI = lazy( - () => import("./onboarding/on-boarding-ui.client"), +const LazyAccountOnboarding = lazy( + () => import("./onboarding/account-onboarding"), ); const loginOptions = [ @@ -115,28 +119,13 @@ export function LoginAndOnboardingPage(props: { ); } -export function LoginAndOnboardingPageContent(props: { - account: Account | undefined; - redirectPath: string; - loginWithInAppWallet: boolean; +function LoginPageContainer(props: { + children: React.ReactNode; }) { return ( -
-
- - -
- } - className="flex justify-center" - > - - + <> +
+ {props.children}
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -145,23 +134,16 @@ export function LoginAndOnboardingPageContent(props: { src="/assets/login/background.svg" className="-bottom-12 -right-12 pointer-events-none fixed lg:right-0 lg:bottom-0" /> -
- ); -} - -function LoadingCard() { - return ( -
- -
+ ); } -function PageContent(props: { +export function LoginAndOnboardingPageContent(props: { redirectPath: string; account: Account | undefined; loginWithInAppWallet: boolean; }) { + const accountAddress = useActiveAccount()?.address; const [screen, setScreen] = useState< | { id: "login" } | { @@ -189,7 +171,7 @@ function PageContent(props: { return; } - if (!isOnboardingComplete(account)) { + if (!isAccountOnboardingComplete(account)) { setScreen({ id: "onboarding", account, @@ -207,37 +189,55 @@ function PageContent(props: { } }, [connectionStatus, screen.id]); + if (screen.id === "complete") { + return ; + } + if (connectionStatus === "connecting") { - return ; + return ( + + + + ); } - if (connectionStatus !== "connected" || screen.id === "login") { + if ( + connectionStatus !== "connected" || + screen.id === "login" || + !accountAddress + ) { return ( - + + + ); } if (screen.id === "onboarding") { return ( - }> - }> + { setScreen({ id: "login" }); }} - skipShowingPlans={props.redirectPath.startsWith("/join/team")} + accountAddress={accountAddress} /> ); } - return ; + return ( + + + + ); } function CustomConnectEmbed(props: { @@ -260,41 +260,65 @@ function CustomConnectEmbed(props: { siteKey={TURNSTILE_SITE_KEY} onSuccess={(token) => setTurnstileToken(token)} /> - { - try { - const result = await doLogin(params, turnstileToken); - if (result.error) { - console.error("Failed to login", result.error, result.context); - throw new Error(result.error); + }> + { + try { + const result = await doLogin(params, turnstileToken); + if (result.error) { + console.error( + "Failed to login", + result.error, + result.context, + ); + throw new Error(result.error); + } + props.onLogin(); + } catch (e) { + console.error("Failed to login", e); + throw e; } - 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={ - props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions - } - client={client} - modalSize="wide" - theme={getSDKTheme(theme === "light" ? "light" : "dark")} - className="shadow-lg" - privacyPolicyUrl="/privacy-policy" - termsOfServiceUrl="/terms" - /> + }, + doLogout, + isLoggedIn: async (x) => { + const isLoggedInResult = await isLoggedIn(x); + if (isLoggedInResult) { + props.onLogin(); + } + return isLoggedInResult; + }, + }} + wallets={ + props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions + } + client={client} + modalSize="wide" + theme={getSDKTheme(theme === "light" ? "light" : "dark")} + className="shadow-lg" + privacyPolicyUrl="/privacy-policy" + termsOfServiceUrl="/terms" + /> +
); } + +function ConnectEmbedSizedCard(props: { + children: React.ReactNode; +}) { + return ( +
+ {props.children} +
+ ); +} + +function ConnectEmbedSizedLoadingCard() { + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/AccountForm.tsx b/apps/dashboard/src/app/login/onboarding/AccountForm.tsx deleted file mode 100644 index cd597b6821c..00000000000 --- a/apps/dashboard/src/app/login/onboarding/AccountForm.tsx +++ /dev/null @@ -1,175 +0,0 @@ -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 { toast } from "sonner"; -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 = ({ - account, - onSave, - onDuplicateError, - buttonText = "Save", - hideName = false, - showSubscription = false, - disableUnchanged = false, -}) => { - const [isSubscribing, setIsSubscribing] = useState(true); - const trackEvent = useTrack(); - const form = useForm({ - 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: (error) => { - console.error(error); - - if ( - onDuplicateError && - error?.message.match(/email address already exists/) - ) { - onDuplicateError(values.email); - return; - } else if (error.message.includes("INVALID_EMAIL_ADDRESS")) { - toast.error("Invalid Email Address"); - } else { - toast.error(error.message || "Failed to update account"); - } - - trackEvent({ - category: "account", - action: "update", - label: "error", - error, - fromOnboarding: !!onDuplicateError, - }); - }, - }); - }); - - return ( -
-
-
- - - - - {!hideName && ( - - - - )} - - {showSubscription && ( - - setIsSubscribing(!!v)} - /> - Subscribe to new features and key product updates - - )} -
- - -
-
- ); -}; diff --git a/apps/dashboard/src/app/login/onboarding/ChoosePlan.tsx b/apps/dashboard/src/app/login/onboarding/ChoosePlan.tsx deleted file mode 100644 index 291ec711ee6..00000000000 --- a/apps/dashboard/src/app/login/onboarding/ChoosePlan.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import type { RedirectBillingCheckoutAction } from "@/actions/billing"; -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; - canTrialGrowth: boolean; - teamSlug: string; - redirectPath: string; - redirectToCheckout: RedirectBillingCheckoutAction; -}) { - return ( -
- - -
- -
- - - -
- - - - -
- ); -} diff --git a/apps/dashboard/src/app/login/onboarding/ConfirmEmail.tsx b/apps/dashboard/src/app/login/onboarding/ConfirmEmail.tsx deleted file mode 100644 index df3cd9b05cb..00000000000 --- a/apps/dashboard/src/app/login/onboarding/ConfirmEmail.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"use client"; - -import type { Team } from "@/api/team"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Button } from "@/components/ui/button"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "@/components/ui/input-otp"; -import { cn } from "@/lib/utils"; -import { - type Account, - useConfirmEmail, - useResendEmailConfirmation, -} from "@3rdweb-sdk/react/hooks/useApi"; -import { zodResolver } from "@hookform/resolvers/zod"; -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 { toast } from "sonner"; -import { useActiveAccount } from "thirdweb/react"; -import { shortenString } from "utils/usedapp-external"; -import { TitleAndDescription } from "./Title"; -import { - type EmailConfirmationValidationSchema, - emailConfirmationValidationSchema, -} from "./validations"; - -interface OnboardingConfirmEmailProps { - email: string; - linking?: boolean; - onEmailConfirm: (params: { - team: Team; - account: Account; - }) => void; - onComplete: () => void; - onBack: () => void; -} - -// TODO - separate out "linking" and "confirmLinking" states into separate components - -export const OnboardingConfirmEmail: React.FC = ({ - email, - linking, - onEmailConfirm, - onBack, - onComplete, -}) => { - const [token, setToken] = useState(""); - const [completed, setCompleted] = useState(false); - const [saving, setSaving] = useState(false); - const trackEvent = useTrack(); - const address = useActiveAccount()?.address; - - const { onSuccess: onResendSuccess, onError: onResendError } = - useTxNotifications( - !linking - ? "We've sent you an email confirmation code." - : "We've sent you a wallet linking confirmation code.", - !linking - ? "Couldn't send an email confirmation code. Try later!" - : "Couldn't send a wallet linking confirmation code. Try later!", - ); - - const form = useForm({ - resolver: zodResolver(emailConfirmationValidationSchema), - defaultValues: { - confirmationToken: "", - }, - }); - - const confirmEmail = useConfirmEmail(); - const resendMutation = useResendEmailConfirmation(); - - const handleChange = (value: string) => { - setToken(value.toUpperCase()); - form.setValue("confirmationToken", value); - }; - - const handleSubmit = form.handleSubmit((values) => { - const trackingAction = !linking ? "confirmEmail" : "confirmLinkWallet"; - - setSaving(true); - - trackEvent({ - category: "account", - action: trackingAction, - label: "attempt", - }); - - confirmEmail.mutate(values, { - onSuccess: (response) => { - if (!linking) { - onEmailConfirm(response); - } else { - setCompleted(true); - } - setSaving(false); - - trackEvent({ - category: "account", - action: trackingAction, - label: "success", - }); - }, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - onError: (error: any) => { - const message = - "message" in error - ? error.message - : "Couldn't verify your email address. Try later!"; - - toast.error(message); - form.reset(); - setToken(""); - setSaving(false); - - trackEvent({ - category: "account", - action: trackingAction, - label: "error", - error: message, - }); - }, - }); - }); - - const handleResend = () => { - setSaving(true); - - trackEvent({ - category: "account", - action: "resendEmailConfirmation", - label: "attempt", - }); - - resendMutation.mutate(undefined, { - onSuccess: () => { - setSaving(false); - onResendSuccess(); - - trackEvent({ - category: "account", - action: "resendEmailConfirmation", - label: "success", - }); - }, - onError: (error) => { - onResendError(error); - form.reset(); - setToken(""); - setSaving(false); - - trackEvent({ - category: "account", - action: "resendEmailConfirmation", - label: "error", - error, - }); - }, - }); - }; - - return ( - <> - - Enter the 6 letter confirmation code sent to{" "} - {email} - - ) : ( - <> - We've linked{" "} - {address && ( - {shortenString(address)} - )} - wallet to {email} thirdweb - account. - - ) - } - /> - -
- - {completed && ( - - )} - - {!completed && ( -
- - - {new Array(6).fill(0).map((_, idx) => ( - - key={idx} - index={idx} - className={cn("h-12 grow text-lg", { - "border-red-500": form.getFieldState( - "confirmationToken", - form.formState, - ).error, - })} - /> - ))} - - - -
- -
- - - - - -
- - )} - - ); -}; diff --git a/apps/dashboard/src/app/login/onboarding/General.tsx b/apps/dashboard/src/app/login/onboarding/General.tsx deleted file mode 100644 index 9eaadf20cc5..00000000000 --- a/apps/dashboard/src/app/login/onboarding/General.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"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; - onLogout: () => void; -}; - -export const OnboardingGeneral: React.FC = ({ - account, - onSave, - onDuplicate, - onLogout, -}) => { - const [existing, setExisting] = useState(false); - const activeWallet = useActiveWallet(); - const { disconnect } = useDisconnect(); - - async function handleLogout() { - await doLogout(); - onLogout(); - if (activeWallet) { - disconnect(activeWallet); - } - } - - const logoutMutation = useMutation({ - mutationFn: handleLogout, - }); - - return ( -
- - -
- -
- - - {!existing ? ( - <> - - - - ) : ( - - )} -
-
- ); -}; diff --git a/apps/dashboard/src/app/login/onboarding/LinkWallet.tsx b/apps/dashboard/src/app/login/onboarding/LinkWallet.tsx deleted file mode 100644 index b6557ca3cc2..00000000000 --- a/apps/dashboard/src/app/login/onboarding/LinkWallet.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"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 { useTrack } from "hooks/analytics/useTrack"; -import { useActiveAccount } from "thirdweb/react"; -import { shortenString } from "utils/usedapp-external"; -import { TitleAndDescription } from "./Title"; - -interface OnboardingLinkWalletProps { - email: string; - onSave: () => void; - onBack: () => void; -} - -export const OnboardingLinkWallet: React.FC = ({ - email, - onSave, - onBack, -}) => { - const address = useActiveAccount()?.address; - const trackEvent = useTrack(); - const updateMutation = useUpdateAccount(); - - const handleSubmit = () => { - trackEvent({ - category: "account", - action: "linkWallet", - label: "attempt", - data: { - email, - }, - }); - - updateMutation.mutate( - { - email, - linkWallet: true, - }, - { - onSuccess: (data) => { - if (onSave) { - onSave(); - } - - trackEvent({ - category: "account", - action: "linkWallet", - label: "success", - data, - }); - }, - onError: (err) => { - const error = err as Error; - - trackEvent({ - category: "account", - action: "linkWallet", - label: "error", - error, - }); - }, - }, - ); - }; - - return ( - <> - - We've noticed that there is another account associated with{" "} - {email}. Would you like to - link your wallet{" "} - {address && ( - - {shortenString(address)} - - )}{" "} - to the existing account? -
- Once you agree, we will email you the details.{" "} - - Learn more about wallet linking - - . - - } - /> - -
- -
-
- - -
-
- - ); -}; diff --git a/apps/dashboard/src/app/login/onboarding/LinkWalletPrompt/LinkWalletPrompt.stories.tsx b/apps/dashboard/src/app/login/onboarding/LinkWalletPrompt/LinkWalletPrompt.stories.tsx new file mode 100644 index 00000000000..5ef0d604942 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/LinkWalletPrompt/LinkWalletPrompt.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { storybookLog } from "../../../../stories/utils"; +import { AccountOnboardingLayout } from "../onboarding-layout"; +import { LinkWalletPrompt } from "./LinkWalletPrompt"; + +const meta = { + title: "Onboarding/AccountOnboarding/LinkWalletPrompt", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SendSuccess: Story = { + args: { + type: "success", + }, +}; + +export const SendError: Story = { + args: { + type: "error", + }, +}; + +function Story(props: { + type: "success" | "error"; +}) { + return ( + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + storybookLog("logout"); + }} + > + { + storybookLog("onLinkWalletRequestSent"); + }} + email="user@example.com" + requestLinkWallet={async (email) => { + storybookLog("requestLinkWallet", email); + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (props.type === "error") { + throw new Error("Example error"); + } + }} + onBack={() => { + storybookLog("onBack"); + }} + trackEvent={(params) => { + storybookLog("trackEvent", params); + }} + accountAddress="0x1234567890123456789012345678901234567890" + /> + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/LinkWalletPrompt/LinkWalletPrompt.tsx b/apps/dashboard/src/app/login/onboarding/LinkWalletPrompt/LinkWalletPrompt.tsx new file mode 100644 index 00000000000..9a8d3133737 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/LinkWalletPrompt/LinkWalletPrompt.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { TrackedLinkTW } from "@/components/ui/tracked-link"; +import { useMutation } from "@tanstack/react-query"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { toast } from "sonner"; +import { shortenString } from "utils/usedapp-external"; +import type { TrackingParams } from "../../../../hooks/analytics/useTrack"; + +export function LinkWalletPrompt(props: { + email: string; + accountAddress: string; + onBack: () => void; + requestLinkWallet: (email: string) => Promise; + trackEvent: (params: TrackingParams) => void; + onLinkWalletRequestSent: () => void; +}) { + const requestLinkWallet = useMutation({ + mutationFn: props.requestLinkWallet, + }); + + function handleLinkWalletRequest() { + props.trackEvent({ + category: "account", + action: "linkWallet", + label: "attempt", + data: { + email: props.email, + }, + }); + + requestLinkWallet.mutate(props.email, { + onSuccess: (data) => { + props.onLinkWalletRequestSent(); + props.trackEvent({ + category: "account", + action: "linkWallet", + label: "success", + data, + }); + }, + onError: (err) => { + const error = err as Error; + console.error(error); + toast.error("Failed to send link wallet request"); + props.trackEvent({ + category: "account", + action: "linkWallet", + label: "error", + error, + }); + }, + }); + } + + return ( +
+

+ Link your wallet +

+ +
+
+

+ An account with{" "} + {props.email} already + exists, but your wallet is not linked to that account. +

+ +

+ You can link your wallet with this account to access it.
{" "} + Multiple wallets can be linked to the same account.{" "} + + Learn more about wallet linking + +

+
+ +

+ Would you like to link your wallet{" "} + + ({shortenString(props.accountAddress)}) + {" "} + with this account? +

+
+ +
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/login/onboarding/LoginOrSignup/LoginOrSignup.stories.tsx b/apps/dashboard/src/app/login/onboarding/LoginOrSignup/LoginOrSignup.stories.tsx new file mode 100644 index 00000000000..b011ec96966 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/LoginOrSignup/LoginOrSignup.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { storybookLog } from "../../../../stories/utils"; +import { AccountOnboardingLayout } from "../onboarding-layout"; +import { LoginOrSignup } from "./LoginOrSignup"; + +const meta = { + title: "Onboarding/AccountOnboarding/LoginOrSignup", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Success: Story = { + args: { + type: "success", + }, +}; + +export const EmailExists: Story = { + args: { + type: "email-exists", + }, +}; + +export const OtherError: Story = { + args: { + type: "error", + }, +}; + +function Story(props: { + type: "success" | "error" | "email-exists"; +}) { + return ( + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + storybookLog("logout"); + }} + > + { + storybookLog("onRequestSent", params); + }} + loginOrSignup={async (data) => { + storybookLog("loginOrSignup", data); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (props.type === "error") { + throw new Error("Error Example"); + } + + if (props.type === "email-exists") { + throw new Error("email address already exists"); + } + }} + trackEvent={(params) => { + storybookLog("trackEvent", params); + }} + /> + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/LoginOrSignup/LoginOrSignup.tsx b/apps/dashboard/src/app/login/onboarding/LoginOrSignup/LoginOrSignup.tsx new file mode 100644 index 00000000000..56f377af1f6 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/LoginOrSignup/LoginOrSignup.tsx @@ -0,0 +1,276 @@ +"use client"; + +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 { TabButtons } from "@/components/ui/tabs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { ArrowRightIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import type { TrackingParams } from "../../../../hooks/analytics/useTrack"; +import { + type AccountValidationSchema, + accountValidationSchema, + emailSchema, +} from "../validations"; + +export function LoginOrSignup(props: { + onRequestSent: (options: { + email: string; + isExistingEmail: boolean; + }) => void; + loginOrSignup: (input: { + email: string; + subscribeToUpdates?: true; + name?: string; + }) => Promise; + trackEvent: (params: TrackingParams) => void; +}) { + const [tab, setTab] = useState<"signup" | "login">("signup"); + const loginOrSignup = useMutation({ + mutationFn: props.loginOrSignup, + }); + + function handleSubmit(values: { + email: string; + subscribeToUpdates?: true; + name?: string; + }) { + loginOrSignup.mutate(values, { + onSuccess: (data) => { + props.onRequestSent({ + email: values.email, + isExistingEmail: false, + }); + props.trackEvent({ + category: "onboarding", + action: "update", + label: "success", + data, + }); + }, + onError: (error) => { + if (error?.message.match(/email address already exists/)) { + props.onRequestSent({ + email: values.email, + isExistingEmail: true, + }); + return; + } else if (error.message.includes("INVALID_EMAIL_ADDRESS")) { + toast.error("Invalid Email Address"); + } else { + toast.error("Failed to send confirmation email"); + } + + console.error(error); + props.trackEvent({ + category: "account", + action: "update", + label: "error", + error: error.message, + fromOnboarding: true, + }); + }, + }); + } + + return ( +
+ setTab("signup"), + isActive: tab === "signup", + isEnabled: true, + }, + { + name: "I already have an account", + onClick: () => setTab("login"), + isActive: tab === "login", + isEnabled: true, + }, + ]} + /> + + {tab === "signup" && ( + + )} + + {tab === "login" && ( + + )} +
+ ); +} + +function SignupForm(props: { + onSubmit: (values: { + name: string; + email: string; + subscribeToUpdates?: true; + }) => void; + isSubmitting: boolean; +}) { + const [subscribeToUpdates, setSubscribeToUpdates] = useState(true); + const form = useForm({ + resolver: zodResolver(accountValidationSchema), + values: { + name: "", + email: "", + }, + }); + + const handleSubmit = form.handleSubmit((values) => { + props.onSubmit({ + ...values, + ...(subscribeToUpdates + ? { + subscribeToUpdates: subscribeToUpdates, + } + : {}), + }); + }); + + return ( +
+
+ + + + + + + + + + setSubscribeToUpdates(!!v)} + /> + Subscribe to new features and key product updates + +
+ +
+ +
+
+ ); +} + +const loginFormSchema = z.object({ + email: emailSchema, +}); + +type LoginFormSchema = z.infer; + +function LoginForm(props: { + onSubmit: (values: { + email: string; + }) => void; + isSubmitting: boolean; +}) { + const form = useForm({ + resolver: zodResolver(loginFormSchema), + values: { + email: "", + }, + }); + + const handleSubmit = form.handleSubmit((values) => { + props.onSubmit(values); + }); + + return ( +
+
+ + + +
+ +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/login/onboarding/Title.tsx b/apps/dashboard/src/app/login/onboarding/Title.tsx deleted file mode 100644 index ada9d091722..00000000000 --- a/apps/dashboard/src/app/login/onboarding/Title.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { JSX } from "react"; - -type TitleAndDescriptionProps = { - heading: string | JSX.Element; - description: string | JSX.Element; -}; - -export const TitleAndDescription: React.FC = ({ - heading, - description, -}) => { - return ( -
-

- {heading} -

- - {description && ( -
{description}
- )} -
- ); -}; diff --git a/apps/dashboard/src/app/login/onboarding/VerifyEmail/VerifyEmail.stories.tsx b/apps/dashboard/src/app/login/onboarding/VerifyEmail/VerifyEmail.stories.tsx new file mode 100644 index 00000000000..fd00b7dd9a4 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/VerifyEmail/VerifyEmail.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { newAccountStub, teamStub } from "../../../../stories/stubs"; +import { storybookLog } from "../../../../stories/utils"; +import { AccountOnboardingLayout } from "../onboarding-layout"; +import { VerifyEmail } from "./VerifyEmail"; + +const meta = { + title: "Onboarding/AccountOnboarding/VerifyEmail", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const VerifyEmailSuccess: Story = { + args: { + verifyEmailType: "success", + resendConfirmationEmailType: "success", + }, +}; + +export const VerifyEmailError: Story = { + args: { + verifyEmailType: "error", + resendConfirmationEmailType: "success", + }, +}; + +export const ResendCodeError: Story = { + args: { + verifyEmailType: "success", + resendConfirmationEmailType: "error", + }, +}; + +function Story(props: { + verifyEmailType: "success" | "error"; + resendConfirmationEmailType: "success" | "error"; +}) { + return ( + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + storybookLog("logout"); + }} + > + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (props.verifyEmailType === "error") { + throw new Error("Example error"); + } + return { + team: teamStub("foo", "free"), + account: newAccountStub(), + }; + }} + resendConfirmationEmail={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (props.resendConfirmationEmailType === "error") { + throw new Error("Example error"); + } + }} + email="user@example.com" + onEmailConfirmed={(params) => { + storybookLog("onEmailConfirmed", params); + }} + onBack={() => { + storybookLog("onBack"); + }} + trackEvent={(params) => { + storybookLog("trackEvent", params); + }} + /> + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/VerifyEmail/VerifyEmail.tsx b/apps/dashboard/src/app/login/onboarding/VerifyEmail/VerifyEmail.tsx new file mode 100644 index 00000000000..8ed64807bb5 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/VerifyEmail/VerifyEmail.tsx @@ -0,0 +1,225 @@ +"use client"; + +import type { Team } from "@/api/team"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +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 type { TrackingParams } from "hooks/analytics/useTrack"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { + type EmailConfirmationValidationSchema, + emailConfirmationValidationSchema, +} from "../validations"; + +type VerifyEmailProps = { + email: string; + onEmailConfirmed: (params: { + team: Team; + account: Account; + }) => void; + onBack: () => void; + verifyEmail: (params: { + confirmationToken: string; + }) => Promise<{ + team: Team; + account: Account; + }>; + resendConfirmationEmail: () => Promise; + trackEvent: (params: TrackingParams) => void; + accountAddress: string; + title: string; + trackingAction: string; +}; + +export function VerifyEmail(props: VerifyEmailProps) { + const form = useForm({ + resolver: zodResolver(emailConfirmationValidationSchema), + values: { + confirmationToken: "", + }, + }); + + const verifyEmail = useMutation({ + mutationFn: props.verifyEmail, + }); + + const resendConfirmationEmail = useMutation({ + mutationFn: props.resendConfirmationEmail, + }); + + const handleSubmit = form.handleSubmit((values) => { + props.trackEvent({ + category: "account", + action: props.trackingAction, + label: "attempt", + }); + + verifyEmail.mutate(values, { + onSuccess: (response) => { + props.onEmailConfirmed(response); + props.trackEvent({ + category: "account", + action: props.trackingAction, + label: "success", + }); + }, + onError: (error) => { + console.error(error); + toast.error("Invalid confirmation code"); + props.trackEvent({ + category: "account", + action: props.trackingAction, + label: "error", + error: error.message, + }); + }, + }); + }); + + function handleResend() { + form.setValue("confirmationToken", ""); + verifyEmail.reset(); + + props.trackEvent({ + category: "account", + action: "resendEmailConfirmation", + label: "attempt", + }); + + resendConfirmationEmail.mutate(undefined, { + onSuccess: () => { + toast.success("Verification code sent"); + props.trackEvent({ + category: "account", + action: "resendEmailConfirmation", + label: "success", + }); + }, + onError: (error) => { + toast.error("Failed to send verification code"); + props.trackEvent({ + category: "account", + action: "resendEmailConfirmation", + label: "error", + error, + }); + }, + }); + } + + return ( +
+
+
+

+ {props.title} +

+

+ Enter the 6 letter confirmation code sent to{" "} + {props.email} +

+ +
+ { + form.setValue("confirmationToken", otp); + }} + disabled={verifyEmail.isPending} + > + + {new Array(6).fill(0).map((_, idx) => ( + + key={idx} + index={idx} + className={cn("h-12 grow border-foreground/25 text-lg", { + "border-red-500": + form.getFieldState("confirmationToken", form.formState) + .error || verifyEmail.isError, + })} + /> + ))} + + +
+
+ +
+ + +
+ + + +
+
+
+
+ ); +} + +export function LinkWalletVerifyEmail( + props: Omit, +) { + return ( + + ); +} + +export function SignupVerifyEmail( + props: Omit, +) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/account-onboarding-ui.tsx b/apps/dashboard/src/app/login/onboarding/account-onboarding-ui.tsx new file mode 100644 index 00000000000..57e3f9c5ff8 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/account-onboarding-ui.tsx @@ -0,0 +1,133 @@ +"use client"; + +import type { Team } from "@/api/team"; +import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; +import { useState } from "react"; +import type { TrackingParams } from "../../../hooks/analytics/useTrack"; +import { LinkWalletPrompt } from "./LinkWalletPrompt/LinkWalletPrompt"; +import { LoginOrSignup } from "./LoginOrSignup/LoginOrSignup"; +import { + LinkWalletVerifyEmail, + SignupVerifyEmail, +} from "./VerifyEmail/VerifyEmail"; +import { AccountOnboardingLayout } from "./onboarding-layout"; + +type AccountOnboardingScreen = + | { id: "login-or-signup" } + | { id: "link-wallet"; email: string; backScreen: AccountOnboardingScreen } + | { + id: "signup-verify-email"; + email: string; + backScreen: AccountOnboardingScreen; + } + | { + id: "link-wallet-verify-email"; + email: string; + backScreen: AccountOnboardingScreen; + }; + +type AccountOnboardingProps = { + onComplete: (param: { + team: Team; + account: Account; + }) => void; + accountAddress: string; + trackEvent: (params: TrackingParams) => void; + verifyEmail: (params: { + confirmationToken: string; + }) => Promise<{ + team: Team; + account: Account; + }>; + resendEmailConfirmation: () => Promise; + loginOrSignup: (input: { + email: string; + subscribeToUpdates?: true; + name?: string; + }) => Promise; + requestLinkWallet: (email: string) => Promise; + logout: () => Promise; +}; + +export function AccountOnboardingUI(props: AccountOnboardingProps) { + const [screen, setScreen] = useState({ + id: "login-or-signup", + }); + + return ( + + {screen.id === "login-or-signup" && ( + { + if (params.isExistingEmail) { + setScreen({ + id: "link-wallet", + email: params.email, + backScreen: screen, + }); + } else { + setScreen({ + id: "signup-verify-email", + email: params.email, + backScreen: screen, + }); + } + }} + /> + )} + + {screen.id === "link-wallet" && ( + { + setScreen({ + id: "link-wallet-verify-email", + email: screen.email, + backScreen: screen, + }); + }} + onBack={() => setScreen(screen.backScreen)} + email={screen.email} + /> + )} + + {screen.id === "signup-verify-email" && ( + { + props.onComplete({ + team: data.team, + account: data.account, + }); + }} + onBack={() => setScreen(screen.backScreen)} + email={screen.email} + /> + )} + + {screen.id === "link-wallet-verify-email" && ( + setScreen(screen.backScreen)} + email={screen.email} + /> + )} + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/account-onboarding.stories.tsx b/apps/dashboard/src/app/login/onboarding/account-onboarding.stories.tsx new file mode 100644 index 00000000000..68eff218a56 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/account-onboarding.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { newAccountStub, teamStub } from "../../../stories/stubs"; +import { storybookLog } from "../../../stories/utils"; +import { AccountOnboardingUI } from "./account-onboarding-ui"; + +const meta = { + title: "Onboarding/AccountOnboarding/Flow", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NewEmail: Story = { + args: { + loginOrSignupType: "success", + requestLinkWalletType: "success", + verifyEmailType: "success", + }, +}; + +export const EmailExists: Story = { + args: { + loginOrSignupType: "error-email-exists", + requestLinkWalletType: "success", + verifyEmailType: "success", + }, +}; + +function Story(props: { + loginOrSignupType: "success" | "error-email-exists" | "error-generic"; + requestLinkWalletType: "success" | "error"; + verifyEmailType: "success" | "error"; +}) { + return ( + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + storybookLog("logout"); + }} + onComplete={() => { + storybookLog("onComplete"); + }} + accountAddress="" + trackEvent={(params) => { + storybookLog("trackEvent", params); + }} + loginOrSignup={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (props.loginOrSignupType === "error-email-exists") { + throw new Error("email address already exists"); + } + + if (props.loginOrSignupType === "error-generic") { + throw new Error("generic error"); + } + }} + requestLinkWallet={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (props.requestLinkWalletType === "error") { + throw new Error("generic error"); + } + }} + verifyEmail={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (props.verifyEmailType === "error") { + throw new Error("generic error"); + } + + return { + team: teamStub("foo", "free"), + account: newAccountStub(), + }; + }} + resendEmailConfirmation={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }} + /> + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/account-onboarding.tsx b/apps/dashboard/src/app/login/onboarding/account-onboarding.tsx new file mode 100644 index 00000000000..3661daeecea --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/account-onboarding.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { + resendEmailClient, + updateAccountClient, + verifyEmailClient, +} from "@3rdweb-sdk/react/hooks/useApi"; +import { useActiveWallet } from "thirdweb/react"; +import { useDisconnect } from "thirdweb/react"; +import { useTrack } from "../../../hooks/analytics/useTrack"; +import { doLogout } from "../auth-actions"; +import { AccountOnboardingUI } from "./account-onboarding-ui"; + +function AccountOnboarding(props: { + onComplete: () => void; + onLogout: () => void; + accountAddress: string; +}) { + const trackEvent = useTrack(); + const activeWallet = useActiveWallet(); + const { disconnect } = useDisconnect(); + return ( + { + if (activeWallet) { + disconnect(activeWallet); + } + await doLogout(); + props.onLogout(); + }} + accountAddress={props.accountAddress} + loginOrSignup={async (params) => { + await updateAccountClient(params); + }} + verifyEmail={verifyEmailClient} + resendEmailConfirmation={async () => { + await resendEmailClient(); + }} + trackEvent={trackEvent} + requestLinkWallet={async (email) => { + await updateAccountClient({ + email, + linkWallet: true, + }); + }} + /> + ); +} + +export default AccountOnboarding; diff --git a/apps/dashboard/src/app/login/onboarding/isOnboardingRequired.ts b/apps/dashboard/src/app/login/onboarding/isOnboardingRequired.ts index 7645a55cd70..03bcbcf6c1a 100644 --- a/apps/dashboard/src/app/login/onboarding/isOnboardingRequired.ts +++ b/apps/dashboard/src/app/login/onboarding/isOnboardingRequired.ts @@ -1,6 +1,11 @@ +import type { Team } from "@/api/team"; import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; -export function isOnboardingComplete(account: Account) { +export function isAccountOnboardingComplete(account: Account) { // if email is confirmed, onboarding is considered complete return !!account.emailConfirmedAt; } + +export function isTeamOnboardingComplete(team: Team) { + return team.isOnboarded; +} diff --git a/apps/dashboard/src/app/login/onboarding/on-boarding-ui.client.tsx b/apps/dashboard/src/app/login/onboarding/on-boarding-ui.client.tsx deleted file mode 100644 index 54b72f7b2bd..00000000000 --- a/apps/dashboard/src/app/login/onboarding/on-boarding-ui.client.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; -import type { RedirectBillingCheckoutAction } from "@/actions/billing"; -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; - onLogout: () => void; - // path to redirect from stripe - redirectPath: string; - redirectToCheckout: RedirectBillingCheckoutAction; - skipShowingPlans: boolean; -}) { - const { account } = props; - const [screen, setScreen] = useState({ id: "onboarding" }); - - const trackEvent = useTrack(); - const [updatedEmail, setUpdatedEmail] = useState(); - 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 ( -
- {screen.id === "onboarding" && ( - { - setUpdatedEmail(email); - setScreen({ - id: "confirming", - }); - trackOnboardingStep({ - nextStep: "confirming", - email, - }); - }} - onDuplicate={(email) => { - setUpdatedEmail(email); - handleDuplicateEmail(email); - }} - /> - )} - - {screen.id === "linking" && ( - { - 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") && ( - { - if (screen.id === "confirmLinking") { - props.onComplete(); - } else if (screen.id === "confirming") { - if (account.onboardSkipped) { - props.onComplete(); - } else { - if (props.skipShowingPlans) { - props.onComplete(); - skipOnboarding(); - } else { - setScreen({ id: "plan", team: res.team }); - } - } - } - }} - onBack={() => - setScreen({ - id: "onboarding", - }) - } - email={(account.unconfirmedEmail || updatedEmail) as string} - /> - )} - - {screen.id === "plan" && ( - { - props.onComplete(); - skipOnboarding(); - }} - canTrialGrowth={true} - redirectToCheckout={props.redirectToCheckout} - /> - )} -
- ); -} - -export default OnboardingUI; diff --git a/apps/dashboard/src/app/login/onboarding/onboarding-layout.tsx b/apps/dashboard/src/app/login/onboarding/onboarding-layout.tsx new file mode 100644 index 00000000000..f530c772447 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/onboarding-layout.tsx @@ -0,0 +1,163 @@ +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { + BoxIcon, + LogOutIcon, + MailIcon, + UserIcon, + UsersIcon, +} from "lucide-react"; + +type OnboardingStep = { + icon: React.FC<{ className?: string }>; + title: string; + description: string; + number: number; +}; + +const accountOnboardingSteps: OnboardingStep[] = [ + { + icon: UserIcon, + title: "Account Details", + description: "Provide email address", + number: 1, + }, + { + icon: MailIcon, + title: "Verify Email", + description: "Enter your verification code", + number: 2, + }, +]; + +export function AccountOnboardingLayout(props: { + children: React.ReactNode; + currentStep: 1 | 2; + logout: () => Promise; +}) { + const logout = useMutation({ + mutationFn: props.logout, + }); + + return ( + { + logout.mutate(); + }} + > + {logout.isPending ? ( + + ) : ( + + )} + Logout + + } + > + {props.children} + + ); +} + +const teamOnboardingSteps: OnboardingStep[] = [ + { + icon: BoxIcon, + title: "Team Details", + description: "Provide team details", + number: 1, + }, + { + icon: UsersIcon, + title: "Team Members", + description: "Invite members to your team", + number: 2, + }, +]; + +export function TeamOnboardingLayout(props: { + children: React.ReactNode; + currentStep: 1 | 2; +}) { + return ( + + {props.children} + + ); +} + +function OnboardingLayout(props: { + steps: OnboardingStep[]; + currentStep: number; + children: React.ReactNode; + title: string; + cta?: React.ReactNode; +}) { + return ( +
+
+
+

+ {props.title} +

+ {props.cta} +
+
+
+ {/* Left */} +
+ {props.children} +
+ + {/* Right */} +
+ {/* Steps */} +
+ {/* Timeline line */} +
+ + {props.steps.map((step) => { + const isInactive = step.number !== props.currentStep; + return ( +
+
+ +
+
+

{step.title}

+

+ {step.description} +

+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx new file mode 100644 index 00000000000..c74df9307da --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { teamStub } from "../../../../stories/stubs"; +import { storybookLog } from "../../../../stories/utils"; +import { TeamOnboardingLayout } from "../onboarding-layout"; +import { InviteTeamMembersUI } from "./InviteTeamMembers"; + +const meta = { + title: "Onboarding/TeamOnboarding/InviteTeamMembers", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FreePlan: Story = { + args: { + plan: "free", + }, +}; + +// This is the case when user returns back to team onboarding flow from stripe +export const StarterPlan: Story = { + args: { + plan: "starter", + }, +}; + +export const GrowthPlan: Story = { + args: { + plan: "growth", + }, +}; + +function Story(props: { + plan: "free" | "growth" | "starter"; +}) { + return ( + + { + storybookLog("trackEvent", params); + }} + getTeam={async () => { + return teamStub("foo", props.plan); + }} + team={teamStub("foo", props.plan)} + inviteTeamMembers={async (params) => { + return { results: params.map(() => "fulfilled") }; + }} + getBillingCheckoutUrl={async () => { + return { status: 200 }; + }} + onComplete={() => { + storybookLog("onComplete"); + }} + /> + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.tsx new file mode 100644 index 00000000000..fdde54c80a9 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.tsx @@ -0,0 +1,246 @@ +"use client"; + +import type { GetBillingCheckoutUrlAction } from "@/actions/billing"; +import type { Team } from "@/api/team"; +import { PricingCard } from "@/components/blocks/pricing-card"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { TabButtons } from "@/components/ui/tabs"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { ArrowRightIcon, CircleArrowUpIcon } from "lucide-react"; +import { useState, useTransition } from "react"; +import type { TrackingParams } from "../../../../hooks/analytics/useTrack"; +import { pollWithTimeout } from "../../../../utils/pollWithTimeout"; +import { useStripeRedirectEvent } from "../../../stripe-redirect/stripeRedirectChannel"; +import { + InviteSection, + type InviteTeamMembersFn, +} from "../../../team/[team_slug]/(team)/~/settings/members/InviteSection"; + +export function InviteTeamMembersUI(props: { + team: Team; + getBillingCheckoutUrl: GetBillingCheckoutUrlAction; + inviteTeamMembers: InviteTeamMembersFn; + onComplete: () => void; + getTeam: () => Promise; + trackEvent: (params: TrackingParams) => void; +}) { + const [showPlanModal, setShowPlanModal] = useState(false); + const [isPending, startTransition] = useTransition(); + const router = useDashboardRouter(); + const [isPollingTeam, setIsPollingTeam] = useState(false); + const [hasSentInvites, setHasSentInvites] = useState(false); + + const showSpinner = isPollingTeam || isPending; + + useStripeRedirectEvent(async () => { + setShowPlanModal(false); + setIsPollingTeam(true); + + // poll until the team has a non-free billing plan with a timeout of 5 seconds + await pollWithTimeout({ + shouldStop: async () => { + const team = await props.getTeam(); + const isNonFreePlan = team.billingPlan !== "free"; + + if (isNonFreePlan) { + props.trackEvent({ + category: "teamOnboarding", + action: "upgradePlan", + label: "success", + plan: team.billingPlan, + }); + } + + return isNonFreePlan; + }, + timeoutMs: 5000, + }); + setIsPollingTeam(false); + + // refresh the page to get the latest team data + startTransition(() => { + router.refresh(); + }); + }); + + return ( +
+ + + + + + + setHasSentInvites(true)} + shouldHideInviteButton={hasSentInvites} + customCTASection={ +
+ {props.team.billingPlan === "free" && ( + + )} + + +
+ } + /> + + {showSpinner && ( +
+ {" "} +
+ )} +
+ ); +} + +function InviteModalContent(props: { + teamSlug: string; + getBillingCheckoutUrl: GetBillingCheckoutUrlAction; + trackEvent: (params: TrackingParams) => void; +}) { + const [planToShow, setPlanToShow] = useState<"starter" | "growth">("starter"); + + const starterPlan = ( + + ); + + const growthPlan = ( + + ); + + return ( +
+ + Choose a plan + + + Get started with the free Starter plan or upgrade to Growth plan for + increased limits and advanced features.{" "} + + Learn more about pricing + + + + +
+ + {/* Desktop */} +
+ {starterPlan} + {growthPlan} +
+ + {/* Mobile */} +
+ setPlanToShow("starter"), + isActive: planToShow === "starter", + isEnabled: true, + }, + { + name: "Growth", + onClick: () => setPlanToShow("growth"), + isActive: planToShow === "growth", + isEnabled: true, + }, + ]} + /> +
+ {planToShow === "starter" && starterPlan} + {planToShow === "growth" && growthPlan} +
+
+ ); +} diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx new file mode 100644 index 00000000000..42e340f2728 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx @@ -0,0 +1,65 @@ +import { Toaster } from "@/components/ui/sonner"; +import type { Meta, StoryObj } from "@storybook/react"; +import { teamStub } from "../../../../stories/stubs"; +import { storybookLog } from "../../../../stories/utils"; +import { TeamOnboardingLayout } from "../onboarding-layout"; +import { TeamInfoFormUI } from "./TeamInfoForm"; + +const meta = { + title: "Onboarding/TeamOnboarding/TeamInfo", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SendSuccess: Story = { + args: { + sendType: "success", + }, +}; + +export const SendError: Story = { + args: { + sendType: "error", + }, +}; + +function Story(props: { + sendType: "success" | "error"; +}) { + return ( + + { + storybookLog("onComplete"); + }} + teamSlug="foo" + isTeamSlugAvailable={async (slug) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + // taken slugs stub + if (slug === "xyz" || slug === "abc" || slug === "xyz-1") { + return false; + } + + return true; + }} + updateTeam={async (formData) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + storybookLog("updateTeam", formData); + if (props.sendType === "error") { + throw new Error("Test Error"); + } + + return teamStub("foo", "free"); + }} + /> + + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.tsx new file mode 100644 index 00000000000..4ef1e588552 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.tsx @@ -0,0 +1,274 @@ +import type { Team } from "@/api/team"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { FileInput } from "components/shared/FileInput"; +import { ArrowRightIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useDebounce } from "use-debounce"; +import { z } from "zod"; +import { teamSlugRegex } from "../../../team/[team_slug]/(team)/~/settings/general/common"; + +type TeamData = { + name?: string; + slug?: string; + image?: File; +}; + +const teamSlugSchema = z + .string() + .min(3, { + message: "URL must be at least 3 characters", + }) + .max(48, { + message: "URL must be at most 48 characters", + }) + .refine((slug) => !teamSlugRegex.test(slug), { + message: "URL can only contain lowercase letters, numbers and hyphens", + }); + +const formSchema = z.object({ + name: z + .string() + .min(3, { + message: "Name must be at least 3 characters", + }) + .max(32, { + message: "Name must be at most 32 characters", + }), + slug: teamSlugSchema, + image: z.instanceof(File).optional(), +}); + +type FormValues = z.infer; + +export function TeamInfoFormUI(props: { + updateTeam: (data: TeamData) => Promise; + onComplete: (updatedTeam: Team) => void; + isTeamSlugAvailable: (slug: string) => Promise; + teamSlug: string; +}) { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + slug: "", + image: undefined, + }, + }); + + const [disableSlugSuggestion, setDisableSlugSuggestion] = useState(false); + const name = form.watch("name"); + const slug = form.watch("slug"); + const [debouncedSlug] = useDebounce(slug, 500); + const [debouncedName] = useDebounce(name, 500); + + const { data: suggestedSlug, isFetching: isCalculatingSlug } = useQuery({ + queryKey: ["suggest-team-slug", debouncedName], + queryFn: async () => { + for (let attempt = 0; attempt < 3; attempt++) { + const computedSlug = computeSlug(name, attempt); + if (teamSlugSchema.safeParse(computedSlug).error) { + return null; + } + const isAvailable = await props.isTeamSlugAvailable(computedSlug); + + if (isAvailable) { + return computedSlug; + } + } + + return null; + }, + enabled: !!debouncedName && !disableSlugSuggestion, + retry: false, + }); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (suggestedSlug && !disableSlugSuggestion) { + form.setValue("slug", suggestedSlug, { + shouldValidate: true, + }); + } + }, [suggestedSlug, form, disableSlugSuggestion]); + + const shouldValidateSlug = + !!debouncedSlug && + !form.getFieldState("slug").invalid && + debouncedSlug !== props.teamSlug && + suggestedSlug !== debouncedSlug; + + const { data: isSlugAvailable, isFetching: isCheckingSlug } = useQuery({ + queryKey: ["checkTeamSlug", debouncedSlug], + queryFn: () => { + return props.isTeamSlugAvailable(debouncedSlug); + }, + enabled: shouldValidateSlug, + retry: false, + }); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (isSlugAvailable === undefined) { + return; + } + if (!isSlugAvailable) { + form.setError("slug", { + type: "manual", + message: "This URL is already taken", + }); + } else { + const isValidSlugError = form.formState.errors.slug?.type === "manual"; + if (isValidSlugError) { + form.clearErrors("slug"); + } + } + }, [isSlugAvailable, form]); + + const submit = useMutation({ + mutationFn: props.updateTeam, + }); + + async function onSubmit(values: FormValues) { + submit.mutate(values, { + onError(error) { + console.error(error); + toast.error("Failed to submit team details"); + }, + onSuccess(updatedTeam) { + props.onComplete(updatedTeam); + }, + }); + } + + return ( +
+
+ +
+ ( + + Team Avatar + + Enhance your team profile by adding a custom avatar + + +
+ + onChange(file)} + className="w-28 rounded-full bg-background" + disableHelperText + /> + +
+ + )} + /> + + ( + + + Team Name + * + + + + + + This is your team's name on thirdweb + + + + )} + /> + + ( + + + Team URL + * + + +
+
+ thirdweb.com/team/ +
+ { + setDisableSlugSuggestion(true); + field.onChange(e); + }} + className="truncate border-0 font-mono" + placeholder="my-team" + /> + {(isCheckingSlug || isCalculatingSlug) && ( +
+ +
+ )} +
+
+ + This is your team's URL namespace on thirdweb + + +
+ )} + /> +
+ +
+ +
+ + +
+ ); +} + +const computeSlug = (name: string, attempt: number) => { + const baseSlug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return attempt === 0 ? baseSlug : `${baseSlug}-${attempt}`; +}; diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding.tsx new file mode 100644 index 00000000000..80881e0367c --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { getBillingCheckoutUrl } from "@/actions/billing"; +import { sendTeamInvites } from "@/actions/sendTeamInvite"; +import type { Team } from "@/api/team"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import { upload } from "thirdweb/storage"; +import { apiServerProxy } from "../../../../@/actions/proxies"; +import { useTrack } from "../../../../hooks/analytics/useTrack"; +import { updateTeam } from "../../../team/[team_slug]/(team)/~/settings/general/updateTeam"; +import { InviteTeamMembersUI } from "./InviteTeamMembers"; +import { TeamInfoFormUI } from "./TeamInfoForm"; + +export function TeamInfoForm(props: { + client: ThirdwebClient; + teamId: string; + teamSlug: string; +}) { + const router = useDashboardRouter(); + const trackEvent = useTrack(); + return ( + { + const res = await apiServerProxy<{ + result: boolean; + }>({ + pathname: "/v1/teams/check-slug", + searchParams: { + slug, + }, + method: "GET", + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data.result; + }} + teamSlug={props.teamSlug} + onComplete={(updatedTeam) => { + router.replace(`/get-started/team/${updatedTeam.slug}/add-members`); + }} + updateTeam={async (data) => { + const teamValue: Partial = { + name: data.name, + slug: data.slug, + }; + + trackEvent({ + category: "teamOnboarding", + action: "updateTeam", + label: "attempt", + }); + + if (data.image) { + try { + teamValue.image = await upload({ + client: props.client, + files: [data.image], + }); + } catch { + // If image upload fails - ignore image, continue with the rest of the update + toast.error("Failed to upload image"); + } + } + + const res = await updateTeam({ + teamId: props.teamId, + value: teamValue, + }); + + if (!res.ok) { + trackEvent({ + category: "teamOnboarding", + action: "updateTeam", + label: "error", + }); + + throw new Error(res.error); + } + + trackEvent({ + category: "teamOnboarding", + action: "updateTeam", + label: "success", + }); + + return res.data; + }} + /> + ); +} + +export function InviteTeamMembers(props: { + team: Team; +}) { + const router = useDashboardRouter(); + const trackEvent = useTrack(); + + return ( + { + router.replace(`/team/${props.team.slug}`); + }} + getTeam={async () => { + const res = await apiServerProxy<{ + result: Team; + }>({ + pathname: `/v1/teams/${props.team.slug}`, + method: "GET", + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data.result; + }} + team={props.team} + getBillingCheckoutUrl={getBillingCheckoutUrl} + inviteTeamMembers={async (params) => { + const res = await sendTeamInvites({ + teamId: props.team.id, + invites: params, + }); + + trackEvent({ + category: "teamOnboarding", + action: "inviteTeamMembers", + label: "attempt", + }); + + if (!res.ok) { + trackEvent({ + category: "teamOnboarding", + action: "inviteTeamMembers", + label: "error", + }); + throw new Error(res.errorMessage); + } + + trackEvent({ + category: "teamOnboarding", + action: "inviteTeamMembers", + label: "success", + }); + + return { + results: res.results, + }; + }} + /> + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/useSkipOnboarding.tsx b/apps/dashboard/src/app/login/onboarding/useSkipOnboarding.tsx deleted file mode 100644 index 19af64728c8..00000000000 --- a/apps/dashboard/src/app/login/onboarding/useSkipOnboarding.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"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; -} diff --git a/apps/dashboard/src/app/login/onboarding/validations.ts b/apps/dashboard/src/app/login/onboarding/validations.ts index 60e94401990..e6d90039495 100644 --- a/apps/dashboard/src/app/login/onboarding/validations.ts +++ b/apps/dashboard/src/app/login/onboarding/validations.ts @@ -1,4 +1,3 @@ -import { RE_EMAIL } from "utils/regex"; import { z } from "zod"; const nameValidation = z @@ -6,12 +5,10 @@ const nameValidation = z .min(3, { message: "Must be at least 3 chars" }) .max(64, { message: "Must be max 64 chars" }); -const emailValidation = z.string().refine((str) => RE_EMAIL.test(str), { - message: "Email address is not valid", -}); +export const emailSchema = z.string().email("Invalid email address"); export const accountValidationSchema = z.object({ - email: emailValidation, + email: emailSchema, name: nameValidation.or(z.literal("")), }); diff --git a/apps/dashboard/src/app/stripe-redirect/page.tsx b/apps/dashboard/src/app/stripe-redirect/page.tsx new file mode 100644 index 00000000000..fdb04fcd840 --- /dev/null +++ b/apps/dashboard/src/app/stripe-redirect/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { useEffect } from "react"; +import { stripeRedirectPageChannel } from "./stripeRedirectChannel"; + +export default function ClosePage() { + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + stripeRedirectPageChannel.postMessage("close"); + // this will only work if this page is opened as a new tab + window.close(); + }, []); + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/stripe-redirect/stripeRedirectChannel.ts b/apps/dashboard/src/app/stripe-redirect/stripeRedirectChannel.ts new file mode 100644 index 00000000000..1ca23115000 --- /dev/null +++ b/apps/dashboard/src/app/stripe-redirect/stripeRedirectChannel.ts @@ -0,0 +1,33 @@ +"use client"; +import { useEffect } from "react"; + +export const stripeRedirectPageChannel = new BroadcastChannel( + "stripe-redirect", +); + +export function useStripeRedirectEvent(cb?: () => void) { + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!cb) { + return; + } + + function handleMessage(event: MessageEvent) { + if (!cb) { + return; + } + + if (event.data === "close") { + cb(); + } + } + + stripeRedirectPageChannel.addEventListener("message", handleMessage); + + return () => { + stripeRedirectPageChannel.removeEventListener("message", handleMessage); + }; + }, [cb]); + + return null; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBanner.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBanner.stories.tsx index c85080c19c7..1f6902e54ba 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBanner.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBanner.stories.tsx @@ -8,6 +8,9 @@ const meta = { title: "Banners/Billing Alerts", parameters: { layout: "centered", + nextjs: { + appDirectory: true, + }, }, } satisfies Meta; @@ -20,12 +23,12 @@ export const PaymentAlerts: Story = {
Promise.resolve({ status: 200 })} + getBillingPortalUrl={() => Promise.resolve({ status: 200 })} /> Promise.resolve({ status: 200 })} + getBillingPortalUrl={() => Promise.resolve({ status: 200 })} />
), diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBanners.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBanners.tsx index ca2871db09e..856f87313d4 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBanners.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBanners.tsx @@ -1,6 +1,6 @@ "use client"; -import { redirectToBillingPortal } from "@/actions/billing"; +import { getBillingPortalUrl } from "@/actions/billing"; import { PastDueBannerUI, ServiceCutOffBannerUI, @@ -9,7 +9,7 @@ import { export function PastDueBanner(props: { teamSlug: string }) { return ( ); @@ -18,7 +18,7 @@ export function PastDueBanner(props: { teamSlug: string }) { export function ServiceCutOffBanner(props: { teamSlug: string }) { return ( ); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx index 541a7889a93..9d60fc4e133 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx @@ -1,10 +1,12 @@ "use client"; -import type { BillingBillingPortalAction } from "@/actions/billing"; +import type { GetBillingPortalUrlAction } from "@/actions/billing"; import { BillingPortalButton } from "@/components/billing"; import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; import { cn } from "@/lib/utils"; -import { useState } from "react"; +import { useTransition } from "react"; +import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel"; function BillingAlertBanner(props: { title: string; @@ -12,40 +14,51 @@ function BillingAlertBanner(props: { teamSlug: string; variant: "error" | "warning"; ctaLabel: string; - redirectToBillingPortal: BillingBillingPortalAction; + getBillingPortalUrl: GetBillingPortalUrlAction; }) { - const [isRouteLoading, setIsRouteLoading] = useState(false); + const router = useDashboardRouter(); + const [isPending, startTransition] = useTransition(); + useStripeRedirectEvent(() => { + setTimeout(() => { + startTransition(() => { + router.refresh(); + }); + }, 1000); + }); return (
+ {isPending && ( +
+ +
+ )} +

{props.title}

{props.description}

{ - setIsRouteLoading(true); + buttonProps={{ + size: "sm", + className: cn( + "gap-2", + props.variant === "warning" && + "border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800", + props.variant === "error" && + "border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800", + ), }} + teamSlug={props.teamSlug} + getBillingPortalUrl={props.getBillingPortalUrl} > {props.ctaLabel} - {isRouteLoading ? : null}
); @@ -53,14 +66,14 @@ function BillingAlertBanner(props: { export function PastDueBannerUI(props: { teamSlug: string; - redirectToBillingPortal: BillingBillingPortalAction; + getBillingPortalUrl: GetBillingPortalUrlAction; }) { return ( You have unpaid invoices. Service may be suspended if not paid @@ -74,14 +87,14 @@ export function PastDueBannerUI(props: { export function ServiceCutOffBannerUI(props: { teamSlug: string; - redirectToBillingPortal: BillingBillingPortalAction; + getBillingPortalUrl: GetBillingPortalUrlAction; }) { return ( Your service has been suspended due to unpaid invoices. Pay now to 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 54d9b6cabf7..32f297a6b5b 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 @@ -1,8 +1,7 @@ "use client"; -import { redirectToCheckout } from "@/actions/billing"; +import { getBillingCheckoutUrl } from "@/actions/billing"; import { CheckoutButton } from "@/components/billing"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Button } from "@/components/ui/button"; import type { EngineTier } from "@3rdweb-sdk/react/hooks/useEngine"; import { Flex, Spacer } from "@chakra-ui/react"; @@ -10,7 +9,6 @@ import { useTrack } from "hooks/analytics/useTrack"; import { CheckIcon } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { useState } from "react"; interface EngineTierCardConfig { name: string; @@ -58,7 +56,6 @@ export const EngineTierCard = ({ isPrimaryCta?: boolean; ctaText?: string; }) => { - const [isRoutePending, startRouteTransition] = useState(false); const trackEvent = useTrack(); const params = useParams<{ team_slug: string }>(); const { name, monthlyPriceUsd } = ENGINE_TIER_CARD_CONFIG[tier]; @@ -140,22 +137,20 @@ export const EngineTierCard = ({ ? "product:engine_standard" : "product:engine_premium" } - className="gap-2" - redirectPath={`/team/${params?.team_slug}/~/engine`} teamSlug={params?.team_slug || "~"} - onClick={() => { - startRouteTransition(true); - trackEvent({ - category: "engine", - action: "click", - label: "clicked-cloud-hosted", - tier, - }); + getBillingCheckoutUrl={getBillingCheckoutUrl} + buttonProps={{ + onClick() { + trackEvent({ + category: "engine", + action: "click", + label: "clicked-cloud-hosted", + tier, + }); + }, + variant: isPrimaryCta ? "default" : "outline", }} - variant={isPrimaryCta ? "default" : "outline"} - redirectToCheckout={redirectToCheckout} > - {isRoutePending && } {ctaText ?? defaultCtaText} )} 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 index dbff4bd8fd1..3146686ae44 100644 --- 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 @@ -68,7 +68,10 @@ function Story() { }, }); - const redirectToBillingPortalStub = async () => ({ status: 200 }); + const getBillingPortalUrlStub = async () => ({ + status: 200, + url: "https://example.com", + }); return (
@@ -76,7 +79,7 @@ function Story() { @@ -84,7 +87,7 @@ function Story() { @@ -92,7 +95,7 @@ function Story() { @@ -100,7 +103,7 @@ function Story() {
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 index 68710dad9d0..4b73bc55d85 100644 --- 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 @@ -1,4 +1,4 @@ -import type { BillingBillingPortalAction } from "@/actions/billing"; +import type { GetBillingPortalUrlAction } from "@/actions/billing"; import type { Team } from "@/api/team"; import type { TeamSubscription } from "@/api/team-subscription"; import { BillingPortalButton } from "@/components/billing"; @@ -14,7 +14,7 @@ import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getVal export function PlanInfoCard(props: { subscriptions: TeamSubscription[]; team: Team; - redirectToBillingPortal: BillingBillingPortalAction; + getBillingPortalUrl: GetBillingPortalUrlAction; }) { const { subscriptions, team } = props; const validPlan = getValidTeamPlan(team); @@ -36,13 +36,13 @@ export function PlanInfoCard(props: {
-
+

{validPlan} Plan

-

- Go to "Manage Billing" to view your invoices, update your payment - method, or edit your billing details. +

+ Click on "Manage Billing" to view your invoices, update your + payment method, or edit your billing details.

{trialEndsInFuture && Trial}
@@ -57,9 +57,11 @@ export function PlanInfoCard(props: { {/* manage team billing */} Manage Billing @@ -143,9 +145,9 @@ function SubscriptionOverview(props: {
{props.title && ( -
{props.title}
+
{props.title}
)} -

+

{format( new Date(props.subscription.currentPeriodStart), "MMMM dd yyyy", diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx index a44bd96b093..14486977516 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx @@ -20,11 +20,15 @@ export function TeamGeneralSettingsPage(props: { team={props.team} client={props.client} updateTeamField={async (teamValue) => { - await updateTeam({ + const res = await updateTeam({ teamId: props.team.id, value: teamValue, }); + if (!res.ok) { + throw new Error(res.error); + } + // Current page's slug is updated if (teamValue.slug) { router.replace(`/team/${teamValue.slug}/~/settings`); @@ -55,13 +59,17 @@ export function TeamGeneralSettingsPage(props: { }); } - await updateTeam({ + const res = await updateTeam({ teamId: props.team.id, value: { image: uri, }, }); + if (!res.ok) { + throw new Error(res.error); + } + router.refresh(); }} /> diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx index a5747083397..9dae2e4c91c 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx @@ -12,6 +12,7 @@ import { FileInput } from "components/shared/FileInput"; import { useState } from "react"; import { toast } from "sonner"; import type { ThirdwebClient } from "thirdweb"; +import { teamSlugRegex } from "./common"; type UpdateTeamField = (team: Partial) => Promise; @@ -140,7 +141,7 @@ function TeamSlugFormControl(props: { setTeamSlug(value); if (value.trim().length === 0) { setErrorMessage("Team URL can not be empty"); - } else if (/[^a-zA-Z0-9-]/.test(value)) { + } else if (teamSlugRegex.test(value)) { setErrorMessage( "Invalid Team URL. Only letters, numbers and hyphens are allowed", ); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/common.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/common.ts new file mode 100644 index 00000000000..647168b4148 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/common.ts @@ -0,0 +1 @@ +export const teamSlugRegex = /[^a-zA-Z0-9-]/; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/updateTeam.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/updateTeam.ts index b00713dc6d2..872805c503d 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/updateTeam.ts +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/updateTeam.ts @@ -24,6 +24,18 @@ export async function updateTeam(params: { }); if (!res.ok) { - throw new Error("failed to update team"); + return { + ok: false as const, + error: await res.text(), + }; } + + const data = (await res.json()) as { + result: Team; + }; + + return { + ok: true as const, + data: data.result, + }; } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx index 1deb8d10a5c..ac14de73d3a 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx @@ -43,17 +43,25 @@ const inviteFormSchema = z.object({ type InviteFormValues = z.infer; +// Note: This component is also used in team onboarding flow and not just in team settings page + +export type InviteTeamMembersFn = ( + params: Array<{ + email: string; + role: TeamAccountRole; + }>, +) => Promise<{ + results: Array<"fulfilled" | "rejected">; +}>; + export function InviteSection(props: { team: Team; userHasEditPermission: boolean; - inviteTeamMembers: ( - params: Array<{ - email: string; - role: TeamAccountRole; - }>, - ) => Promise<{ - results: Array<"fulfilled" | "rejected">; - }>; + inviteTeamMembers: InviteTeamMembersFn; + customCTASection?: React.ReactNode; + className?: string; + onInviteSuccess?: () => void; + shouldHideInviteButton?: boolean; }) { const teamPlan = getValidTeamPlan(props.team); let bottomSection: React.ReactNode = null; @@ -76,7 +84,6 @@ export function InviteSection(props: { const sendInvites = useMutation({ mutationFn: async (data: InviteFormValues) => { const res = await props.inviteTeamMembers(data.invites); - return { inviteStatuses: res.results, }; @@ -97,14 +104,18 @@ export function InviteSection(props: {

- + {props.customCTASection ? ( + props.customCTASection + ) : ( + + )}
); } else if (!props.userHasEditPermission) { @@ -144,20 +155,28 @@ export function InviteSection(props: {

)} - )} - {form.watch("invites").length > 1 ? "Send Invites" : "Send Invite"} - + + {props.customCTASection} +
); } @@ -190,6 +209,10 @@ export function InviteSection(props: { toast.success( `Successfully sent ${data.inviteStatuses.length === 1 ? "" : data.inviteStatuses.length} ${inviteOrInvites}`, ); + + if (props.onInviteSuccess) { + props.onInviteSuccess(); + } } }, }); @@ -209,7 +232,12 @@ export function InviteSection(props: {
-
+

Invite

diff --git a/apps/dashboard/src/app/team/[team_slug]/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/layout.tsx index 262f263ed83..45464173d5c 100644 --- a/apps/dashboard/src/app/team/[team_slug]/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/layout.tsx @@ -1,7 +1,7 @@ import { getTeamBySlug } from "@/api/team"; import { AppFooter } from "@/components/blocks/app-footer"; import { redirect } from "next/navigation"; -import { TWAutoConnect } from "../../components/autoconnect"; +import { isTeamOnboardingComplete } from "../../login/onboarding/isOnboardingRequired"; import { SaveLastVisitedTeamPage } from "../components/last-visited-page/SaveLastVisitedPage"; import { PastDueBanner, @@ -19,6 +19,10 @@ export default async function RootTeamLayout(props: { redirect("/team"); } + if (!isTeamOnboardingComplete(team)) { + redirect(`/get-started/team/${team.slug}`); + } + return (
@@ -32,7 +36,6 @@ export default async function RootTeamLayout(props: { {props.children}
- diff --git a/apps/dashboard/src/app/team/[team_slug]/loading.tsx b/apps/dashboard/src/app/team/[team_slug]/loading.tsx new file mode 100644 index 00000000000..ddbf6bd8ca7 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/loading.tsx @@ -0,0 +1,5 @@ +import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; + +export default function Loading() { + return ; +} diff --git a/apps/dashboard/src/components/notices/AnnouncementBanner.tsx b/apps/dashboard/src/components/notices/AnnouncementBanner.tsx index aaf1e77ad9f..87ed2fb50d9 100644 --- a/apps/dashboard/src/components/notices/AnnouncementBanner.tsx +++ b/apps/dashboard/src/components/notices/AnnouncementBanner.tsx @@ -19,7 +19,8 @@ export function AnnouncementBanner(props: { hasDismissedAnnouncement || layoutSegment === "login" || layoutSegment === "nebula-app" || - layoutSegment === "join" + layoutSegment === "join" || + layoutSegment === "get-started" ) { return null; } diff --git a/apps/dashboard/src/components/settings/Account/Billing/BillingPricing.stories.tsx b/apps/dashboard/src/components/settings/Account/Billing/BillingPricing.stories.tsx index db0131d256f..74cee458086 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/BillingPricing.stories.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/BillingPricing.stories.tsx @@ -28,7 +28,10 @@ export const Mobile: Story = { }; function Story() { - const redirectToBillingPortalStub = async () => ({ status: 200 }); + const getBillingPortalUrlStub = async () => ({ + status: 200, + url: "https://example.com", + }); return (
@@ -36,7 +39,7 @@ function Story() { @@ -44,7 +47,7 @@ function Story() { @@ -52,7 +55,7 @@ function Story() { @@ -60,7 +63,7 @@ function Story() {
diff --git a/apps/dashboard/src/components/settings/Account/Billing/Pricing.tsx b/apps/dashboard/src/components/settings/Account/Billing/Pricing.tsx index 04c1522450e..407d6351794 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/Pricing.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/Pricing.tsx @@ -1,7 +1,12 @@ -import type { RedirectBillingCheckoutAction } from "@/actions/billing"; +"use client"; + +import type { GetBillingCheckoutUrlAction } from "@/actions/billing"; import type { Team } from "@/api/team"; import { PricingCard } from "@/components/blocks/pricing-card"; -import { useMemo } from "react"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { useMemo, useTransition } from "react"; +import { useStripeRedirectEvent } from "../../../../app/stripe-redirect/stripeRedirectChannel"; import { getValidTeamPlan } from "../../../../app/team/components/TeamHeader/getValidTeamPlan"; // TODO - move this in app router folder in other pr @@ -9,7 +14,7 @@ import { getValidTeamPlan } from "../../../../app/team/components/TeamHeader/get interface BillingPricingProps { team: Team; trialPeriodEndedAt: string | undefined; - redirectToCheckout: RedirectBillingCheckoutAction; + getBillingCheckoutUrl: GetBillingCheckoutUrlAction; } type CtaLink = { @@ -19,11 +24,19 @@ type CtaLink = { export const BillingPricing: React.FC = ({ team, trialPeriodEndedAt, - redirectToCheckout, + getBillingCheckoutUrl, }) => { - const pagePath = `/team/${team.slug}/~/settings/billing`; const validTeamPlan = getValidTeamPlan(team); + const [isPending, startTransition] = useTransition(); const contactUsHref = "/contact-us"; + const router = useDashboardRouter(); + useStripeRedirectEvent(() => { + setTimeout(() => { + startTransition(() => { + router.refresh(); + }); + }, 1000); + }); const starterCta: CtaLink | undefined = useMemo(() => { switch (validTeamPlan) { @@ -101,73 +114,79 @@ export const BillingPricing: React.FC = ({ }, [validTeamPlan]); return ( -
- {/* Starter */} - - - {/* Growth */} - - - +
+

+ {validTeamPlan === "free" ? "Select a Plan" : "Plans"}{" "} + {isPending && } +

+

+ Upgrade or downgrade your plan here to better fit your needs. +

+
+
+ {/* Starter */} + + + {/* Growth */} + + + +
); }; diff --git a/apps/dashboard/src/components/settings/Account/Billing/index.tsx b/apps/dashboard/src/components/settings/Account/Billing/index.tsx index 202d1b7bc0f..cd5f60aa289 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/index.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/index.tsx @@ -1,4 +1,4 @@ -import { redirectToBillingPortal, redirectToCheckout } from "@/actions/billing"; +import { getBillingCheckoutUrl, getBillingPortalUrl } from "@/actions/billing"; import type { Team } from "@/api/team"; import type { TeamSubscription } from "@/api/team-subscription"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -6,7 +6,6 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import { AlertCircleIcon } from "lucide-react"; import Link from "next/link"; import { PlanInfoCard } from "../../../../app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard"; -import { getValidTeamPlan } from "../../../../app/team/components/TeamHeader/getValidTeamPlan"; import { CouponSection } from "./CouponCard"; import { CreditsInfoCard } from "./PlanCard"; import { BillingPricing } from "./Pricing"; @@ -26,52 +25,48 @@ export const Billing: React.FC = ({ }) => { const validPayment = team.billingStatus === "validPayment" || team.billingStatus === "pastDue"; - const validPlan = getValidTeamPlan(team); const planSubscription = subscriptions.find((sub) => sub.type === "PLAN"); return (
- - - Manage your plan - - - Adjust your plan here to avoid unnecessary charges. For details, see{" "} - - - - {" "} - how to manage billing - {" "} - - - - -
-

- {validPlan === "free" ? "Select a Plan" : "Plans"} -

-

- Upgrade or downgrade your plan here to better fit your needs. -

-
- + +
+ + + + Manage your plan + + + Adjust your plan here to avoid unnecessary charges. For details, + see{" "} + + + + {" "} + how to manage billing + {" "} + + +
+ +
diff --git a/apps/dashboard/src/hooks/analytics/useTrack.ts b/apps/dashboard/src/hooks/analytics/useTrack.ts index b5461ec8269..16eefc5b746 100644 --- a/apps/dashboard/src/hooks/analytics/useTrack.ts +++ b/apps/dashboard/src/hooks/analytics/useTrack.ts @@ -2,7 +2,7 @@ import { flatten } from "flat"; import posthog from "posthog-js"; import { useCallback } from "react"; -type TExtendedTrackParams = { +export type TrackingParams = { category: string; action: string; label?: string; @@ -11,7 +11,7 @@ type TExtendedTrackParams = { }; export function useTrack() { - return useCallback((trackingData: TExtendedTrackParams) => { + return useCallback((trackingData: TrackingParams) => { const { category, action, label, ...restData } = trackingData; const catActLab = label ? `${category}.${action}.${label}` diff --git a/apps/dashboard/src/stories/stubs.ts b/apps/dashboard/src/stories/stubs.ts index 84eabb55ac1..e83d578bf80 100644 --- a/apps/dashboard/src/stories/stubs.ts +++ b/apps/dashboard/src/stories/stubs.ts @@ -44,6 +44,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team { billingPlanVersion: 1, canCreatePublicChains: null, image: null, + isOnboarded: true, enabledScopes: [ "pay", "storage", @@ -273,3 +274,15 @@ export function accountStub(overrides?: Partial): Account { ...overrides, }; } + +export function newAccountStub(overrides?: Partial): Account { + return { + email: undefined, + name: undefined, + id: "foo", + isStaff: false, + advancedEnabled: false, + creatorWalletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37", + ...overrides, + }; +} diff --git a/apps/dashboard/src/stories/utils.tsx b/apps/dashboard/src/stories/utils.tsx index 7b28d886321..bd143c0b9a2 100644 --- a/apps/dashboard/src/stories/utils.tsx +++ b/apps/dashboard/src/stories/utils.tsx @@ -34,3 +34,13 @@ export function mobileViewport( defaultViewport: key, }; } + +export function storybookLog( + ...mesages: (string | object | number | boolean)[] +) { + console.debug( + "%cStorybook", + "color: white; background-color: black; padding: 2px 4px; border-radius: 4px;", + ...mesages, + ); +} diff --git a/apps/dashboard/src/utils/pollWithTimeout.ts b/apps/dashboard/src/utils/pollWithTimeout.ts new file mode 100644 index 00000000000..12ff6a6b2e0 --- /dev/null +++ b/apps/dashboard/src/utils/pollWithTimeout.ts @@ -0,0 +1,36 @@ +export function pollWithTimeout(params: { + shouldStop: () => Promise; + timeoutMs: number; +}): Promise { + const { shouldStop: shouldStopPolling, timeoutMs } = params; + + return new Promise((resolve) => { + let isPromiseResolved = false; + + const timeoutPromise = new Promise((timeoutResolve) => { + setTimeout(() => { + timeoutResolve(); + }, timeoutMs); + }); + + const poll = async () => { + if (isPromiseResolved) { + return; + } + + if (await shouldStopPolling().catch(() => false)) { + return; + } else { + // Add a small delay before next poll to ensure we're not in a infinite loop if `isTrue` resolves in very short time + await new Promise((r) => setTimeout(r, 100)); + await poll(); + } + }; + + // resolve the promise if the condition is met or the timeout is reached + Promise.race([poll(), timeoutPromise]).then(() => { + resolve(); + isPromiseResolved = true; + }); + }); +} diff --git a/apps/dashboard/src/utils/regex.ts b/apps/dashboard/src/utils/regex.ts index cefd78f9637..0dae8d8736e 100644 --- a/apps/dashboard/src/utils/regex.ts +++ b/apps/dashboard/src/utils/regex.ts @@ -1,7 +1,3 @@ -export const RE_EMAIL = new RegExp( - /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, -); - export const RE_DOMAIN = new RegExp( /(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/, ); diff --git a/packages/service-utils/src/core/api.ts b/packages/service-utils/src/core/api.ts index b9d826c7919..af5f17639b4 100644 --- a/packages/service-utils/src/core/api.ts +++ b/packages/service-utils/src/core/api.ts @@ -63,6 +63,7 @@ export type TeamResponse = { growthTrialEligible: false; canCreatePublicChains: boolean | null; enabledScopes: ServiceName[]; + isOnboarded: boolean; }; export type ProjectSecretKey = { diff --git a/packages/service-utils/src/mocks.ts b/packages/service-utils/src/mocks.ts index c29f3373cee..af27d445927 100644 --- a/packages/service-utils/src/mocks.ts +++ b/packages/service-utils/src/mocks.ts @@ -58,6 +58,7 @@ export const validTeamResponse: TeamResponse = { growthTrialEligible: false, canCreatePublicChains: false, enabledScopes: ["storage", "rpc", "bundler"], + isOnboarded: true, }; export const validTeamAndProjectResponse: TeamAndProjectResponse = {