Onboarding Flow (#503)
* wip * wip * db: schedule schema * fix adding time goes into new day fix adding new time not keeping updating ranges fix updating ranges not maintaining changed values * remove photo upload * remove unused code * remove more unused code * undo time lib * didnt actually change this * dont show onboardi ng flow for users created before sept 1 2021 * use more consistent max-widths * align all inputs in scheduler component * allow overriding of default styles * match figma designs implement goto previous step * add more types, match figma Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>pull/552/head
parent
f63c9311e5
commit
2d23a8b7db
|
@ -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<HTMLFormElement>(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<HTMLFormElement>) => {
|
||||||
|
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<HTMLSelectElement>) => {
|
||||||
|
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 (
|
||||||
|
<div key={`${day}-range-${index}`} className="flex items-center justify-between space-x-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<select
|
||||||
|
id={`${day}.${index}.start`}
|
||||||
|
name={`${day}.${index}.start`}
|
||||||
|
defaultValue={range?.start || DEFAULT_START_TIME}
|
||||||
|
onChange={handleSelectRangeChange}
|
||||||
|
className="block px-4 pr-8 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-sm">
|
||||||
|
{TIMES.map((time) => {
|
||||||
|
return (
|
||||||
|
<option key={`${day}.${index}.start.${time}`} value={time}>
|
||||||
|
{time}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<Text>-</Text>
|
||||||
|
<select
|
||||||
|
id={`${day}.${index}.end`}
|
||||||
|
name={`${day}.${index}.end`}
|
||||||
|
defaultValue={range?.end || DEFAULT_END_TIME}
|
||||||
|
onChange={handleSelectRangeChange}
|
||||||
|
className=" block px-4 pr-8 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-sm">
|
||||||
|
{TIMES.map((time) => {
|
||||||
|
return (
|
||||||
|
<option key={`${day}.${index}.end.${time}`} value={time}>
|
||||||
|
{time}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="">
|
||||||
|
<DeleteAction range={range} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Actions = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button type="button" onClick={() => handleAddRange()}>
|
||||||
|
<PlusIcon className="h-5 w-5 text-neutral-400 hover:text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteAction = ({ range }: { range: TimeRange }) => {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => handleDeleteRange(range)}>
|
||||||
|
<TrashIcon className="h-5 w-5 text-neutral-400 hover:text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className=" py-6">
|
||||||
|
<section
|
||||||
|
className={classnames(
|
||||||
|
"flex flex-col space-y-6 sm:space-y-0 sm:flex-row sm:justify-between",
|
||||||
|
ranges && ranges?.length > 1 ? "sm:items-start" : "sm:items-center"
|
||||||
|
)}>
|
||||||
|
<div style={{ minWidth: "33%" }} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2 ">
|
||||||
|
<input
|
||||||
|
id={day}
|
||||||
|
name={day}
|
||||||
|
checked={selected}
|
||||||
|
onChange={handleSelectedChange}
|
||||||
|
type="checkbox"
|
||||||
|
className="focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm"
|
||||||
|
/>
|
||||||
|
<Text variant="overline">{day}</Text>
|
||||||
|
</div>
|
||||||
|
<div className="sm:hidden justify-self-end self-end">
|
||||||
|
<Actions />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 w-full">
|
||||||
|
{selected && ranges && ranges.length != 0 ? (
|
||||||
|
ranges.map((range, index) => (
|
||||||
|
<TimeRangeField key={`${day}-range-${index}`} range={range} index={index} day={day} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text key={`${day}`} variant="caption">
|
||||||
|
Unavailable
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:block px-2">
|
||||||
|
<Actions />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form id={SCHEDULE_FORM_ID} onSubmit={handleSubmit} ref={ref} className="divide-y divide-gray-200">
|
||||||
|
{Object.keys(schedule).map((day) => {
|
||||||
|
const selected = schedule[day as DayOfWeek] != null;
|
||||||
|
return (
|
||||||
|
<ScheduleBlock
|
||||||
|
key={`${day}`}
|
||||||
|
day={day as DayOfWeek}
|
||||||
|
ranges={schedule[day as DayOfWeek]}
|
||||||
|
selected={selected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SchedulerForm;
|
|
@ -15,6 +15,7 @@ type Props = {
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
availability: Availability[];
|
availability: Availability[];
|
||||||
setTimeZone: unknown;
|
setTimeZone: unknown;
|
||||||
|
setAvailability: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Scheduler = ({
|
export const Scheduler = ({
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Body: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Body: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Body;
|
export default Body;
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Caption: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Caption: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Caption;
|
export default Caption;
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Caption2: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Caption2: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Caption2;
|
export default Caption2;
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Footnote: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Footnote: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Footnote;
|
export default Footnote;
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Headline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Headline: React.FunctionComponent<TextProps> = (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 <h1 className={classes}>{props.children}</h1>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Headline;
|
export default Headline;
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Largetitle: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Largetitle: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Largetitle;
|
export default Largetitle;
|
||||||
|
|
|
@ -4,10 +4,11 @@ import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Overline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Overline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||||
const classes = classnames(
|
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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Overline;
|
export default Overline;
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Subheadline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Subheadline: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Subheadline;
|
export default Subheadline;
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Subtitle: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Subtitle: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Subtitle;
|
export default Subtitle;
|
||||||
|
|
|
@ -12,8 +12,6 @@ import Title from "./Title";
|
||||||
import Title2 from "./Title2";
|
import Title2 from "./Title2";
|
||||||
import Title3 from "./Title3";
|
import Title3 from "./Title3";
|
||||||
|
|
||||||
import classnames from "classnames";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
variant?:
|
variant?:
|
||||||
| "overline"
|
| "overline"
|
||||||
|
@ -32,14 +30,12 @@ type Props = {
|
||||||
text?: string;
|
text?: string;
|
||||||
tx?: string;
|
tx?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
color?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TextProps = {
|
export type TextProps = {
|
||||||
children: any;
|
children: any;
|
||||||
text?: string;
|
text?: string;
|
||||||
tx?: string;
|
tx?: string;
|
||||||
color?: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -79,84 +75,82 @@ export type TextProps = {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const Text: React.FunctionComponent<Props> = (props: Props) => {
|
const Text: React.FunctionComponent<Props> = (props: Props) => {
|
||||||
const classes = classnames(props?.className, props?.color);
|
|
||||||
|
|
||||||
switch (props?.variant) {
|
switch (props?.variant) {
|
||||||
case "overline":
|
case "overline":
|
||||||
return (
|
return (
|
||||||
<Overline className={classes} {...props}>
|
<Overline text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Overline>
|
</Overline>
|
||||||
);
|
);
|
||||||
case "body":
|
case "body":
|
||||||
return (
|
return (
|
||||||
<Body className={classes} {...props}>
|
<Body text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Body>
|
</Body>
|
||||||
);
|
);
|
||||||
case "caption":
|
case "caption":
|
||||||
return (
|
return (
|
||||||
<Caption className={classes} {...props}>
|
<Caption text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Caption>
|
</Caption>
|
||||||
);
|
);
|
||||||
case "caption2":
|
case "caption2":
|
||||||
return (
|
return (
|
||||||
<Caption2 className={classes} {...props}>
|
<Caption2 text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Caption2>
|
</Caption2>
|
||||||
);
|
);
|
||||||
case "footnote":
|
case "footnote":
|
||||||
return (
|
return (
|
||||||
<Footnote className={classes} {...props}>
|
<Footnote text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Footnote>
|
</Footnote>
|
||||||
);
|
);
|
||||||
case "headline":
|
case "headline":
|
||||||
return (
|
return (
|
||||||
<Headline className={classes} {...props}>
|
<Headline text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Headline>
|
</Headline>
|
||||||
);
|
);
|
||||||
case "largetitle":
|
case "largetitle":
|
||||||
return (
|
return (
|
||||||
<Largetitle className={classes} {...props}>
|
<Largetitle text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Largetitle>
|
</Largetitle>
|
||||||
);
|
);
|
||||||
case "subheadline":
|
case "subheadline":
|
||||||
return (
|
return (
|
||||||
<Subheadline className={classes} {...props}>
|
<Subheadline text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Subheadline>
|
</Subheadline>
|
||||||
);
|
);
|
||||||
case "subtitle":
|
case "subtitle":
|
||||||
return (
|
return (
|
||||||
<Subtitle className={classes} {...props}>
|
<Subtitle text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
);
|
);
|
||||||
case "title":
|
case "title":
|
||||||
return (
|
return (
|
||||||
<Title className={classes} {...props}>
|
<Title text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Title>
|
</Title>
|
||||||
);
|
);
|
||||||
case "title2":
|
case "title2":
|
||||||
return (
|
return (
|
||||||
<Title2 className={classes} {...props}>
|
<Title2 text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Title2>
|
</Title2>
|
||||||
);
|
);
|
||||||
case "title3":
|
case "title3":
|
||||||
return (
|
return (
|
||||||
<Title3 className={classes} {...props}>
|
<Title3 text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Title3>
|
</Title3>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Body className={classes} {...props}>
|
<Body text={props?.text} tx={props?.tx} className={props?.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Body>
|
</Body>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Title: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Title: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Title;
|
export default Title;
|
||||||
|
|
|
@ -3,9 +3,9 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Title2: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Title2: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Title2;
|
export default Title2;
|
||||||
|
|
|
@ -3,9 +3,12 @@ import classnames from "classnames";
|
||||||
import { TextProps } from "../Text";
|
import { TextProps } from "../Text";
|
||||||
|
|
||||||
const Title3: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
const Title3: React.FunctionComponent<TextProps> = (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 <p className={classes}>{props.children}</p>;
|
return <p className={classes}>{props?.text || props.children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Title3;
|
export default Title3;
|
||||||
|
|
|
@ -5,6 +5,11 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ADD_CALDAV_INTEGRATION_FORM_TITLE = "addCalDav";
|
export const ADD_CALDAV_INTEGRATION_FORM_TITLE = "addCalDav";
|
||||||
|
export type AddCalDavIntegrationRequest = {
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
||||||
const onSubmit = (event) => {
|
const onSubmit = (event) => {
|
||||||
|
|
|
@ -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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import showToast from "@lib/notification";
|
import showToast from "@lib/notification";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { getSession, useSession } from "next-auth/client";
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
@ -23,6 +24,7 @@ import prisma from "@lib/prisma";
|
||||||
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
|
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import createEventType from "@lib/mutations/event-types/create-event-type";
|
import createEventType from "@lib/mutations/event-types/create-event-type";
|
||||||
|
import { ONBOARDING_INTRODUCED_AT } from "../getting_started/index";
|
||||||
|
|
||||||
const EventTypesPage = (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {
|
const EventTypesPage = (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {
|
||||||
const { user, types } = props;
|
const { user, types } = props;
|
||||||
|
@ -657,9 +659,20 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
bufferTime: 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({
|
const types = await prisma.eventType.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
@ -674,9 +687,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userObj = Object.assign({}, user, {
|
||||||
|
createdDate: user.createdDate.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
user,
|
user: userObj,
|
||||||
types,
|
types,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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<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 you’re in. You’ll 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:
|
||||||
|
<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 they’re 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 they’re 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>
|
||||||
|
|
||||||
|
<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 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ import AddCalDavIntegration, {
|
||||||
ADD_CALDAV_INTEGRATION_FORM_TITLE,
|
ADD_CALDAV_INTEGRATION_FORM_TITLE,
|
||||||
} from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
} from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
||||||
|
|
||||||
type Integration = {
|
export type Integration = {
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
credential: unknown;
|
credential: unknown;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
|
@ -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;
|
|
@ -11,69 +11,72 @@ generator client {
|
||||||
}
|
}
|
||||||
|
|
||||||
model EventType {
|
model EventType {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title String
|
||||||
slug String
|
slug String
|
||||||
description String?
|
description String?
|
||||||
locations Json?
|
locations Json?
|
||||||
length Int
|
length Int
|
||||||
hidden Boolean @default(false)
|
hidden Boolean @default(false)
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId Int?
|
userId Int?
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
availability Availability[]
|
availability Availability[]
|
||||||
eventName String?
|
eventName String?
|
||||||
customInputs EventTypeCustomInput[]
|
customInputs EventTypeCustomInput[]
|
||||||
timeZone String?
|
timeZone String?
|
||||||
periodType String? @default("unlimited") // unlimited | rolling | range
|
periodType String? @default("unlimited") // unlimited | rolling | range
|
||||||
periodStartDate DateTime?
|
periodStartDate DateTime?
|
||||||
periodEndDate DateTime?
|
periodEndDate DateTime?
|
||||||
periodDays Int?
|
periodDays Int?
|
||||||
periodCountCalendarDays Boolean?
|
periodCountCalendarDays Boolean?
|
||||||
requiresConfirmation Boolean @default(false)
|
requiresConfirmation Boolean @default(false)
|
||||||
minimumBookingNotice Int @default(120)
|
minimumBookingNotice Int @default(120)
|
||||||
|
Schedule Schedule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Credential {
|
model Credential {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
type String
|
type String
|
||||||
key Json
|
key Json
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId Int?
|
userId Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
username String?
|
username String?
|
||||||
name String?
|
name String?
|
||||||
email String? @unique
|
email String? @unique
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
password String?
|
password String?
|
||||||
bio String?
|
bio String?
|
||||||
avatar String?
|
avatar String?
|
||||||
timeZone String @default("Europe/London")
|
timeZone String @default("Europe/London")
|
||||||
weekStart String? @default("Sunday")
|
weekStart String? @default("Sunday")
|
||||||
startTime Int @default(0)
|
startTime Int @default(0)
|
||||||
endTime Int @default(1440)
|
endTime Int @default(1440)
|
||||||
bufferTime Int @default(0)
|
bufferTime Int @default(0)
|
||||||
hideBranding Boolean @default(false)
|
hideBranding Boolean @default(false)
|
||||||
theme String?
|
theme String?
|
||||||
createdDate DateTime @default(now()) @map(name: "created")
|
createdDate DateTime @default(now()) @map(name: "created")
|
||||||
eventTypes EventType[]
|
eventTypes EventType[]
|
||||||
credentials Credential[]
|
credentials Credential[]
|
||||||
teams Membership[]
|
teams Membership[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
availability Availability[]
|
availability Availability[]
|
||||||
selectedCalendars SelectedCalendar[]
|
selectedCalendars SelectedCalendar[]
|
||||||
|
completedOnboarding Boolean? @default(false)
|
||||||
|
|
||||||
|
Schedule Schedule[]
|
||||||
@@map(name: "users")
|
@@map(name: "users")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
name String?
|
name String?
|
||||||
slug String?
|
slug String?
|
||||||
members Membership[]
|
members Membership[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MembershipRole {
|
enum MembershipRole {
|
||||||
|
@ -82,98 +85,110 @@ enum MembershipRole {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Membership {
|
model Membership {
|
||||||
teamId Int
|
teamId Int
|
||||||
userId Int
|
userId Int
|
||||||
accepted Boolean @default(false)
|
accepted Boolean @default(false)
|
||||||
role MembershipRole
|
role MembershipRole
|
||||||
team Team @relation(fields: [teamId], references: [id])
|
team Team @relation(fields: [teamId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@id([userId,teamId])
|
@@id([userId, teamId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model VerificationRequest {
|
model VerificationRequest {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
identifier String
|
identifier String
|
||||||
token String @unique
|
token String @unique
|
||||||
expires DateTime
|
expires DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([identifier, token])
|
@@unique([identifier, token])
|
||||||
}
|
}
|
||||||
|
|
||||||
model BookingReference {
|
model BookingReference {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
type String
|
type String
|
||||||
uid String
|
uid String
|
||||||
booking Booking? @relation(fields: [bookingId], references: [id])
|
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||||
bookingId Int?
|
bookingId Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Attendee {
|
model Attendee {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
email String
|
email String
|
||||||
name String
|
name String
|
||||||
timeZone String
|
timeZone String
|
||||||
booking Booking? @relation(fields: [bookingId], references: [id])
|
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||||
bookingId Int?
|
bookingId Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Booking {
|
model Booking {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
uid String @unique
|
uid String @unique
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId Int?
|
userId Int?
|
||||||
references BookingReference[]
|
references BookingReference[]
|
||||||
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||||
eventTypeId Int?
|
eventTypeId Int?
|
||||||
|
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
startTime DateTime
|
startTime DateTime
|
||||||
endTime DateTime
|
endTime DateTime
|
||||||
|
|
||||||
attendees Attendee[]
|
attendees Attendee[]
|
||||||
location String?
|
location String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime?
|
updatedAt DateTime?
|
||||||
confirmed Boolean @default(true)
|
confirmed Boolean @default(true)
|
||||||
rejected Boolean @default(false)
|
rejected Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Availability {
|
model Schedule {
|
||||||
id Int @default(autoincrement()) @id
|
id Int @id @default(autoincrement())
|
||||||
label String?
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId Int?
|
userId Int?
|
||||||
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||||
eventTypeId Int?
|
eventTypeId Int?
|
||||||
days Int[]
|
title String?
|
||||||
startTime Int
|
freeBusyTimes Json?
|
||||||
endTime Int
|
}
|
||||||
date DateTime? @db.Date
|
|
||||||
|
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 {
|
model SelectedCalendar {
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId Int
|
userId Int
|
||||||
integration String
|
integration String
|
||||||
externalId String
|
externalId String
|
||||||
@@id([userId,integration,externalId])
|
|
||||||
|
@@id([userId, integration, externalId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EventTypeCustomInputType {
|
enum EventTypeCustomInputType {
|
||||||
TEXT @map("text")
|
TEXT @map("text")
|
||||||
TEXTLONG @map("textLong")
|
TEXTLONG @map("textLong")
|
||||||
NUMBER @map("number")
|
NUMBER @map("number")
|
||||||
BOOL @map("bool")
|
BOOL @map("bool")
|
||||||
}
|
}
|
||||||
|
|
||||||
model EventTypeCustomInput {
|
model EventTypeCustomInput {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
eventTypeId Int
|
eventTypeId Int
|
||||||
eventType EventType @relation(fields: [eventTypeId], references: [id])
|
eventType EventType @relation(fields: [eventTypeId], references: [id])
|
||||||
label String
|
label String
|
||||||
type EventTypeCustomInputType
|
type EventTypeCustomInputType
|
||||||
required Boolean
|
required Boolean
|
||||||
|
@ -181,11 +196,11 @@ model EventTypeCustomInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
model ResetPasswordRequest {
|
model ResetPasswordRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
email String
|
email String
|
||||||
expires DateTime
|
expires DateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ReminderType {
|
enum ReminderType {
|
||||||
|
@ -193,9 +208,9 @@ enum ReminderType {
|
||||||
}
|
}
|
||||||
|
|
||||||
model ReminderMail {
|
model ReminderMail {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
referenceId Int
|
referenceId Int
|
||||||
reminderType ReminderType
|
reminderType ReminderType
|
||||||
elapsedMinutes Int
|
elapsedMinutes Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
|
@ -5762,7 +5762,8 @@ tsdav@^1.0.6:
|
||||||
|
|
||||||
tslib@2.0.1:
|
tslib@2.0.1:
|
||||||
version "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:
|
tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
|
||||||
version "1.14.1"
|
version "1.14.1"
|
||||||
|
|
Loading…
Reference in New Issue