import { ArrowRightIcon } from "@heroicons/react/outline"; import { Prisma } from "@prisma/client"; import classnames from "classnames"; import dayjs from "dayjs"; 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/client"; import Head from "next/head"; import { useRouter } from "next/router"; import React, { useEffect, useRef, useState } from "react"; import TimezoneSelect from "react-timezone-select"; import { getSession } from "@lib/auth"; import { useLocale } from "@lib/hooks/useLocale"; import getIntegrations from "@lib/integrations/getIntegrations"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import Loader from "@components/Loader"; import { ShellSubHeading } from "@components/Shell"; import CalendarsList from "@components/integrations/CalendarsList"; import ConnectedCalendarsList from "@components/integrations/ConnectedCalendarsList"; import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections"; import { Alert } from "@components/ui/Alert"; import Button from "@components/ui/Button"; import SchedulerForm, { SCHEDULE_FORM_ID } from "@components/ui/Schedule/Schedule"; import Text from "@components/ui/Text"; import getCalendarCredentials from "@server/integrations/getCalendarCredentials"; import getConnectedCalendars from "@server/integrations/getConnectedCalendars"; import getEventTypes from "../lib/queries/event-types/get-event-types"; dayjs.extend(utc); dayjs.extend(timezone); export default function Onboarding(props: inferSSRProps) { const { t } = useLocale(); const router = useRouter(); const refreshData = () => { router.replace(router.asPath); }; 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 Sess = useSession(); const [ready, setReady] = useState(false); 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 = async (data: Prisma.ScheduleCreateInput) => { const res = await fetch(`/api/schedule`, { method: "POST", 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; }; /** Name */ const nameRef = useRef(null); const bioRef = useRef(null); /** End Name */ /** TimeZone */ const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone ?? dayjs.tz.guess(), label: null, }); const currentTime = React.useMemo(() => { return dayjs().tz(selectedTimeZone.value).format("H:mm A"); }, [selectedTimeZone]); /** End TimeZone */ /** Onboarding Steps */ const [currentStep, setCurrentStep] = useState(0); const detectStep = () => { let step = 0; const hasSetUserNameOrTimeZone = props.user.name && props.user.timeZone; if (hasSetUserNameOrTimeZone) { step = 1; } 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) { console.log("handleConfirmStep", 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) { Promise.all( DEFAULT_EVENT_TYPES.map(async (event) => { return await createEventType(event); }) ); } } await updateUser({ completedOnboarding: true, }); setSubmitting(false); router.push("/event-types"); }; const steps = [ { id: t("welcome"), title: t("welcome_to_calcom"), description: t("welcome_instructions"), Component: (
{t("current_time")}:  {currentTime}
), hideConfirm: false, confirmText: t("continue"), showCancel: true, cancelText: t("set_up_later"), onComplete: async () => { try { setSubmitting(true); await updateUser({ name: nameRef.current?.value, timeZone: selectedTimeZone.value, }); setEnteredName(nameRef.current?.value || ""); setSubmitting(true); } catch (error) { setError(error as Error); setSubmitting(false); } }, }, { id: "connect-calendar", title: t("connect_your_calendar"), description: t("connect_your_calendar_instructions"), Component: ( <> {props.connectedCalendars.length > 0 && ( <> { refreshData(); }} /> } /> )} { refreshData(); }} /> ), 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: ( <>
{ try { setSubmitting(true); await createSchedule({ freeBusyTimes: data, }); debouncedHandleConfirmStep(); setSubmitting(false); } catch (error) { setError(error as 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); console.log("updating"); await updateUser({ bio: bioRef.current?.value, }); setSubmitting(false); } catch (error) { setError(error as Error); setSubmitting(false); } }, }, ]; /** End Onboarding Steps */ useEffect(() => { detectStep(); setReady(true); }, []); if (Sess[1] || !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 bg-white w-1/4", index < currentStep ? "cursor-pointer" : "" )}>
) : (
); })}
{steps[currentStep].Component} {!steps[currentStep].hideConfirm && (
)}
{currentStep !== 0 && ( )}
); } export async function getServerSideProps(context: NextPageContext) { const session = await getSession(context); let integrations = []; let connectedCalendars = []; let credentials = []; let eventTypes = []; let schedules = []; 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, 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", }, }; } credentials = await prisma.credential.findMany({ where: { userId: user.id, }, select: { id: true, type: true, key: true, }, }); integrations = getIntegrations(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) connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); eventTypes = await prisma.eventType.findMany({ where: { userId: user.id, }, select: { id: true, title: true, slug: true, description: true, length: true, hidden: true, }, }); schedules = await prisma.schedule.findMany({ where: { userId: user.id, }, select: { id: true, }, }); return { props: { session, user, integrations, connectedCalendars, eventTypes, schedules, }, }; }