import { zodResolver } from "@hookform/resolvers/zod"; import { Prisma } from "@prisma/client"; import classnames from "classnames"; import debounce from "lodash/debounce"; import omit from "lodash/omit"; import { NextPageContext } from "next"; import { useSession } from "next-auth/react"; import Head from "next/head"; import { useRouter } from "next/router"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; import getApps from "@calcom/app-store/utils"; import dayjs from "@calcom/dayjs"; import { DEFAULT_SCHEDULE } from "@calcom/lib/availability"; import { DOCS_URL } from "@calcom/lib/constants"; import { fetchUsername } from "@calcom/lib/fetchUsername"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import prisma from "@calcom/prisma"; import { trpc } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; import { Icon } from "@calcom/ui/Icon"; import TimezoneSelect from "@calcom/ui/form/TimezoneSelect"; import { Form } from "@calcom/ui/form/fields"; import { getSession } from "@lib/auth"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import { Schedule as ScheduleType } from "@lib/types/schedule"; import { ClientSuspense } from "@components/ClientSuspense"; import Loader from "@components/Loader"; import Schedule from "@components/availability/Schedule"; import { CalendarListContainer } from "@components/integrations/CalendarListContainer"; import { UsernameAvailability } from "@components/ui/UsernameAvailability"; import { TRPCClientErrorLike } from "@trpc/client"; // Embed isn't applicable to onboarding, so ignore the rule /* eslint-disable @calcom/eslint/avoid-web-storage */ type ScheduleFormValues = { schedule: ScheduleType; }; let mutationComplete: ((err: Error | null) => void) | null; export default function Onboarding(props: inferSSRProps) { const { t } = useLocale(); const { user } = props; const router = useRouter(); const utils = trpc.useContext(); const telemetry = useTelemetry(); const [hasErrors, setHasErrors] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const { data: eventTypes } = trpc.useQuery(["viewer.eventTypes.list"]); const onSuccessMutation = async () => { showToast(t("your_user_profile_updated_successfully"), "success"); setHasErrors(false); // dismiss any open errors await utils.invalidateQueries(["viewer.me"]); }; const onErrorMutation = (error: TRPCClientErrorLike) => { setHasErrors(true); setErrorMessage(error.message); document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); }; const mutation = trpc.useMutation("viewer.updateProfile", { onSuccess: async () => { setSubmitting(true); setEnteredName(nameRef.current?.value || ""); setInputUsernameValue(usernameRef.current?.value || ""); if (mutationComplete) { mutationComplete(null); mutationComplete = null; } setSubmitting(false); }, onError: (err: TRPCClientErrorLike) => { setError(new Error(err.message)); if (mutationComplete) { mutationComplete(new Error(err.message)); } setSubmitting(false); }, }); const DEFAULT_EVENT_TYPES = [ { title: t("15min_meeting"), slug: "15min", length: 15, }, { title: t("30min_meeting"), slug: "30min", length: 30, }, { title: t("secret_meeting"), slug: "secret", length: 15, hidden: true, }, ]; const [isSubmitting, setSubmitting] = React.useState(false); const [enteredName, setEnteredName] = React.useState(""); const [currentUsername, setCurrentUsername] = useState(user.username || undefined); const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername); const { status } = useSession(); const loading = status === "loading"; const [ready, setReady] = useState(false); const [selectedImport, setSelectedImport] = useState(""); const [error, setError] = useState(null); const updateUser = useCallback( async (data: Prisma.UserUpdateInput) => { const res = await fetch(`/api/user/${user.id}`, { method: "PATCH", body: JSON.stringify({ data: { ...data } }), headers: { "Content-Type": "application/json", }, }); if (!res.ok) { throw new Error((await res.json()).message); } const responseData = await res.json(); return responseData.data; }, [user.id] ); const createEventType = trpc.useMutation("viewer.eventTypes.create"); const createSchedule = trpc.useMutation("viewer.availability.schedule.create", { onError: (error: TRPCClientErrorLike) => { throw new Error(error.message); }, }); /** Name */ const nameRef = useRef(null); /** Username */ const usernameRef = useRef(null!); const bioRef = useRef(null); /** End Name */ /** TimeZone */ const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess()); /** End TimeZone */ /** Onboarding Steps */ const [currentStep, setCurrentStep] = useState(props.initialStep); const handleConfirmStep = async () => { try { setSubmitting(true); const onComplete = steps[currentStep]?.onComplete; if (onComplete) { await onComplete(); } incrementStep(); setSubmitting(false); } catch (error) { setSubmitting(false); setError(error as Error); } }; const debouncedHandleConfirmStep = debounce(handleConfirmStep, 850); const handleSkipStep = () => { incrementStep(); }; const incrementStep = () => { const nextStep = currentStep + 1; if (nextStep >= steps.length) { completeOnboarding(); return; } setCurrentStep(nextStep); }; const decrementStep = () => { const previous = currentStep - 1; if (previous < 0) { return; } setCurrentStep(previous); }; const goToStep = (step: number) => { setCurrentStep(step); }; /** * Complete Onboarding finalizes the onboarding flow for a new user. * * Here, 3 event types are pre-created for the user as well. * Set to the availability the user enter during the onboarding. * * If a user skips through the Onboarding flow, * then the default availability is applied. */ const completeOnboarding = async () => { setSubmitting(true); if (eventTypes?.length === 0) { await Promise.all( DEFAULT_EVENT_TYPES.map(async (event) => { return createEventType.mutate(event); }) ); } await updateUser({ completedOnboarding: true, }); setSubmitting(false); router.push("/event-types"); }; const schema = z.object({ token: z.string(), }); const formMethods = useForm<{ token: string; }>({ resolver: zodResolver(schema), mode: "onSubmit" }); // Should update username on user when being redirected from sign up and doing google/saml useEffect(() => { async function validateAndSave(username: string) { const { data } = await fetchUsername(username); // Only persist username if its available and not premium // premium usernames are saved via stripe webhook if (data.available && !data.premium) { await updateUser({ username, }); } // Remove it from localStorage window.localStorage.removeItem("username"); return; } // Looking for username on localStorage const username = window.localStorage.getItem("username"); if (username) { validateAndSave(username); } }, [updateUser]); const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } }); const steps = [ { id: t("welcome"), title: t("welcome_to_calcom"), description: t("welcome_instructions"), Component: ( <> {/** @NOTE: Hiding temporarily * @URL related: https://github.com/calcom/cal.com/issues/3941 */} {/* {selectedImport == "" && (
)} */} {/* {selectedImport && (

{t("import_from")} {selectedImport === "calendly" ? "Calendly" : "SavvyCal"}

{t("you_will_need_to_generate")}. Find out how to do this{" "} here.

{ // track the number of imports. Without personal data/payload telemetry.event(telemetryEventTypes.importSubmitted, { ...collectPageParameters(), selectedImport, }); setSubmitting(true); const response = await fetch(`/api/import/${selectedImport}`, { method: "POST", body: JSON.stringify({ token: values.token, }), headers: { "Content-Type": "application/json", }, }); if (response.status === 201) { setSubmitting(false); handleSkipStep(); } else { await response.json().catch((e) => { console.log("Error: response.json invalid: " + e); setSubmitting(false); }); } })}> {hasErrors && } { formMethods.setValue("token", e.target.value); }} type="text" name="token" id="token" placeholder={t("access_token")} required className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" />
)}
*/}
{user.username !== "" && ( )}

