diff --git a/apps/web/components/getting-started/steps-views/SetupAvailability.tsx b/apps/web/components/getting-started/steps-views/SetupAvailability.tsx new file mode 100644 index 0000000000..390bb55cba --- /dev/null +++ b/apps/web/components/getting-started/steps-views/SetupAvailability.tsx @@ -0,0 +1,88 @@ +import { useForm } from "react-hook-form"; + +import { Schedule } from "@calcom/features/schedules"; +import { DEFAULT_SCHEDULE } from "@calcom/lib/availability"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { TRPCClientErrorLike } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import type { AppRouter } from "@calcom/trpc/server/routers/_app"; +import { Button, Form, Icon } from "@calcom/ui"; + +interface ISetupAvailabilityProps { + nextStep: () => void; + defaultScheduleId?: number | null; +} + +const SetupAvailability = (props: ISetupAvailabilityProps) => { + const { defaultScheduleId } = props; + + const { t } = useLocale(); + const { nextStep } = props; + + const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId; + const queryAvailability = trpc.viewer.availability.schedule.get.useQuery( + { scheduleId: defaultScheduleId ?? undefined }, + { + enabled: !!scheduleId, + } + ); + + const availabilityForm = useForm({ + defaultValues: { + schedule: queryAvailability?.data?.availability || DEFAULT_SCHEDULE, + }, + }); + + const mutationOptions = { + onError: (error: TRPCClientErrorLike) => { + throw new Error(error.message); + }, + onSuccess: () => { + nextStep(); + }, + }; + const createSchedule = trpc.viewer.availability.schedule.create.useMutation(mutationOptions); + const updateSchedule = trpc.viewer.availability.schedule.update.useMutation(mutationOptions); + return ( +
{ + try { + if (defaultScheduleId) { + await updateSchedule.mutate({ + scheduleId: defaultScheduleId, + name: t("default_schedule_name"), + ...values, + }); + } else { + await createSchedule.mutate({ + name: t("default_schedule_name"), + ...values, + }); + } + } catch (error) { + if (error instanceof Error) { + // setError(error); + // @TODO: log error + } + } + }}> +
+ +
+ +
+ +
+
+ ); +}; + +export { SetupAvailability }; diff --git a/apps/web/modules/getting-started/[[...step]]/onboarding-view.tsx b/apps/web/modules/getting-started/[[...step]]/onboarding-view.tsx index 5b64fd34fa..6bf55279c1 100644 --- a/apps/web/modules/getting-started/[[...step]]/onboarding-view.tsx +++ b/apps/web/modules/getting-started/[[...step]]/onboarding-view.tsx @@ -4,32 +4,30 @@ import { signOut } from "next-auth/react"; import type { TFunction } from "next-i18next"; import Head from "next/head"; import { usePathname, useRouter } from "next/navigation"; -import { Suspense, useEffect } from "react"; +import { Suspense } from "react"; import { Toaster } from "react-hot-toast"; import { z } from "zod"; import { classNames } from "@calcom/lib"; -import { DEFAULT_SCHEDULE } from "@calcom/lib/availability"; import { APP_NAME } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { IdentityProvider } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; -import type { TRPCClientErrorLike } from "@calcom/trpc/react"; -import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; -import { Button, showToast, StepCard, Steps } from "@calcom/ui"; +import { Button, StepCard, Steps } from "@calcom/ui"; import { Icon } from "@calcom/ui"; import type { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps"; import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars"; import { ConnectedVideoStep } from "@components/getting-started/steps-views/ConnectedVideoStep"; +import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability"; import UserProfile from "@components/getting-started/steps-views/UserProfile"; import { UserSettings } from "@components/getting-started/steps-views/UserSettings"; const INITIAL_STEP = "user-settings"; -const BASE_STEPS = ["user-settings", "user-profile"] as const; +const BASE_STEPS = ["user-settings", "setup-availability", "user-profile"] as const; const EXTRA_STEPS = ["connected-calendar", "connected-video"] as const; type StepType = (typeof BASE_STEPS)[number] | (typeof EXTRA_STEPS)[number]; @@ -43,6 +41,13 @@ const getStepsAndHeadersForUser = (identityProvider: IdentityProvider, t: TFunct title: t("welcome_to_cal_header", { appName: APP_NAME }), subtitle: [t("we_just_need_basic_info"), t("edit_form_later_subtitle")], }, + { + title: t("set_availability"), + subtitle: [ + t("set_availability_getting_started_subtitle_1"), + t("set_availability_getting_started_subtitle_2"), + ], + }, { title: t("nearly_there"), subtitle: [t("nearly_there_instructions")], @@ -81,7 +86,9 @@ const getStepsAndHeadersForUser = (identityProvider: IdentityProvider, t: TFunct const stepRouteSchema = z.object({ step: z - .array(z.enum(["user-settings", "user-profile", "connected-calendar", "connected-video"])) + .array( + z.enum(["user-settings", "setup-availability", "user-profile", "connected-calendar", "connected-video"]) + ) .default([INITIAL_STEP]), from: z.string().optional(), }); @@ -90,15 +97,10 @@ export type PageProps = inferSSRProps; const OnboardingPage = (props: PageProps) => { const pathname = usePathname(); const params = useParamsWithFallback(); + const router = useRouter(); - const { t } = useLocale(); const [user] = trpc.viewer.me.useSuspenseQuery(); - const scheduleMutationOptions = { - onError: (error: TRPCClientErrorLike) => { - showToast(error.message, "error"); - }, - }; - const createSchedule = trpc.viewer.availability.schedule.create.useMutation(scheduleMutationOptions); + const { t } = useLocale(); const result = stepRouteSchema.safeParse({ ...params, @@ -135,20 +137,6 @@ const OnboardingPage = (props: PageProps) => { router.push(`/getting-started/${stepTransform(newStep)}`); }; - useEffect(() => { - if (!user || user.completedOnboarding) { - return; - } - const createDefaultSchedule = async () => { - await createSchedule.mutateAsync({ - name: t("default_schedule_name"), - ...DEFAULT_SCHEDULE, - }); - }; - - createDefaultSchedule(); - }, []); - return (
{ {currentStep === "connected-video" && } + {currentStep === "setup-availability" && ( + + )} {currentStep === "user-profile" && } diff --git a/apps/web/playwright/onboarding.e2e.ts b/apps/web/playwright/onboarding.e2e.ts index ee8cd88343..5fbd67dfc6 100644 --- a/apps/web/playwright/onboarding.e2e.ts +++ b/apps/web/playwright/onboarding.e2e.ts @@ -41,7 +41,7 @@ test.describe("Onboarding", () => { await page.locator("button[type=submit]").click(); if (identityProvider === IdentityProvider.GOOGLE) { - await expect(page).toHaveURL(/.*user-profile/); + await expect(page).toHaveURL(/.*setup-availability/); } else { await expect(page).toHaveURL(/.*connected-calendar/); } @@ -66,11 +66,20 @@ test.describe("Onboarding", () => { // tests skip button, we don't want to test entire flow. await page.locator("button[data-testid=skip-step]").click(); - await expect(page).toHaveURL(/.*user-profile/); + await expect(page).toHaveURL(/.*setup-availability/); }); } - await test.step("step 4 - User Profile", async () => { + await test.step("step 4 - Setup Availability", async () => { + const isDisabled = await page.locator("button[data-testid=save-availability]").isDisabled(); + await expect(isDisabled).toBe(false); + // same here, skip this step. + await page.locator("button[data-testid=save-availability]").click(); + + await expect(page).toHaveURL(/.*user-profile/); + }); + + await test.step("step 5- User Profile", async () => { await page.locator("button[type=submit]").click(); // should redirect to /event-types after onboarding