import Head from "next/head"; import prisma from "@lib/prisma"; import { useSession } from "next-auth/client"; import { EventType, EventTypeCreateInput, Schedule, ScheduleCreateInput, User, UserUpdateInput, } from "@prisma/client"; import { NextPageContext } from "next"; import React, { useEffect, useRef, useState } from "react"; import { validJson } from "@lib/jsonUtils"; import TimezoneSelect from "react-timezone-select"; import Text from "@components/ui/Text"; import ErrorAlert from "@components/ui/alerts/Error"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import AddCalDavIntegration, { ADD_CALDAV_INTEGRATION_FORM_TITLE, } from "@lib/integrations/CalDav/components/AddCalDavIntegration"; import { Dialog, DialogClose, DialogContent, DialogHeader } from "@components/Dialog"; import SchedulerForm, { SCHEDULE_FORM_ID } from "@components/ui/Schedule/Schedule"; import { useRouter } from "next/router"; import { Integration } from "pages/integrations"; import { AddCalDavIntegrationRequest } from "../lib/integrations/CalDav/components/AddCalDavIntegration"; import classnames from "classnames"; import { ArrowRightIcon } from "@heroicons/react/outline"; import { getSession } from "@lib/auth"; import Button from "@components/ui/Button"; dayjs.extend(utc); dayjs.extend(timezone); const DEFAULT_EVENT_TYPES = [ { title: "15 Min Meeting", slug: "15min", length: 15, }, { title: "30 Min Meeting", slug: "30min", length: 30, }, { title: "Secret Meeting", slug: "secret", length: 15, hidden: true, }, ]; type OnboardingProps = { user: User; integrations?: Record[]; eventTypes?: EventType[]; schedules?: Schedule[]; }; export default function Onboarding(props: OnboardingProps) { const router = useRouter(); const [enteredName, setEnteredName] = React.useState(); const Sess = useSession(); const [ready, setReady] = useState(false); const [error, setError] = useState(null); const updateUser = async (data: 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: 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: 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; }; const integrationHandler = (type: string) => { if (type === "caldav_calendar") { setAddCalDavError(null); setIsAddCalDavIntegrationDialogOpen(true); return; } fetch("/api/integrations/" + type.replace("_", "") + "/add") .then((response) => response.json()) .then((data) => { window.location.href = data.url; }); }; /** Internal Components */ const IntegrationGridListItem = ({ integration }: { integration: Integration }) => { if (!integration || !integration.installed) { return null; } return (
  • integrationHandler(integration.type)} key={integration.type} className="flex py-4">
    {integration.title}
    {integration.title} {integration.description}
  • ); }; /** End Internal Components */ /** 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 */ /** CalDav Form */ const addCalDavIntegrationRef = useRef(null); const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false); const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null); const handleAddCalDavIntegration = async ({ url, username, password }: AddCalDavIntegrationRequest) => { const requestBody = JSON.stringify({ url, username, password, }); return await fetch("/api/integrations/caldav/add", { method: "POST", body: requestBody, headers: { "Content-Type": "application/json", }, }); }; const handleAddCalDavIntegrationSaveButtonPress = async () => { const form = addCalDavIntegrationRef.current.elements; const url = form.url.value; const password = form.password.value; const username = form.username.value; try { setAddCalDavError(null); const addCalDavIntegrationResponse = await handleAddCalDavIntegration({ username, password, url }); if (addCalDavIntegrationResponse.ok) { setIsAddCalDavIntegrationDialogOpen(false); incrementStep(); } else { const j = await addCalDavIntegrationResponse.json(); setAddCalDavError({ message: j.message }); } } catch (reason) { console.error(reason); } }; const ConnectCalDavServerDialog = () => { return ( setIsAddCalDavIntegrationDialogOpen(isOpen)}>
    {addCalDavError && (

    Error: {addCalDavError.message}

    )}
    { setIsAddCalDavIntegrationDialogOpen(false); }} asChild>
    ); }; /**End CalDav Form */ /** 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 { if ( steps[currentStep] && steps[currentStep]?.onComplete && typeof steps[currentStep]?.onComplete === "function" ) { await steps[currentStep].onComplete(); } incrementStep(); } catch (error) { console.log("handleConfirmStep", error); setError(error); } }; 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 () => { if (!props.eventTypes || props.eventTypes.length === 0) { Promise.all( DEFAULT_EVENT_TYPES.map(async (event) => { return await createEventType(event); }) ); } await updateUser({ completedOnboarding: true, }); router.push("/event-types"); }; const steps = [ { id: "welcome", title: "Welcome to Cal.com", description: "Tell us what to call you and let us know what timezone you’re in. You’ll be able to edit this later.", Component: (
    Current time:  {currentTime}
    ), hideConfirm: false, confirmText: "Continue", showCancel: true, cancelText: "Set up later", onComplete: async () => { try { await updateUser({ name: nameRef.current.value, timeZone: selectedTimeZone.value, }); setEnteredName(nameRef.current.value); } catch (error) { setError(error); } }, }, { id: "connect-calendar", title: "Connect your calendar", description: "Connect your calendar to automatically check for busy times and new events as they’re scheduled.", Component: ( ), hideConfirm: true, confirmText: "Continue", showCancel: true, cancelText: "Continue without calendar", }, { id: "set-availability", title: "Set your availability", description: "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.", Component: ( <>
    { try { await createSchedule({ freeBusyTimes: data, }); handleConfirmStep(); } catch (error) { setError(error); } }} />
    ), hideConfirm: true, showCancel: false, }, { id: "profile", title: "Nearly there", description: "Last thing, a brief description about you and a photo really help you get bookings and let people know who they’re booking with.", Component: (
    A few sentences about yourself. This will appear on your personal url page.
    ), hideConfirm: false, confirmText: "Finish", showCancel: true, cancelText: "Set up later", onComplete: async () => { try { await updateUser({ bio: bioRef.current.value, }); } catch (error) { setError(error); } }, }, ]; /** End Onboarding Steps */ useEffect(() => { detectStep(); setReady(true); }, []); if (Sess[1] || !ready) { return
    ; } return (
    Calendso - Getting Started
    {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 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, }, }); 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 = [ { installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), credential: credentials.find((integration) => integration.type === "google_calendar") || null, type: "google_calendar", title: "Google Calendar", imageSrc: "integrations/google-calendar.svg", description: "Gmail, G Suite", }, { installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), credential: credentials.find((integration) => integration.type === "office365_calendar") || null, type: "office365_calendar", title: "Office 365 Calendar", imageSrc: "integrations/outlook.svg", description: "Office 365, Outlook.com, live.com, or hotmail calendar", }, { installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET), credential: credentials.find((integration) => integration.type === "zoom_video") || null, type: "zoom_video", title: "Zoom", imageSrc: "integrations/zoom.svg", description: "Video Conferencing", }, { installed: true, credential: credentials.find((integration) => integration.type === "caldav_calendar") || null, type: "caldav_calendar", title: "Caldav", imageSrc: "integrations/caldav.svg", description: "CalDav Server", }, ]; 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: { user, integrations, eventTypes, schedules, }, }; }