cal.pub0.org/pages/getting-started.tsx

734 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import Head from "next/head";
import prisma from "@lib/prisma";
import { useSession } from "next-auth/client";
import {
EventTypeCreateInput,
ScheduleCreateInput,
User,
UserUpdateInput,
EventType,
Schedule,
} from "@prisma/client";
import { NextPageContext } from "next";
import React, { useState, useEffect, useRef } 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";
dayjs.extend(utc);
dayjs.extend(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";
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<string, string>[];
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 (
<li onClick={() => integrationHandler(integration.type)} key={integration.type} className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
</div>
<div className="w-10/12">
<Text className="text-gray-800 font-medium">{integration.title}</Text>
<Text className="text-gray-400" variant="subtitle">
{integration.description}
</Text>
</div>
<div className="w-2/12 text-right pt-2">
<button
onClick={() => integrationHandler(integration.type)}
className="font-medium text-neutral-900 hover:text-neutral-500 border px-4 py-2 border-gray-200 rounded-sm">
Connect
</button>
</div>
</li>
);
};
/** 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<HTMLFormElement>(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 (
<Dialog
open={isAddCalDavIntegrationDialogOpen}
onOpenChange={(isOpen) => setIsAddCalDavIntegrationDialogOpen(isOpen)}>
<DialogContent>
<DialogHeader
title="Connect to CalDav Server"
subtitle="Your credentials will be stored and encrypted."
/>
<div className="my-4">
{addCalDavError && (
<p className="text-red-700 text-sm">
<span className="font-bold">Error: </span>
{addCalDavError.message}
</p>
)}
<AddCalDavIntegration
ref={addCalDavIntegrationRef}
onSubmit={handleAddCalDavIntegrationSaveButtonPress}
/>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="submit"
form={ADD_CALDAV_INTEGRATION_FORM_TITLE}
className="flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
Save
</button>
<DialogClose
onClick={() => {
setIsAddCalDavIntegrationDialogOpen(false);
}}
as="button"
className="btn btn-white mx-2">
Cancel
</DialogClose>
</div>
</DialogContent>
</Dialog>
);
};
/**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 Calendso",
description:
"Tell us what to call you and let us know what timezone youre in. Youll be able to edit this later.",
Component: (
<form className="sm:mx-auto sm:w-full sm:max-w-md">
<section className="space-y-4">
<fieldset>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Full name
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
autoComplete="given-name"
placeholder="Your name"
defaultValue={props.user.name}
required
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
</fieldset>
<fieldset>
<section className="flex justify-between">
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
Timezone
</label>
<Text variant="caption">
Current time:&nbsp;
<span className="text-black">{currentTime}</span>
</Text>
</section>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={setSelectedTimeZone}
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</fieldset>
</section>
</form>
),
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 theyre scheduled.",
Component: (
<ul className="divide-y divide-gray-200 sm:mx-auto sm:w-full sm:max-w-md">
{props.integrations.map((integration) => {
return <IntegrationGridListItem key={integration.type} integration={integration} />;
})}
</ul>
),
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: (
<>
<section className="bg-white dark:bg-opacity-5 text-black dark:text-white mx-auto max-w-lg">
<SchedulerForm
onSubmit={async (data) => {
try {
await createSchedule({
freeBusyTimes: data,
});
handleConfirmStep();
} catch (error) {
setError(error);
}
}}
/>
</section>
<footer className="py-6 sm:mx-auto sm:w-full sm:max-w-md flex flex-col space-y-6">
<button
type="submit"
form={SCHEDULE_FORM_ID}
className="w-full btn btn-primary text-center justify-center space-x-2">
<Text variant="subtitle" className="text-white">
Continue
</Text>
<ArrowRightIcon className="text-white h-4 w-4" />
</button>
</footer>
</>
),
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 theyre booking with.",
Component: (
<form className="sm:mx-auto sm:w-full sm:max-w-md" id="ONBOARDING_STEP_4">
<section className="space-y-4">
<fieldset>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Full name
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
autoComplete="given-name"
placeholder="Your name"
defaultValue={props.user.name || enteredName}
required
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
</fieldset>
<fieldset>
<label htmlFor="bio" className="block text-sm font-medium text-gray-700">
About
</label>
<input
ref={bioRef}
type="text"
name="bio"
id="bio"
required
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={props.user.bio}
/>
<Text variant="caption">
A few sentences about yourself. This will appear on your personal url page.
</Text>
</fieldset>
</section>
</form>
),
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 <div className="loader"></div>;
}
return (
<div className="bg-black min-h-screen">
<Head>
<title>Calendso - Getting Started</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto py-24 px-4">
<article>
<section className="sm:mx-auto sm:w-full sm:max-w-md space-y-4">
<header className="">
<Text className="text-white" variant="largetitle">
{steps[currentStep].title}
</Text>
<Text className="text-white" variant="subtitle">
{steps[currentStep].description}
</Text>
</header>
<section className="space-y-2">
<Text variant="footnote">
Step {currentStep + 1} of {steps.length}
</Text>
{error && <ErrorAlert {...error} />}
<section className="w-full space-x-2 flex">
{steps.map((s, index) => {
return index <= currentStep ? (
<div
key={`step-${index}`}
onClick={() => goToStep(index)}
className={classnames(
"h-1 bg-white w-1/4",
index < currentStep ? "cursor-pointer" : ""
)}></div>
) : (
<div key={`step-${index}`} className="h-1 bg-white bg-opacity-25 w-1/4"></div>
);
})}
</section>
</section>
</section>
<section className="py-6 mt-10 mx-auto max-w-xl bg-white p-10 rounded-sm">
{steps[currentStep].Component}
{!steps[currentStep].hideConfirm && (
<footer className="py-6 sm:mx-auto sm:w-full sm:max-w-md flex flex-col space-y-6 mt-8">
<button
onClick={handleConfirmStep}
type="button"
className="w-full btn btn-primary text-center justify-center space-x-2">
<Text variant="subtitle" className="text-white">
{steps[currentStep].confirmText}
</Text>
<ArrowRightIcon className="text-white h-4 w-4" />
</button>
</footer>
)}
</section>
<section className="py-6 mt-8 mx-auto max-w-xl">
<div className="flex justify-between">
<button onClick={decrementStep}>
<Text variant="caption">Prev Step</Text>
</button>
<button onClick={handleSkipStep}>
<Text variant="caption">Skip Step</Text>
</button>
</div>
</section>
</article>
</div>
<ConnectCalDavServerDialog />
</div>
);
}
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",
} as const,
};
}
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,
},
};
}