import { ArrowRightIcon } from "@heroicons/react/outline"; import { zodResolver } from "@hookform/resolvers/zod"; import { Prisma } from "@prisma/client"; import classnames from "classnames"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; 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, { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import TimezoneSelect from "react-timezone-select"; import * as z from "zod"; import getApps from "@calcom/app-store/utils"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { ResponseUsernameApi } from "@calcom/ee/lib/core/checkPremiumUsername"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; import { Form } from "@calcom/ui/form/fields"; import { getSession } from "@lib/auth"; import { DEFAULT_SCHEDULE } from "@lib/availability"; import { useLocale } from "@lib/hooks/useLocale"; import prisma from "@lib/prisma"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { trpc } from "@lib/trpc"; 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 Text from "@components/ui/Text"; import getEventTypes from "../lib/queries/event-types/get-event-types"; dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(localizedFormat); type ScheduleFormValues = { schedule: ScheduleType; }; let mutationComplete: ((err: Error | null) => void) | null; export default function Onboarding(props: inferSSRProps) { const { t } = useLocale(); const router = useRouter(); const telemetry = useTelemetry(); const mutation = trpc.useMutation("viewer.updateProfile", { onSuccess: async () => { setSubmitting(true); setEnteredName(nameRef.current?.value || ""); if (mutationComplete) { mutationComplete(null); mutationComplete = null; } setSubmitting(false); }, onError: (err) => { 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 { status } = useSession(); const loading = status === "loading"; const [ready, setReady] = useState(false); const [selectedImport, setSelectedImport] = useState(""); const [error, setError] = useState(null); const updateUser = async (data: Prisma.UserUpdateInput) => { const res = await fetch(`/api/user/${props.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; }; const createEventType = async (data: Prisma.EventTypeCreateInput) => { const res = await fetch(`/api/availability/eventtype`, { method: "POST", body: JSON.stringify(data), headers: { "Content-Type": "application/json", }, }); if (!res.ok) { throw new Error((await res.json()).message); } const responseData = await res.json(); return responseData.data; }; const createSchedule = trpc.useMutation("viewer.availability.schedule.create", { onError: (err) => { throw new Error(err.message); }, }); /** Name */ const nameRef = useRef(null); const usernameRef = useRef(null); const bioRef = useRef(null); /** End Name */ /** TimeZone */ const [selectedTimeZone, setSelectedTimeZone] = useState(props.user.timeZone ?? dayjs.tz.guess()); /** End TimeZone */ /** Onboarding Steps */ const [currentStep, setCurrentStep] = useState(0); const detectStep = () => { // Always set timezone if new user let step = 0; const hasConfigureCalendar = props.integrations.some((integration) => integration.credential !== null); if (hasConfigureCalendar) { step = 2; } const hasSchedules = props.schedules && props.schedules.length > 0; if (hasSchedules) { step = 3; } setCurrentStep(step); }; const handleConfirmStep = async () => { try { setSubmitting(true); if ( steps[currentStep] && steps[currentStep].onComplete && typeof steps[currentStep].onComplete === "function" ) { await steps[currentStep].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 (!props.eventTypes || props.eventTypes.length === 0) { const eventTypes = await getEventTypes(); if (eventTypes.length === 0) { await Promise.all( DEFAULT_EVENT_TYPES.map(async (event) => { return await createEventType(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" }); const fetchUsername = async (username: string) => { const response = await fetch(`${process.env.NEXT_PUBLIC_WEBSITE_URL}/api/username`, { credentials: "include", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ username: username.trim() }), method: "POST", mode: "cors", }); const data = (await response.json()) as ResponseUsernameApi; return { response, data }; }; // 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); } }, []); const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } }); const steps = [ { id: t("welcome"), title: t("welcome_to_calcom"), description: t("welcome_instructions"), Component: ( <> {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.withJitsu((jitsu) => jitsu.track(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); }); } })}> { 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 shadow-sm focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" />
)}
{t("current_time")}:  {dayjs().tz(selectedTimeZone).format("LT")}
setSelectedTimeZone(value)} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm 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, 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(() => { detectStep(); 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, selectedCalendars: { select: { externalId: true, integration: 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 credentials = await prisma.credential.findMany({ where: { userId: user.id, }, select: { id: true, type: true, key: true, userId: true, appId: true, }, }); const integrations = getApps(credentials) .filter((item) => item.type.endsWith("_calendar")) .map((item) => omit(item, "key")); // get user's credentials + their connected integrations const calendarCredentials = getCalendarCredentials(credentials, user.id); // get all the connected integrations' calendars (from third party) const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); const eventTypes = await prisma.eventType.findMany({ where: { userId: user.id, }, select: { id: true, title: true, slug: true, description: true, length: true, hidden: true, }, }); const schedules = await prisma.schedule.findMany({ where: { userId: user.id, }, select: { id: true, }, }); return { props: { session, user, integrations, connectedCalendars, eventTypes, schedules, }, }; }