diff --git a/components/ui/Schedule/Schedule.tsx b/components/ui/Schedule/Schedule.tsx new file mode 100644 index 0000000000..5d2f45b930 --- /dev/null +++ b/components/ui/Schedule/Schedule.tsx @@ -0,0 +1,337 @@ +import React from "react"; +import Text from "@components/ui/Text"; +import { PlusIcon, TrashIcon } from "@heroicons/react/outline"; +import dayjs, { Dayjs } from "dayjs"; +import classnames from "classnames"; +export const SCHEDULE_FORM_ID = "SCHEDULE_FORM_ID"; +export const toCalendsoAvailabilityFormat = (schedule: Schedule) => { + return schedule; +}; + +export const AM_PM_TIME_FORMAT = `h:mm:ss a`; +export const _24_HOUR_TIME_FORMAT = `HH:mm:ss`; + +const DEFAULT_START_TIME = "09:00:00"; +const DEFAULT_END_TIME = "17:00:00"; + +/** Begin Time Increments For Select */ +const increment = 15; + +/** + * Creates an array of times on a 15 minute interval from + * 00:00:00 (Start of day) to + * 23:45:00 (End of day with enough time for 15 min booking) + */ +const TIMES = (() => { + const starting_time = dayjs().startOf("day"); + const ending_time = dayjs().endOf("day"); + + const times = []; + let t: Dayjs = starting_time; + + while (t.isBefore(ending_time)) { + times.push(t.format(_24_HOUR_TIME_FORMAT)); + t = t.add(increment, "minutes"); + } + return times; +})(); +/** End Time Increments For Select */ + +const DEFAULT_SCHEDULE: Schedule = { + monday: [{ start: "09:00:00", end: "17:00:00" }], + tuesday: [{ start: "09:00:00", end: "17:00:00" }], + wednesday: [{ start: "09:00:00", end: "17:00:00" }], + thursday: [{ start: "09:00:00", end: "17:00:00" }], + friday: [{ start: "09:00:00", end: "17:00:00" }], + saturday: null, + sunday: null, +}; + +type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday"; +export type TimeRange = { + start: string; + end: string; +}; + +export type FreeBusyTime = TimeRange[]; + +export type Schedule = { + monday?: FreeBusyTime | null; + tuesday?: FreeBusyTime | null; + wednesday?: FreeBusyTime | null; + thursday?: FreeBusyTime | null; + friday?: FreeBusyTime | null; + saturday?: FreeBusyTime | null; + sunday?: FreeBusyTime | null; +}; + +type ScheduleBlockProps = { + day: DayOfWeek; + ranges?: FreeBusyTime | null; + selected?: boolean; +}; + +type Props = { + schedule?: Schedule; + onChange?: (data: Schedule) => void; + onSubmit: (data: Schedule) => void; +}; + +const SchedulerForm = ({ schedule = DEFAULT_SCHEDULE, onSubmit }: Props) => { + const ref = React.useRef(null); + + const transformElementsToSchedule = (elements: HTMLFormControlsCollection): Schedule => { + const schedule: Schedule = {}; + const formElements = Array.from(elements) + .map((element) => { + return element.id; + }) + .filter((value) => value); + + /** + * elementId either {day} or {day.N.start} or {day.N.end} + * If elementId in DAYS_ARRAY add elementId to scheduleObj + * then element is the checkbox and can be ignored + * + * If elementId starts with a day in DAYS_ARRAY + * the elementId should be split by "." resulting in array length 3 + * [day, rangeIndex, "start" | "end"] + */ + formElements.forEach((elementId) => { + const [day, rangeIndex, rangeId] = elementId.split("."); + if (rangeIndex && rangeId) { + if (!schedule[day]) { + schedule[day] = []; + } + + if (!schedule[day][parseInt(rangeIndex)]) { + schedule[day][parseInt(rangeIndex)] = {}; + } + + schedule[day][parseInt(rangeIndex)][rangeId] = elements[elementId].value; + } + }); + + return schedule; + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const elements = ref.current?.elements; + if (elements) { + const schedule = transformElementsToSchedule(elements); + onSubmit && typeof onSubmit === "function" && onSubmit(schedule); + } + }; + + const ScheduleBlock = ({ day, ranges: defaultRanges, selected: defaultSelected }: ScheduleBlockProps) => { + const [ranges, setRanges] = React.useState(defaultRanges); + const [selected, setSelected] = React.useState(defaultSelected); + React.useEffect(() => { + if (!ranges || ranges.length === 0) { + setSelected(false); + } else { + setSelected(true); + } + }, [ranges]); + + const handleSelectedChange = () => { + if (!selected && (!ranges || ranges.length === 0)) { + setRanges([ + { + start: "09:00:00", + end: "17:00:00", + }, + ]); + } + setSelected(!selected); + }; + + const handleAddRange = () => { + let rangeToAdd; + if (!ranges || ranges?.length === 0) { + rangeToAdd = { + start: DEFAULT_START_TIME, + end: DEFAULT_END_TIME, + }; + setRanges([rangeToAdd]); + } else { + const lastRange = ranges[ranges.length - 1]; + + const [hour, minute, second] = lastRange.end.split(":"); + const date = dayjs() + .set("hour", parseInt(hour)) + .set("minute", parseInt(minute)) + .set("second", parseInt(second)); + const nextStartTime = date.add(1, "hour"); + const nextEndTime = date.add(2, "hour"); + + /** + * If next range goes over into "tomorrow" + * i.e. time greater that last value in Times + * return + */ + if (nextStartTime.isAfter(date.endOf("day"))) { + return; + } + + rangeToAdd = { + start: nextStartTime.format(_24_HOUR_TIME_FORMAT), + end: nextEndTime.format(_24_HOUR_TIME_FORMAT), + }; + setRanges([...ranges, rangeToAdd]); + } + }; + + const handleDeleteRange = (range: TimeRange) => { + if (ranges && ranges.length > 0) { + setRanges( + ranges.filter((r: TimeRange) => { + return r.start != range.start; + }) + ); + } + }; + + /** + * Should update ranges values + */ + const handleSelectRangeChange = (event: React.ChangeEvent) => { + const [day, rangeIndex, rangeId] = event.currentTarget.name.split("."); + + if (day && ranges) { + const newRanges = ranges.map((range, index) => { + const newRange = { + ...range, + [rangeId]: event.currentTarget.value, + }; + return index === parseInt(rangeIndex) ? newRange : range; + }); + + setRanges(newRanges); + } + }; + + const TimeRangeField = ({ range, day, index }: { range: TimeRange; day: DayOfWeek; index: number }) => { + return ( +
+
+ + - + +
+
+ +
+
+ ); + }; + + const Actions = () => { + return ( +
+ +
+ ); + }; + + const DeleteAction = ({ range }: { range: TimeRange }) => { + return ( + + ); + }; + + return ( +
+
1 ? "sm:items-start" : "sm:items-center" + )}> +
+
+ + {day} +
+
+ +
+
+ +
+ {selected && ranges && ranges.length != 0 ? ( + ranges.map((range, index) => ( + + )) + ) : ( + + Unavailable + + )} +
+ +
+ +
+
+
+ ); + }; + + return ( + <> +
+ {Object.keys(schedule).map((day) => { + const selected = schedule[day as DayOfWeek] != null; + return ( + + ); + })} + + + ); +}; + +export default SchedulerForm; diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx index f043eff7e3..487b904be0 100644 --- a/components/ui/Scheduler.tsx +++ b/components/ui/Scheduler.tsx @@ -15,6 +15,7 @@ type Props = { timeZone: string; availability: Availability[]; setTimeZone: unknown; + setAvailability: unknown; }; export const Scheduler = ({ diff --git a/components/ui/Text/Body/Body.tsx b/components/ui/Text/Body/Body.tsx index 3dedad9578..1a0aaaa3ee 100644 --- a/components/ui/Text/Body/Body.tsx +++ b/components/ui/Text/Body/Body.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Body: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-lg leading-relaxed text-gray-900 dark:text-white"); + const classes = classnames("text-lg leading-relaxed text-gray-900 dark:text-white", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Body; diff --git a/components/ui/Text/Caption/Caption.tsx b/components/ui/Text/Caption/Caption.tsx index 95a340548d..62fd14b2c1 100644 --- a/components/ui/Text/Caption/Caption.tsx +++ b/components/ui/Text/Caption/Caption.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Caption: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-sm text-gray-500 dark:text-white leading-tight"); + const classes = classnames("text-sm text-gray-500 dark:text-white leading-tight", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Caption; diff --git a/components/ui/Text/Caption2/Caption2.tsx b/components/ui/Text/Caption2/Caption2.tsx index ffee176b96..7b1f6d4980 100644 --- a/components/ui/Text/Caption2/Caption2.tsx +++ b/components/ui/Text/Caption2/Caption2.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Caption2: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-xs italic text-gray-500 dark:text-white leading-tight"); + const classes = classnames("text-xs italic text-gray-500 dark:text-white leading-tight", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Caption2; diff --git a/components/ui/Text/Footnote/Footnote.tsx b/components/ui/Text/Footnote/Footnote.tsx index adf789be21..2af6d6525e 100644 --- a/components/ui/Text/Footnote/Footnote.tsx +++ b/components/ui/Text/Footnote/Footnote.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Footnote: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-base font-normal text-gray-900 dark:text-white"); + const classes = classnames("text-xs font-medium text-gray-500 dark:text-white", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Footnote; diff --git a/components/ui/Text/Headline/Headline.tsx b/components/ui/Text/Headline/Headline.tsx index 7b52dd0e98..d68da0b499 100644 --- a/components/ui/Text/Headline/Headline.tsx +++ b/components/ui/Text/Headline/Headline.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Headline: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-xl font-bold text-gray-900 dark:text-white"); + const classes = classnames("text-xl font-bold text-gray-900 dark:text-white", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Headline; diff --git a/components/ui/Text/Largetitle/Largetitle.tsx b/components/ui/Text/Largetitle/Largetitle.tsx index 0451cf4deb..4c6293d9de 100644 --- a/components/ui/Text/Largetitle/Largetitle.tsx +++ b/components/ui/Text/Largetitle/Largetitle.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Largetitle: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-2xl font-normal text-gray-900 dark:text-white"); + const classes = classnames("text-3xl font-extrabold text-gray-900 dark:text-white", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Largetitle; diff --git a/components/ui/Text/Overline/Overline.tsx b/components/ui/Text/Overline/Overline.tsx index 94196a7bff..8303e4ae0a 100644 --- a/components/ui/Text/Overline/Overline.tsx +++ b/components/ui/Text/Overline/Overline.tsx @@ -4,10 +4,11 @@ import { TextProps } from "../Text"; const Overline: React.FunctionComponent = (props: TextProps) => { const classes = classnames( - "text-sm uppercase font-semibold leading-snug tracking-wide text-gray-900 dark:text-white" + "text-sm uppercase font-semibold leading-snug tracking-wide text-gray-900 dark:text-white", + props?.className ); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Overline; diff --git a/components/ui/Text/Subheadline/Subheadline.tsx b/components/ui/Text/Subheadline/Subheadline.tsx index 535ac74ece..e0629fe7ed 100644 --- a/components/ui/Text/Subheadline/Subheadline.tsx +++ b/components/ui/Text/Subheadline/Subheadline.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Subheadline: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-xl text-gray-500 dark:text-white leading-relaxed"); + const classes = classnames("text-xl text-gray-500 dark:text-white leading-relaxed", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Subheadline; diff --git a/components/ui/Text/Subtitle/Subtitle.tsx b/components/ui/Text/Subtitle/Subtitle.tsx index 302d21f49f..de18d2d4e8 100644 --- a/components/ui/Text/Subtitle/Subtitle.tsx +++ b/components/ui/Text/Subtitle/Subtitle.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Subtitle: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("ext-sm text-neutral-500 dark:text-white"); + const classes = classnames("text-sm font-normal text-neutral-500 dark:text-white", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Subtitle; diff --git a/components/ui/Text/Text.tsx b/components/ui/Text/Text.tsx index eb3d189798..b92acc9e31 100644 --- a/components/ui/Text/Text.tsx +++ b/components/ui/Text/Text.tsx @@ -12,8 +12,6 @@ import Title from "./Title"; import Title2 from "./Title2"; import Title3 from "./Title3"; -import classnames from "classnames"; - type Props = { variant?: | "overline" @@ -32,14 +30,12 @@ type Props = { text?: string; tx?: string; className?: string; - color?: string; }; export type TextProps = { children: any; text?: string; tx?: string; - color?: string; className?: string; }; @@ -79,84 +75,82 @@ export type TextProps = { */ const Text: React.FunctionComponent = (props: Props) => { - const classes = classnames(props?.className, props?.color); - switch (props?.variant) { case "overline": return ( - + {props.children} ); case "body": return ( - + {props.children} ); case "caption": return ( - + {props.children} ); case "caption2": return ( - + {props.children} ); case "footnote": return ( - + {props.children} ); case "headline": return ( - + {props.children} ); case "largetitle": return ( - + {props.children} ); case "subheadline": return ( - + {props.children} ); case "subtitle": return ( - + {props.children} ); case "title": return ( - + <Title text={props?.text} tx={props?.tx} className={props?.className}> {props.children} ); case "title2": return ( - + {props.children} ); case "title3": return ( - + {props.children} ); default: return ( - + {props.children} ); diff --git a/components/ui/Text/Title/Title.tsx b/components/ui/Text/Title/Title.tsx index 1cfd91bb0c..c21e92f895 100644 --- a/components/ui/Text/Title/Title.tsx +++ b/components/ui/Text/Title/Title.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Title: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("font-medium text-neutral-900 dark:text-white"); + const classes = classnames("font-medium text-neutral-900 dark:text-white", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Title; diff --git a/components/ui/Text/Title2/Title2.tsx b/components/ui/Text/Title2/Title2.tsx index e2bcd121a6..2696e7c002 100644 --- a/components/ui/Text/Title2/Title2.tsx +++ b/components/ui/Text/Title2/Title2.tsx @@ -3,9 +3,9 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Title2: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-base font-normal text-gray-900 dark:text-white"); + const classes = classnames("text-base font-normal text-gray-900 dark:text-white", props?.className); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Title2; diff --git a/components/ui/Text/Title3/Title3.tsx b/components/ui/Text/Title3/Title3.tsx index 1b35651bdc..cf69495ff4 100644 --- a/components/ui/Text/Title3/Title3.tsx +++ b/components/ui/Text/Title3/Title3.tsx @@ -3,9 +3,12 @@ import classnames from "classnames"; import { TextProps } from "../Text"; const Title3: React.FunctionComponent = (props: TextProps) => { - const classes = classnames("text-xs font-semibold leading-tight text-gray-900 dark:text-white"); + const classes = classnames( + "text-xs font-semibold leading-tight text-gray-900 dark:text-white", + props?.className + ); - return

{props.children}

; + return

{props?.text || props.children}

; }; export default Title3; diff --git a/lib/integrations/CalDav/components/AddCalDavIntegration.tsx b/lib/integrations/CalDav/components/AddCalDavIntegration.tsx index 2275e01dd5..33a00f04e5 100644 --- a/lib/integrations/CalDav/components/AddCalDavIntegration.tsx +++ b/lib/integrations/CalDav/components/AddCalDavIntegration.tsx @@ -5,6 +5,11 @@ type Props = { }; export const ADD_CALDAV_INTEGRATION_FORM_TITLE = "addCalDav"; +export type AddCalDavIntegrationRequest = { + url: string; + username: string; + password: string; +}; const AddCalDavIntegration = React.forwardRef((props, ref) => { const onSubmit = (event) => { diff --git a/pages/api/schedule/index.ts b/pages/api/schedule/index.ts new file mode 100644 index 0000000000..8b3e088a0b --- /dev/null +++ b/pages/api/schedule/index.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getSession } from "next-auth/client"; +import prisma from "@lib/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getSession({ req: req }); + + if (!session) { + res.status(401).json({ message: "Not authenticated" }); + return; + } + + if (req.method === "POST") { + try { + const createdSchedule = await prisma.schedule.create({ + data: { + freeBusyTimes: req.body.data.freeBusyTimes, + user: { + connect: { + id: session.user.id, + }, + }, + }, + }); + + return res.status(200).json({ + message: "created", + data: createdSchedule, + }); + } catch (error) { + console.error(error); + return res.status(500).json({ message: "Unable to create schedule." }); + } + } +} diff --git a/pages/api/user/[id].ts b/pages/api/user/[id].ts new file mode 100644 index 0000000000..5eed37fdee --- /dev/null +++ b/pages/api/user/[id].ts @@ -0,0 +1,48 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from "@lib/prisma"; +import { getSession } from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getSession({ req: req }); + + if (!session) { + return res.status(401).json({ message: "Not authenticated" }); + } + + const userIdQuery = req.query?.id ?? null; + const userId = Array.isArray(userIdQuery) ? parseInt(userIdQuery.pop()) : parseInt(userIdQuery); + + const authenticatedUser = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + }, + }); + + if (userId !== authenticatedUser.id) { + return res.status(401).json({ message: "Unauthorized" }); + } + + if (req.method === "GET") { + return res.status(405).json({ message: "Method Not Allowed" }); + } + + if (req.method === "DELETE") { + return res.status(405).json({ message: "Method Not Allowed" }); + } + + if (req.method === "PATCH") { + const data = req.body.data; + const updatedUser = await prisma.user.update({ + where: { + id: authenticatedUser.id, + }, + data: { + ...data, + }, + }); + return res.status(200).json({ message: "User Updated", data: updatedUser }); + } +} diff --git a/pages/event-types/index.tsx b/pages/event-types/index.tsx index 75c4431687..38d3a6fc3d 100644 --- a/pages/event-types/index.tsx +++ b/pages/event-types/index.tsx @@ -14,6 +14,7 @@ import { } from "@heroicons/react/solid"; import classNames from "@lib/classNames"; import showToast from "@lib/notification"; +import dayjs from "dayjs"; import { getSession, useSession } from "next-auth/client"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -23,6 +24,7 @@ import prisma from "@lib/prisma"; import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next"; import { useMutation } from "react-query"; import createEventType from "@lib/mutations/event-types/create-event-type"; +import { ONBOARDING_INTRODUCED_AT } from "../getting_started/index"; const EventTypesPage = (props: InferGetServerSidePropsType) => { const { user, types } = props; @@ -657,9 +659,20 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => startTime: true, endTime: true, bufferTime: true, + completedOnboarding: true, + createdDate: true, }, }); + if (!user.completedOnboarding && dayjs(user.createdDate).isBefore(ONBOARDING_INTRODUCED_AT)) { + return { + redirect: { + permanent: false, + destination: "/getting_started", + }, + }; + } + const types = await prisma.eventType.findMany({ where: { userId: user.id, @@ -674,9 +687,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, }); + const userObj = Object.assign({}, user, { + createdDate: user.createdDate.toString(), + }); + return { props: { - user, + user: userObj, types, }, }; diff --git a/pages/getting_started/index.tsx b/pages/getting_started/index.tsx new file mode 100644 index 0000000000..343f538d9e --- /dev/null +++ b/pages/getting_started/index.tsx @@ -0,0 +1,724 @@ +import Head from "next/head"; +import prisma from "@lib/prisma"; +import { getSession, 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 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"; + +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, + }, +]; + +export const ONBOARDING_INTRODUCED_AT = dayjs("September 1 2021").toISOString(); + +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); + }} + as="button" + className="btn btn-white mx-2"> + Cancel + +
    +
    +
    + ); + }; + /**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 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: ( +
      + {props.integrations.map((integration) => { + return ; + })} +
    + ), + 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} + + +
    + {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 && ( +
    + +
    + )} +
    +
    +
    + + +
    +
    +
    +
    + +
    + ); +} + +export async function getServerSideProps(context: NextPageContext) { + const session = await getSession(context); + + let user: User = null; + let integrations = []; + let credentials = []; + let eventTypes = []; + let schedules = []; + + if (session) { + user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + startTime: true, + endTime: true, + username: true, + name: true, + email: true, + bio: true, + avatar: true, + timeZone: true, + completedOnboarding: true, + }, + }); + + 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, + }, + }; +} diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 3c63c3181a..258b9c5a0b 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -12,7 +12,7 @@ import AddCalDavIntegration, { ADD_CALDAV_INTEGRATION_FORM_TITLE, } from "@lib/integrations/CalDav/components/AddCalDavIntegration"; -type Integration = { +export type Integration = { installed: boolean; credential: unknown; type: string; diff --git a/prisma/migrations/20210825004801_schedule_schema/migration.sql b/prisma/migrations/20210825004801_schedule_schema/migration.sql new file mode 100644 index 0000000000..e87848bc3a --- /dev/null +++ b/prisma/migrations/20210825004801_schedule_schema/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "completedOnboarding" BOOLEAN DEFAULT false; + +-- CreateTable +CREATE TABLE "Schedule" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "eventTypeId" INTEGER, + "title" TEXT, + "freeBusyTimes" JSONB, + + PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Schedule" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schedule" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78e3b91c38..eb9ea280e9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,69 +11,72 @@ generator client { } model EventType { - id Int @default(autoincrement()) @id - title String - slug String - description String? - locations Json? - length Int - hidden Boolean @default(false) - user User? @relation(fields: [userId], references: [id]) - userId Int? - bookings Booking[] - availability Availability[] - eventName String? - customInputs EventTypeCustomInput[] - timeZone String? - periodType String? @default("unlimited") // unlimited | rolling | range - periodStartDate DateTime? - periodEndDate DateTime? - periodDays Int? + id Int @id @default(autoincrement()) + title String + slug String + description String? + locations Json? + length Int + hidden Boolean @default(false) + user User? @relation(fields: [userId], references: [id]) + userId Int? + bookings Booking[] + availability Availability[] + eventName String? + customInputs EventTypeCustomInput[] + timeZone String? + periodType String? @default("unlimited") // unlimited | rolling | range + periodStartDate DateTime? + periodEndDate DateTime? + periodDays Int? periodCountCalendarDays Boolean? - requiresConfirmation Boolean @default(false) - minimumBookingNotice Int @default(120) + requiresConfirmation Boolean @default(false) + minimumBookingNotice Int @default(120) + Schedule Schedule[] } model Credential { - id Int @default(autoincrement()) @id - type String - key Json - user User? @relation(fields: [userId], references: [id]) - userId Int? + id Int @id @default(autoincrement()) + type String + key Json + user User? @relation(fields: [userId], references: [id]) + userId Int? } model User { - id Int @default(autoincrement()) @id - username String? - name String? - email String? @unique - emailVerified DateTime? - password String? - bio String? - avatar String? - timeZone String @default("Europe/London") - weekStart String? @default("Sunday") - startTime Int @default(0) - endTime Int @default(1440) - bufferTime Int @default(0) - hideBranding Boolean @default(false) - theme String? - createdDate DateTime @default(now()) @map(name: "created") - eventTypes EventType[] - credentials Credential[] - teams Membership[] - bookings Booking[] - availability Availability[] - selectedCalendars SelectedCalendar[] + id Int @id @default(autoincrement()) + username String? + name String? + email String? @unique + emailVerified DateTime? + password String? + bio String? + avatar String? + timeZone String @default("Europe/London") + weekStart String? @default("Sunday") + startTime Int @default(0) + endTime Int @default(1440) + bufferTime Int @default(0) + hideBranding Boolean @default(false) + theme String? + createdDate DateTime @default(now()) @map(name: "created") + eventTypes EventType[] + credentials Credential[] + teams Membership[] + bookings Booking[] + availability Availability[] + selectedCalendars SelectedCalendar[] + completedOnboarding Boolean? @default(false) + Schedule Schedule[] @@map(name: "users") } model Team { - id Int @default(autoincrement()) @id - name String? - slug String? - members Membership[] + id Int @id @default(autoincrement()) + name String? + slug String? + members Membership[] } enum MembershipRole { @@ -82,98 +85,110 @@ enum MembershipRole { } model Membership { - teamId Int - userId Int - accepted Boolean @default(false) - role MembershipRole - team Team @relation(fields: [teamId], references: [id]) - user User @relation(fields: [userId], references: [id]) + teamId Int + userId Int + accepted Boolean @default(false) + role MembershipRole + team Team @relation(fields: [teamId], references: [id]) + user User @relation(fields: [userId], references: [id]) - @@id([userId,teamId]) + @@id([userId, teamId]) } model VerificationRequest { - id Int @default(autoincrement()) @id + id Int @id @default(autoincrement()) identifier String token String @unique expires DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@unique([identifier, token]) } model BookingReference { - id Int @default(autoincrement()) @id - type String - uid String - booking Booking? @relation(fields: [bookingId], references: [id]) - bookingId Int? + id Int @id @default(autoincrement()) + type String + uid String + booking Booking? @relation(fields: [bookingId], references: [id]) + bookingId Int? } model Attendee { - id Int @default(autoincrement()) @id - email String - name String - timeZone String - booking Booking? @relation(fields: [bookingId], references: [id]) - bookingId Int? + id Int @id @default(autoincrement()) + email String + name String + timeZone String + booking Booking? @relation(fields: [bookingId], references: [id]) + bookingId Int? } model Booking { - id Int @default(autoincrement()) @id - uid String @unique - user User? @relation(fields: [userId], references: [id]) - userId Int? - references BookingReference[] - eventType EventType? @relation(fields: [eventTypeId], references: [id]) - eventTypeId Int? + id Int @id @default(autoincrement()) + uid String @unique + user User? @relation(fields: [userId], references: [id]) + userId Int? + references BookingReference[] + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? - title String - description String? - startTime DateTime - endTime DateTime + title String + description String? + startTime DateTime + endTime DateTime - attendees Attendee[] - location String? + attendees Attendee[] + location String? - createdAt DateTime @default(now()) - updatedAt DateTime? - confirmed Boolean @default(true) - rejected Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime? + confirmed Boolean @default(true) + rejected Boolean @default(false) } -model Availability { - id Int @default(autoincrement()) @id - label String? +model Schedule { + id Int @id @default(autoincrement()) user User? @relation(fields: [userId], references: [id]) userId Int? eventType EventType? @relation(fields: [eventTypeId], references: [id]) eventTypeId Int? - days Int[] - startTime Int - endTime Int - date DateTime? @db.Date + title String? + freeBusyTimes Json? +} + +model Availability { + id Int @id @default(autoincrement()) + label String? + user User? @relation(fields: [userId], references: [id]) + userId Int? + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + days Int[] + startTime Int + endTime Int + date DateTime? @db.Date } model SelectedCalendar { - user User @relation(fields: [userId], references: [id]) - userId Int - integration String - externalId String - @@id([userId,integration,externalId]) + user User @relation(fields: [userId], references: [id]) + userId Int + integration String + externalId String + + @@id([userId, integration, externalId]) } enum EventTypeCustomInputType { - TEXT @map("text") - TEXTLONG @map("textLong") - NUMBER @map("number") - BOOL @map("bool") + TEXT @map("text") + TEXTLONG @map("textLong") + NUMBER @map("number") + BOOL @map("bool") } model EventTypeCustomInput { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) eventTypeId Int - eventType EventType @relation(fields: [eventTypeId], references: [id]) + eventType EventType @relation(fields: [eventTypeId], references: [id]) label String type EventTypeCustomInputType required Boolean @@ -181,11 +196,11 @@ model EventTypeCustomInput { } model ResetPasswordRequest { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - email String - expires DateTime + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String + expires DateTime } enum ReminderType { @@ -193,9 +208,9 @@ enum ReminderType { } model ReminderMail { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) referenceId Int reminderType ReminderType elapsedMinutes Int - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) } diff --git a/yarn.lock b/yarn.lock index e13836cfd2..0b121bc1b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5762,7 +5762,8 @@ tsdav@^1.0.6: tslib@2.0.1: version "2.0.1" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e" + integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ== tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1"