From 865d3d412a1ebf29fbbdaf56ff33b5fdc3ba63e1 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Thu, 27 Feb 2025 02:41:07 +0530 Subject: [PATCH] [TOOL-3446] Dashboard: Revamp account onboarding, Add Team onboarding --- .changeset/chilly-trams-wash.md | 5 + apps/dashboard/.storybook/preview.tsx | 35 +- .../src/@/components/blocks/pricing-card.tsx | 22 +- .../src/@3rdweb-sdk/react/hooks/useApi.ts | 173 +++---- .../components/server/routelist-card.tsx | 2 + .../components/server/routelist-row.tsx | 2 + .../components/client/FaucetButton.tsx | 4 +- .../src/app/account/settings/getAccount.ts | 4 +- .../app/get-started/team/[team_slug]/page.tsx | 62 +++ apps/dashboard/src/app/login/LoginPage.tsx | 108 +++-- .../src/app/login/onboarding/AccountForm.tsx | 175 ------- .../src/app/login/onboarding/ConfirmEmail.tsx | 261 ---------- .../src/app/login/onboarding/General.tsx | 90 ---- .../src/app/login/onboarding/LinkWallet.tsx | 125 ----- .../LinkWalletPrompt.stories.tsx | 64 +++ .../LinkWalletPrompt/LinkWalletPrompt.tsx | 123 +++++ .../LoginOrSignup/LoginOrSignup.stories.tsx | 71 +++ .../LoginOrSignup/LoginOrSignup.tsx | 276 +++++++++++ .../src/app/login/onboarding/Title.tsx | 23 - .../VerifyEmail/VerifyEmail.stories.tsx | 86 ++++ .../onboarding/VerifyEmail/VerifyEmail.tsx | 225 +++++++++ .../onboarding/account-onboarding-ui.tsx | 133 +++++ .../onboarding/account-onboarding.stories.tsx | 85 ++++ .../login/onboarding/account-onboarding.tsx | 51 ++ .../login/onboarding/isOnboardingRequired.ts | 7 +- .../onboarding/on-boarding-ui.client.tsx | 157 ------ .../login/onboarding/onboarding-layout.tsx | 163 +++++++ .../ChooseTeamPlan.stories.tsx | 35 ++ .../ChooseTeamPlan.tsx} | 48 +- .../team-onboarding/TeamInfoForm.stories.tsx | 52 ++ .../team-onboarding/TeamInfoForm.tsx | 458 ++++++++++++++++++ .../team-onboarding-ui.stories.tsx | 39 ++ .../team-onboarding/team-onboarding-ui.tsx | 45 ++ .../team-onboarding/team-onboarding.tsx | 37 ++ .../login/onboarding/useSkipOnboarding.tsx | 42 -- .../src/app/login/onboarding/validations.ts | 7 +- .../src/app/team/[team_slug]/layout.tsx | 7 +- .../src/app/team/[team_slug]/loading.tsx | 5 + .../components/notices/AnnouncementBanner.tsx | 3 +- .../settings/Account/Billing/Pricing.tsx | 1 - .../dashboard/src/hooks/analytics/useTrack.ts | 4 +- apps/dashboard/src/stories/stubs.ts | 13 + apps/dashboard/src/stories/utils.tsx | 10 + apps/dashboard/src/utils/regex.ts | 4 - packages/service-utils/src/core/api.ts | 1 + packages/service-utils/src/mocks.ts | 1 + 46 files changed, 2277 insertions(+), 1067 deletions(-) create mode 100644 .changeset/chilly-trams-wash.md create mode 100644 apps/dashboard/src/app/get-started/team/[team_slug]/page.tsx delete mode 100644 apps/dashboard/src/app/login/onboarding/AccountForm.tsx delete mode 100644 apps/dashboard/src/app/login/onboarding/ConfirmEmail.tsx delete mode 100644 apps/dashboard/src/app/login/onboarding/General.tsx delete mode 100644 apps/dashboard/src/app/login/onboarding/LinkWallet.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/LinkWalletPrompt/LinkWalletPrompt.stories.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/LinkWalletPrompt/LinkWalletPrompt.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/LoginOrSignup/LoginOrSignup.stories.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/LoginOrSignup/LoginOrSignup.tsx delete mode 100644 apps/dashboard/src/app/login/onboarding/Title.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/VerifyEmail/VerifyEmail.stories.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/VerifyEmail/VerifyEmail.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/account-onboarding-ui.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/account-onboarding.stories.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/account-onboarding.tsx delete mode 100644 apps/dashboard/src/app/login/onboarding/on-boarding-ui.client.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/onboarding-layout.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/team-onboarding/ChooseTeamPlan.stories.tsx rename apps/dashboard/src/app/login/onboarding/{ChoosePlan.tsx => team-onboarding/ChooseTeamPlan.tsx} (57%) create mode 100644 apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding-ui.stories.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding-ui.tsx create mode 100644 apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding.tsx delete mode 100644 apps/dashboard/src/app/login/onboarding/useSkipOnboarding.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/loading.tsx 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/@/components/blocks/pricing-card.tsx b/apps/dashboard/src/@/components/blocks/pricing-card.tsx index fe84e0a2410..098f91119ba 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"; @@ -6,10 +7,12 @@ import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { cn } from "@/lib/utils"; import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react"; import type React from "react"; +import { useState } from "react"; import { TEAM_PLANS } from "utils/pricing"; import { remainingDays } from "../../../utils/date-utils"; import type { RedirectBillingCheckoutAction } from "../../actions/billing"; import { CheckoutButton } from "../billing"; +import { Spinner } from "../ui/Spinner/Spinner"; type ButtonProps = React.ComponentProps; @@ -31,7 +34,6 @@ type PricingCardProps = { ctaHint?: string; highlighted?: boolean; current?: boolean; - canTrialGrowth?: boolean; activeTrialEndsAt?: string; redirectPath: string; redirectToCheckout: RedirectBillingCheckoutAction; @@ -43,11 +45,11 @@ export const PricingCard: React.FC = ({ cta, highlighted = false, current = false, - canTrialGrowth = false, activeTrialEndsAt, redirectPath, redirectToCheckout, }) => { + const [isRouteLoading, setIsRouteLoading] = useState(false); const plan = TEAM_PLANS[billingPlan]; const isCustomPrice = typeof plan.price === "string"; @@ -88,18 +90,7 @@ export const PricingCard: React.FC = ({
- {isCustomPrice ? ( - plan.price - ) : canTrialGrowth ? ( - <> - - ${plan.price} - {" "} - $0 - - ) : ( - `$${plan.price}` - )} + ${plan.price} {!isCustomPrice && ( @@ -154,7 +145,10 @@ export const PricingCard: React.FC = ({ sku={billingPlan === "starter" ? "plan:starter" : "plan:growth"} redirectPath={redirectPath} redirectToCheckout={redirectToCheckout} + className="gap-2" + onClick={() => setIsRouteLoading(true)} > + {isRouteLoading && } {cta.title} ) : ( diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts index 9dba9002be5..5c0ef512167 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts @@ -44,14 +44,6 @@ export type Account = { // TODO - add image URL }; -interface UpdateAccountInput { - name?: string; - email?: string; - linkWallet?: boolean; - subscribeToUpdates?: boolean; - onboardSkipped?: boolean; -} - interface UpdateAccountNotificationsInput { billing: "email" | "none"; updates: "email" | "none"; @@ -140,45 +132,40 @@ export function useAccountCredits() { }); } -export function useUpdateAccount() { - const queryClient = useQueryClient(); - const address = useActiveAccount()?.address; - - return useMutation({ - mutationFn: async (input: UpdateAccountInput) => { - 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)/(bridge)/routes/components/server/routelist-card.tsx b/apps/dashboard/src/app/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx index 7deca3f819a..1d2f71b326e 100644 --- a/apps/dashboard/src/app/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx +++ b/apps/dashboard/src/app/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx @@ -74,6 +74,7 @@ export async function RouteListCard({
{resolvedOriginTokenIconUri ? ( + // eslint-disable-next-line @next/next/no-img-element {originTokenAddress} )} {resolvedDestinationTokenIconUri ? ( + // eslint-disable-next-line @next/next/no-img-element {destinationTokenAddress} {resolvedOriginTokenIconUri ? ( // For now we're using a normal img tag because the domain for these images is unknown + // eslint-disable-next-line @next/next/no-img-element {originTokenAddress}
{resolvedDestinationTokenIconUri ? ( + // eslint-disable-next-line @next/next/no-img-element {destinationTokenAddress} ; +}) { + const { team_slug } = await props.params; + const [team, account, accountAddress, teams] = await Promise.all([ + getTeamBySlug(team_slug), + getValidAccount(`/team/${team_slug}`), + getAuthTokenWalletAddress(), + getTeams(), + ]); + + if (!accountAddress || !account || !teams) { + loginRedirect(`/team/${team_slug}`); + } + + if (!team) { + redirect("/team"); + } + + // if already onboarded + if (isTeamOnboardingComplete(team)) { + redirect(`/team/${team.slug}`); + } + + const teamsAndProjects = await Promise.all( + teams.map(async (team) => ({ + team, + projects: await getProjects(team.slug), + })), + ); + + return ( +
+ {/* header */} +
+ +
+ + {/* content */} + + + +
+ ); +} diff --git a/apps/dashboard/src/app/login/LoginPage.tsx b/apps/dashboard/src/app/login/LoginPage.tsx index 4a3c1d05860..188b3f13eeb 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,19 @@ 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 wallets = [ @@ -96,26 +99,13 @@ export function LoginAndOnboardingPage(props: { ); } -export function LoginAndOnboardingPageContent(props: { - account: Account | undefined; - redirectPath: string; +function LoginPageContainer(props: { + children: React.ReactNode; }) { return ( -
-
- - -
- } - className="flex justify-center" - > - - + <> +
+ {props.children}
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -124,22 +114,15 @@ 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; }) { + const accountAddress = useActiveAccount()?.address; const [screen, setScreen] = useState< | { id: "login" } | { @@ -167,7 +150,7 @@ function PageContent(props: { return; } - if (!isOnboardingComplete(account)) { + if (!isAccountOnboardingComplete(account)) { setScreen({ id: "onboarding", account, @@ -185,32 +168,49 @@ function PageContent(props: { } }, [connectionStatus, screen.id]); + if (screen.id === "complete") { + return ; + } + if (connectionStatus === "connecting") { - return ; + return ( + + + + ); } - if (connectionStatus !== "connected" || screen.id === "login") { - return ; + 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: { @@ -268,3 +268,21 @@ function CustomConnectEmbed(props: {
); } + +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/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..d593d7ad362 --- /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 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..0b92d47e76f --- /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: UsersIcon, + title: "Team Details", + description: "Provide team details", + number: 1, + }, + { + icon: BoxIcon, + title: "Choose a plan", + description: "Pick a plan for 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/ChooseTeamPlan.stories.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/ChooseTeamPlan.stories.tsx new file mode 100644 index 00000000000..40764ed60a1 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/ChooseTeamPlan.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { TeamOnboardingLayout } from "../onboarding-layout"; +import { ChooseTeamPlan } from "./ChooseTeamPlan"; + +const meta = { + title: "Onboarding/TeamOnboarding/ChoosePlan", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Story() { + return ( + + {}} + teamSlug="test" + redirectPath="/" + redirectToCheckout={async () => { + return { status: 200 }; + }} + /> + + ); +} diff --git a/apps/dashboard/src/app/login/onboarding/ChoosePlan.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/ChooseTeamPlan.tsx similarity index 57% rename from apps/dashboard/src/app/login/onboarding/ChoosePlan.tsx rename to apps/dashboard/src/app/login/onboarding/team-onboarding/ChooseTeamPlan.tsx index 291ec711ee6..fb79e10a53c 100644 --- a/apps/dashboard/src/app/login/onboarding/ChoosePlan.tsx +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/ChooseTeamPlan.tsx @@ -3,24 +3,41 @@ import type { RedirectBillingCheckoutAction } from "@/actions/billing"; import { TextDivider } from "@/components/TextDivider"; 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 { TitleAndDescription } from "./Title"; +import { useMutation } from "@tanstack/react-query"; -export function OnboardingChoosePlan(props: { +export function ChooseTeamPlan(props: { skipPlan: () => Promise; - canTrialGrowth: boolean; teamSlug: string; redirectPath: string; redirectToCheckout: RedirectBillingCheckoutAction; }) { + const skipPlan = useMutation({ + mutationFn: async () => { + // wait for a while before skipping so the team onboarding status is updated in backend + // and we don't end up redirected back to this page + await new Promise((resolve) => setTimeout(resolve, 3000)); + props.skipPlan(); + }, + }); + return (
- +

+ Choose a plan +

+ +
+ Start building with the free Starter plan or upgrade to Growth for + increased limits and advanced features.{" "} + + Learn more + +
-
+
- + + +
); } 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..a83e41ee0d8 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx @@ -0,0 +1,52 @@ +import { Toaster } from "@/components/ui/sonner"; +import type { Meta, StoryObj } from "@storybook/react"; +import { storybookLog } from "../../../../stories/utils"; +import { TeamOnboardingLayout } from "../onboarding-layout"; +import { TeamInfoForm } 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"); + }} + sendTeamOnboardingData={async (formData) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + storybookLog("sendTeamOnboardingData", formData); + if (props.sendType === "error") { + throw new Error("Demo Error"); + } + }} + /> + + + ); +} 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..0d24721170d --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/TeamInfoForm.tsx @@ -0,0 +1,458 @@ +import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { + ArrowRightIcon, + CheckIcon, + Globe, + StoreIcon, + UserIcon, +} from "lucide-react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const teamTypes = [ + { + icon: UserIcon, + label: "Developer", + description: "I am building an app or game", + }, + { + icon: StoreIcon, + label: "Studio", + description: "I am building multiple apps or games", + }, + { + icon: Globe, + label: "Ecosystem", + description: "I am building a platform", + }, +] as const; + +const teamScales = ["Startup", "Scaleup", "Enterprise"] as const; + +const teamIndustries = [ + "Consumer", + "DeFi", + "Gaming", + "Social", + "AI", + "Blockchain", +] as const; + +const teamPlatformInterests = [ + "Connect", + "Engine", + "Contracts", + "RPC", + "Insight", + "Nebula", +] as const; + +const teamRoles = [ + "Frontend Developer", + "Backend Developer", + "Smart Contract Engineer", + "Founder", + "Product Manager", + "Business Development", + "Finance", +] as const; + +const productInterests = [ + "Connect", + "Engine", + "Contracts", + "RPC", + "Insight", + "Nebula", +] as const; + +const teamPlatforms = ["Web", "Backend", "Mobile", "Unity", "Unreal"] as const; + +type TeamType = (typeof teamTypes)[number]["label"]; + +const teamFormSchema = z.object({ + team: z.object({ + name: z.string().min(1, "Team name is required"), + type: z.enum(teamTypes.map((x) => x.label) as [TeamType, ...TeamType[]]), + scale: z.enum(teamScales), + industry: z.enum(teamIndustries), + platforms: z.array(z.enum(teamPlatforms)), + productInterests: z.array(z.enum(teamPlatformInterests)), + chainInterests: z.array(z.number()), + }), + member: z.object({ + role: z.enum(teamRoles), + }), +}); + +export type TeamOnboardingData = z.infer; + +export function TeamInfoForm(props: { + onComplete: () => void; + sendTeamOnboardingData: (data: TeamOnboardingData) => Promise; +}) { + const sendTeamOnboardingData = useMutation({ + mutationFn: props.sendTeamOnboardingData, + }); + + const form = useForm({ + resolver: zodResolver(teamFormSchema), + defaultValues: { + team: { + productInterests: [], + platforms: [], + chainInterests: [], + }, + }, + }); + + const onSubmit = (data: TeamOnboardingData) => { + sendTeamOnboardingData.mutate(data, { + onSuccess() { + props.onComplete(); + }, + onError(error) { + console.error(error); + toast.error("Failed to send team details"); + }, + }); + }; + + // conditional fields ---- + const shouldShowPlatforms = form + .watch("team.productInterests") + .includes("Connect"); + + const showShowChainInterests = + form.watch("team.type") === "Developer" || + form.watch("team.type") === "Studio"; + + // ensure that hidden fields have empty values + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!shouldShowPlatforms) { + form.setValue("team.platforms", []); + } + if (!showShowChainInterests) { + form.setValue("team.chainInterests", []); + } + }, [shouldShowPlatforms, showShowChainInterests, form]); + + return ( +
+
+ + {/* Team Name */} + ( + + What is your team name? + + + + + + )} + /> + + {/* Team Type */} + ( + + What is your team type? +
+ {teamTypes.map((teamType) => ( +
+ {field.value === teamType.label && ( +
+ +
+ )} + + +
+ ))} +
+ +
+ )} + /> + + {/* Team Scale */} + ( + + What is the size of your team? + + + + )} + /> + + {/* Team Industry */} + ( + + What industry is your company in? + + + + + )} + /> + + {/* Product Interests */} + ( + + What thirdweb products made you sign up? + + + {productInterests.map((product) => ( + ( + + + { + const newValue = checked + ? [...field.value, product] + : field.value?.filter( + (value) => value !== product, + ); + field.onChange(newValue); + }} + /> + + {product} + + )} + /> + ))} + + + + + )} + /> + + {/* Platforms */} + {shouldShowPlatforms && ( + ( + + What platforms do you use? + + {teamPlatforms.map((platform) => ( + ( + + + { + const newValue = checked + ? [...field.value, platform] + : field.value?.filter( + (value) => value !== platform, + ); + field.onChange(newValue); + }} + /> + + {platform} + + )} + /> + ))} + + + + )} + /> + )} + + {/* Chain Interests */} + {showShowChainInterests && ( + ( + + + Which chains are you interested in building on? + + + + + + + )} + /> + )} + + {/* Member Role */} + ( + + What is your role in the team? + + + + )} + /> + +
+ +
+ + +
+ ); +} + +function CheckboxCard(props: { children: React.ReactNode }) { + return ( +
+
+ {props.children} +
+
+ ); +} diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding-ui.stories.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding-ui.stories.tsx new file mode 100644 index 00000000000..5421c829bb1 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding-ui.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { storybookLog } from "../../../../stories/utils"; +import { TeamOnboardingUI } from "./team-onboarding-ui"; + +function Story() { + return ( + { + storybookLog("sendTeamOnboardingData", data); + }} + teamSlug="foo" + onSkipPlan={() => { + storybookLog("onSkipPlan"); + }} + redirectPath="/foo" + redirectToCheckout={async (data) => { + storybookLog("redirectToCheckout", data); + return { status: 200 }; + }} + /> + ); +} + +const meta = { + title: "Onboarding/TeamOnboarding/Flow", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding-ui.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding-ui.tsx new file mode 100644 index 00000000000..031e574b8cf --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding-ui.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { RedirectBillingCheckoutAction } from "@/actions/billing"; +import { useState } from "react"; +import { TeamOnboardingLayout } from "../onboarding-layout"; +import { ChooseTeamPlan } from "./ChooseTeamPlan"; +import { TeamInfoForm, type TeamOnboardingData } from "./TeamInfoForm"; + +type TeamOnboardingProps = { + redirectPath: string; + redirectToCheckout: RedirectBillingCheckoutAction; + sendTeamOnboardingData: (data: TeamOnboardingData) => Promise; + onSkipPlan: () => void; + teamSlug: string; +}; + +type TeamOnboardingScreen = "show-plans" | "team-info"; + +export function TeamOnboardingUI(props: TeamOnboardingProps) { + const [screen, setScreen] = useState("team-info"); + + return ( + + {screen === "team-info" && ( + { + setScreen("show-plans"); + }} + /> + )} + + {screen === "show-plans" && ( + { + props.onSkipPlan(); + }} + redirectToCheckout={props.redirectToCheckout} + /> + )} + + ); +} 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..7577503c9b4 --- /dev/null +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/team-onboarding.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { redirectToCheckout } from "@/actions/billing"; +import { apiServerProxy } from "@/actions/proxies"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { TeamOnboardingUI } from "./team-onboarding-ui"; + +export function TeamOnboarding(props: { + teamSlug: string; + teamId: string; +}) { + const router = useDashboardRouter(); + return ( + { + const teamOnboardRes = await apiServerProxy({ + pathname: `/v1/teams/${props.teamId}/onboard`, + method: "PUT", + body: JSON.stringify(params), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!teamOnboardRes.ok) { + throw new Error(teamOnboardRes.error); + } + }} + onSkipPlan={() => { + router.replace(`/team/${props.teamSlug}`); + }} + teamSlug={props.teamId} + /> + ); +} 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/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/Pricing.tsx b/apps/dashboard/src/components/settings/Account/Billing/Pricing.tsx index 04c1522450e..4946b0410db 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/Pricing.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/Pricing.tsx @@ -143,7 +143,6 @@ export const BillingPricing: React.FC = ({ } : undefined } - canTrialGrowth={false} // upsell growth plan if user is on free plan highlighted={validTeamPlan === "free" || validTeamPlan === "starter"} teamSlug={team.slug} 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/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 = {