{t("current_time")}:  {dayjs().tz(selectedTimeZone).format("LT")}

setSelectedTimeZone(value)} className="mt-1 block w-full rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm" />
), hideConfirm: false, confirmText: t("continue"), showCancel: true, cancelText: t("set_up_later"), onComplete: async () => { mutationComplete = null; setError(null); const mutationAsync = new Promise((resolve, reject) => { mutationComplete = (err) => { if (err) { reject(err); return; } resolve(null); }; }); const userUpdateData = { name: nameRef.current?.value, username: usernameRef.current?.value, timeZone: selectedTimeZone, }; mutation.mutate(userUpdateData); if (mutationComplete) { await mutationAsync; } }, }, { id: "connect-calendar", title: t("connect_your_calendar"), description: t("connect_your_calendar_instructions"), Component: (
}>
), hideConfirm: true, confirmText: t("continue"), showCancel: true, cancelText: t("continue_without_calendar"), }, { id: "set-availability", title: t("set_availability"), description: t("set_availability_instructions"), Component: ( className="mx-auto max-w-lg bg-white text-black dark:bg-opacity-5 dark:text-white" form={availabilityForm} handleSubmit={async (values) => { try { setSubmitting(true); await createSchedule.mutate({ name: t("default_schedule_name"), ...values, }); debouncedHandleConfirmStep(); setSubmitting(false); } catch (error) { if (error instanceof Error) { setError(error); } } }}>
), hideConfirm: true, showCancel: false, }, { id: "profile", title: t("nearly_there"), description: t("nearly_there_instructions"), Component: (

{t("few_sentences_about_yourself")}

), hideConfirm: false, confirmText: t("finish"), showCancel: true, cancelText: t("set_up_later"), onComplete: async () => { try { setSubmitting(true); await updateUser({ bio: bioRef.current?.value, }); setSubmitting(false); } catch (error) { setError(error as Error); setSubmitting(false); } }, }, ]; /** End Onboarding Steps */ useEffect(() => { setReady(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (loading || !ready) { return
; } return (
Cal.com - {t("getting_started")} {isSubmitting && (
)}

{steps[currentStep].title}

{steps[currentStep].description}

Step {currentStep + 1} of {steps.length}

{error && }
{steps.map((s, index) => { return index <= currentStep ? (
goToStep(index)} className={classnames( "h-1 w-1/4 bg-white", index < currentStep ? "cursor-pointer" : "" )} /> ) : (
); })}
{steps[currentStep].Component} {!steps[currentStep].hideConfirm && (
)}
{currentStep !== 0 && ( )}
); } export async function getServerSideProps(context: NextPageContext) { const session = await getSession(context); if (!session?.user?.id) { return { redirect: { permanent: false, destination: "/auth/login", }, }; } const user = await prisma.user.findFirst({ where: { id: session.user.id, }, select: { id: true, startTime: true, endTime: true, username: true, name: true, email: true, bio: true, avatar: true, timeZone: true, identityProvider: true, completedOnboarding: true, weekStart: true, hideBranding: true, theme: true, plan: true, brandColor: true, darkBrandColor: true, metadata: true, timeFormat: true, allowDynamicBooking: true, selectedCalendars: { select: { externalId: true, integration: true, }, }, credentials: { where: { userId: session.user.id, }, select: { id: true, type: true, key: true, userId: true, appId: true, }, }, schedules: { where: { userId: session.user.id, }, select: { id: true, }, }, }, }); if (!user) { throw new Error(`Signed in as ${session.user.id} but cannot be found in db`); } if (user.completedOnboarding) { return { redirect: { permanent: false, destination: "/event-types", }, }; } const integrations = getApps(user.credentials) .filter((item) => item.type.endsWith("_calendar")) .map((item) => omit(item, "key")); const { schedules } = user; const hasConfigureCalendar = integrations.some((integration) => integration.credential !== null); const hasSchedules = schedules && schedules.length > 0; return { props: { session, user, initialStep: hasSchedules ? (hasConfigureCalendar ? 2 : 3) : 0, }, }; }