Feature/availability page revamp (#1032)

* Refactored Schedule component

* Merge branch 'main' into feature/availability-page-revamp

* wip

* Turned value into number, many other TS tweaks

* NodeJS 16x works 100% on my local, but out of scope for this already massive PR

* Fixed TS errors in viewer.tsx and schedule/index.ts

* Reverted next.config.js

* Fixed minor remnant from moving types to @lib/types

* schema comment

* some changes to form handling

* add comments

* Turned ConfigType into number; which seems to be the value preferred by tRPC

* Fixed localized time display during onboarding

* Update components/ui/form/Schedule.tsx

Co-authored-by: Alex Johansson <alexander@n1s.se>

* Added showToast to indicate save success

* Converted number to Date, and also always establish time based on current date

* prevent height flickering of availability

by removing mb-2 of input field

* availabilty: re-added mb-2 but added min-height

* Quite a few bugs discovered, but this seems functional

Co-authored-by: KATT <alexander@n1s.se>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Alex van Andel 2021-11-10 11:16:32 +00:00 committed by GitHub
parent 559ccb8ca7
commit 8664d217c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 2550 additions and 2028 deletions

View File

@ -1,8 +1,10 @@
import { useId } from "@radix-ui/react-id";
import { forwardRef, ReactNode } from "react";
import { FormProvider, UseFormReturn } from "react-hook-form";
import { FormProvider, SubmitHandler, UseFormReturn } from "react-hook-form";
import classNames from "@lib/classNames";
import { getErrorFromUnknown } from "@lib/errors";
import showToast from "@lib/notification";
type InputProps = Omit<JSX.IntrinsicElements["input"], "name"> & { name: string };
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
@ -48,20 +50,56 @@ export const TextField = forwardRef<
);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Form = forwardRef<HTMLFormElement, { form: UseFormReturn<any> } & JSX.IntrinsicElements["form"]>(
function Form(props, ref) {
const { form, ...passThrough } = props;
/**
* Form helper that creates a rect-hook-form Provider and helps with submission handling & default error handling
*/
export function Form<TFieldValues>(
props: {
/**
* Pass in the return from `react-hook-form`s `useForm()`
*/
form: UseFormReturn<TFieldValues>;
/**
* Submit handler - you'll get the typed form values back
*/
handleSubmit?: SubmitHandler<TFieldValues>;
/**
* Optional - Override the default error handling
* By default it shows a toast with the error
*/
handleError?: (err: ReturnType<typeof getErrorFromUnknown>) => void;
} & Omit<JSX.IntrinsicElements["form"], "ref">
) {
const {
form,
handleSubmit,
handleError = (err) => {
showToast(err.message, "error");
},
...passThrough
} = props;
return (
<FormProvider {...form}>
<form ref={ref} {...passThrough}>
{props.children}
</form>
</FormProvider>
);
}
);
return (
<FormProvider {...form}>
<form
onSubmit={
handleSubmit
? form.handleSubmit(async (...args) => {
try {
await handleSubmit(...args);
} catch (_err) {
const err = getErrorFromUnknown(_err);
handleError(err);
}
})
: undefined
}
{...passThrough}>
{props.children}
</form>
</FormProvider>
);
}
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
return (

View File

@ -1,337 +0,0 @@
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
import classnames from "classnames";
import dayjs, { Dayjs } from "dayjs";
import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
import Text from "@components/ui/Text";
export const SCHEDULE_FORM_ID = "SCHEDULE_FORM_ID";
export const toCalendsoAvailabilityFormat = (schedule: Schedule) => {
return schedule;
};
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);
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 { t } = useLocale();
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 }) => {
const timeOptions = (type: "start" | "end") =>
TIMES.map((time) => (
<option
key={`${day}.${index}.${type}.${time.format(_24_HOUR_TIME_FORMAT)}`}
value={time.format(_24_HOUR_TIME_FORMAT)}>
{time.toDate().toLocaleTimeString(undefined, { minute: "numeric", hour: "numeric" })}
</option>
));
return (
<div key={`${day}-range-${index}`} className="flex items-center 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">
{timeOptions("start")}
</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">
{timeOptions("end")}
</select>
</div>
<div>
<DeleteAction range={range} />
</div>
</div>
);
};
const Actions = () => {
return (
<div className="flex items-center">
<button className="btn-icon" type="button" onClick={() => handleAddRange()}>
<PlusIcon className="h-5 w-5" />
</button>
</div>
);
};
const DeleteAction = ({ range }: { range: TimeRange }) => {
return (
<button className="btn-icon" type="button" onClick={() => handleDeleteRange(range)}>
<TrashIcon className="h-5 w-5" />
</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">
{t("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;

View File

@ -4,9 +4,10 @@ import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import React, { useEffect, useState } from "react";
import TimezoneSelect from "react-timezone-select";
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
import { useLocale } from "@lib/hooks/useLocale";
import { OpeningHours, DateOverride } from "@lib/types/event-type";
import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal";
@ -17,44 +18,30 @@ dayjs.extend(timezone);
type Props = {
timeZone: string;
availability: Availability[];
setTimeZone: unknown;
setTimeZone: (timeZone: string) => void;
setAvailability: (schedule: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }) => void;
};
export const Scheduler = ({
availability,
setAvailability,
timeZone: selectedTimeZone,
setTimeZone,
}: Props) => {
const { t } = useLocale();
/**
* @deprecated
*/
export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone }: Props) => {
const { t, i18n } = useLocale();
const [editSchedule, setEditSchedule] = useState(-1);
const [dateOverrides, setDateOverrides] = useState([]);
const [openingHours, setOpeningHours] = useState([]);
const [openingHours, setOpeningHours] = useState<Availability[]>([]);
useEffect(() => {
setOpeningHours(
availability
.filter((item: Availability) => item.days.length !== 0)
.map((item) => {
item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes");
item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes");
return item;
})
);
setDateOverrides(availability.filter((item: Availability) => item.date));
setOpeningHours(availability.filter((item: Availability) => item.days.length !== 0));
}, []);
// updates availability to how it should be formatted outside this component.
useEffect(() => {
setAvailability({
dateOverrides: dateOverrides,
openingHours: openingHours,
});
}, [dateOverrides, openingHours]);
setAvailability({ openingHours, dateOverrides: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openingHours]);
const addNewSchedule = () => setEditSchedule(openingHours.length);
const applyEditSchedule = (changed) => {
const applyEditSchedule = (changed: Availability) => {
// new entry
if (!changed.days) {
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
@ -63,39 +50,33 @@ export const Scheduler = ({
// update
const replaceWith = { ...openingHours[editSchedule], ...changed };
openingHours.splice(editSchedule, 1, replaceWith);
setOpeningHours([].concat(openingHours));
setOpeningHours([...openingHours]);
}
};
const removeScheduleAt = (toRemove: number) => {
openingHours.splice(toRemove, 1);
setOpeningHours([].concat(openingHours));
setOpeningHours([...openingHours]);
};
const OpeningHours = ({ idx, item }) => (
<li className="py-2 flex justify-between border-b">
const OpeningHours = ({ idx, item }: { idx: number; item: Availability }) => (
<li className="flex justify-between py-2 border-b">
<div className="flex flex-col space-y-4 lg:inline-flex">
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
<button
className="text-sm bg-neutral-100 rounded-sm py-2 px-3"
className="px-3 py-2 text-sm rounded-sm bg-neutral-100"
type="button"
onClick={() => setEditSchedule(idx)}>
{dayjs()
.startOf("day")
.add(item.startTime, "minutes")
.format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
{item.startTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
&nbsp;{t("until")}&nbsp;
{dayjs()
.startOf("day")
.add(item.endTime, "minutes")
.format(item.endTime % 60 === 0 ? "ha" : "h:mma")}
{item.endTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
</button>
</div>
<button
type="button"
onClick={() => removeScheduleAt(idx)}
className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-5 w-5 inline text-gray-400 -mt-1" />
className="px-2 py-1 ml-1 bg-transparent btn-sm">
<TrashIcon className="inline w-5 h-5 -mt-1 text-gray-400" />
</button>
</li>
);
@ -111,9 +92,9 @@ export const Scheduler = ({
<div className="mt-1">
<TimezoneSelect
id="timeZone"
value={{ value: selectedTimeZone }}
onChange={(tz) => setTimeZone(tz.value)}
className="shadow-sm focus:ring-black focus:border-brand mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
value={timeZone}
onChange={(tz: ITimezoneOption) => setTimeZone(tz.value)}
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
</div>
@ -122,16 +103,36 @@ export const Scheduler = ({
<OpeningHours key={idx} idx={idx} item={item} />
))}
</ul>
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm mt-2">
<button type="button" onClick={addNewSchedule} className="mt-2 btn-white btn-sm">
{t("add_another")}
</button>
</div>
</div>
{editSchedule >= 0 && (
<SetTimesModal
startTime={openingHours[editSchedule] ? openingHours[editSchedule].startTime : 540}
endTime={openingHours[editSchedule] ? openingHours[editSchedule].endTime : 1020}
onChange={(times) => applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })}
startTime={
openingHours[editSchedule]
? new Date(openingHours[editSchedule].startTime).getHours() * 60 +
new Date(openingHours[editSchedule].startTime).getMinutes()
: 540
}
endTime={
openingHours[editSchedule]
? new Date(openingHours[editSchedule].endTime).getHours() * 60 +
new Date(openingHours[editSchedule].endTime).getMinutes()
: 1020
}
onChange={(times: { startTime: number; endTime: number }) =>
applyEditSchedule({
...(openingHours[editSchedule] || {}),
startTime: new Date(
new Date().setHours(Math.floor(times.startTime / 60), times.startTime % 60, 0, 0)
),
endTime: new Date(
new Date().setHours(Math.floor(times.endTime / 60), times.endTime % 60, 0, 0)
),
})
}
onExit={() => setEditSchedule(-1)}
/>
)}

View File

@ -0,0 +1,187 @@
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
import dayjs, { Dayjs } from "dayjs";
import React, { useCallback, useState } from "react";
import { Controller, useFieldArray } from "react-hook-form";
import { weekdayNames } from "@lib/core/i18n/weekday";
import { useLocale } from "@lib/hooks/useLocale";
import { TimeRange, Schedule as ScheduleType } from "@lib/types/schedule";
import Button from "@components/ui/Button";
import Select from "@components/ui/form/Select";
/** 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 end = dayjs().endOf("day");
let t: Dayjs = dayjs().startOf("day");
const times = [];
while (t.isBefore(end)) {
times.push(t);
t = t.add(increment, "minutes");
}
return times;
})();
/** End Time Increments For Select */
// sets the desired time in current date, needs to be current date for proper DST translation
const defaultDayRange: TimeRange = {
start: new Date(new Date().setHours(9, 0, 0, 0)),
end: new Date(new Date().setHours(17, 0, 0, 0)),
};
export const DEFAULT_SCHEDULE: ScheduleType = [
[],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[],
];
type Option = {
readonly label: string;
readonly value: number;
};
type TimeRangeFieldProps = {
name: string;
};
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
const [options, setOptions] = useState<Option[]>([]);
const getOption = (time: Date) => ({
value: time.valueOf(),
label: time.toLocaleTimeString("nl-NL", { minute: "numeric", hour: "numeric" }),
});
const timeOptions = useCallback((offsetOrLimit: { offset?: number; limit?: number } = {}) => {
const { limit, offset } = offsetOrLimit;
return TIMES.filter((time) => (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset))).map(
(t) => getOption(t.toDate())
);
}, []);
return (
<>
<Controller
name={`${name}.start`}
render={({ field: { onChange, value } }) => (
<Select
className="w-[6rem]"
options={options}
onFocus={() => setOptions(timeOptions())}
onBlur={() => setOptions([])}
defaultValue={getOption(value)}
onChange={(option) => onChange(new Date(option?.value as number))}
/>
)}
/>
<span>-</span>
<Controller
name={`${name}.end`}
render={({ field: { onChange, value } }) => (
<Select
className="w-[6rem]"
options={options}
onFocus={() => setOptions(timeOptions())}
onBlur={() => setOptions([])}
defaultValue={getOption(value)}
onChange={(option) => onChange(new Date(option?.value as number))}
/>
)}
/>
</>
);
};
type ScheduleBlockProps = {
day: number;
weekday: string;
name: string;
};
const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
const { t } = useLocale();
const { fields, append, remove, replace } = useFieldArray({
name: `${name}.${day}`,
});
const handleAppend = () => {
// FIXME: Fix type-inference, can't get this to work. @see https://github.com/react-hook-form/react-hook-form/issues/4499
const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
const nextRangeEnd = dayjs(nextRangeStart).add(1, "hour");
if (nextRangeEnd.isBefore(nextRangeStart.endOf("day"))) {
return append({
start: nextRangeStart.toDate(),
end: nextRangeEnd.toDate(),
});
}
};
return (
<fieldset className="flex justify-between py-5 min-h-[86px]">
<div className="w-1/3">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={fields.length > 0}
onChange={(e) => (e.target.checked ? replace([defaultDayRange]) : replace([]))}
className="inline-block border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
/>
<span className="inline-block capitalize">{weekday}</span>
</label>
</div>
<div className="flex-grow">
{fields.map((field, index) => (
<div key={field.id} className="flex justify-between mb-2">
<div className="flex items-center space-x-2">
<TimeRangeField name={`${name}.${day}.${index}`} />
</div>
<Button
size="icon"
color="minimal"
StartIcon={TrashIcon}
type="button"
onClick={() => remove(index)}
/>
</div>
))}
{!fields.length && t("no_availability")}
</div>
<div>
<Button
type="button"
color="minimal"
size="icon"
className={fields.length > 0 ? "visible" : "invisible"}
StartIcon={PlusIcon}
onClick={handleAppend}
/>
</div>
</fieldset>
);
};
const Schedule = ({ name }: { name: string }) => {
const { i18n } = useLocale();
return (
<fieldset className="divide-y divide-gray-200">
{weekdayNames(i18n.language).map((weekday, num) => (
<ScheduleBlock key={num} name={name} weekday={weekday} day={num} />
))}
</fieldset>
);
};
export default Schedule;

View File

@ -1,29 +1,33 @@
import React, { PropsWithChildren } from "react";
import Select, { components, NamedProps } from "react-select";
import React from "react";
import ReactSelect, { components, GroupBase, Props } from "react-select";
import classNames from "@lib/classNames";
export const SelectComp = (props: PropsWithChildren<NamedProps>) => (
<Select
theme={(theme) => ({
...theme,
borderRadius: 2,
colors: {
...theme.colors,
primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
primary50: "rgba(17, 17, 17, var(--tw-bg-opacity))",
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
},
})}
components={{
...components,
IndicatorSeparator: () => null,
}}
className={classNames("text-sm shadow-sm focus:border-primary-500", props.className)}
{...props}
/>
);
function Select<
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({ className, ...props }: Props<Option, IsMulti, Group>) {
return (
<ReactSelect
theme={(theme) => ({
...theme,
borderRadius: 2,
colors: {
...theme.colors,
primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
primary50: "rgba(17, 17, 17, var(--tw-bg-opacity))",
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
},
})}
components={{
...components,
IndicatorSeparator: () => null,
}}
className={classNames("text-sm shadow-sm focus:border-primary-500", className)}
{...props}
/>
);
}
SelectComp.displayName = "Select";
export default SelectComp;
export default Select;

View File

@ -9,6 +9,22 @@ const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
export type Integration = {
installed: boolean;
type:
| "google_calendar"
| "office365_calendar"
| "zoom_video"
| "daily_video"
| "caldav_calendar"
| "apple_calendar"
| "stripe_payment";
title: string;
imageSrc: string;
description: string;
variant: "calendar" | "conferencing" | "payment";
};
export const ALL_INTEGRATIONS = [
{
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
@ -70,7 +86,7 @@ export const ALL_INTEGRATIONS = [
description: "Collect payments",
variant: "payment",
},
] as const;
] as Integration[];
function getIntegrations(userCredentials: CredentialData[]) {
const integrations = ALL_INTEGRATIONS.map((integration) => {

View File

@ -1,16 +1,7 @@
import { SchedulingType, EventType } from "@prisma/client";
import { SchedulingType, EventType, Availability } from "@prisma/client";
export type OpeningHours = {
days: number[];
startTime: number;
endTime: number;
};
export type DateOverride = {
date: string;
startTime: number;
endTime: number;
};
export type OpeningHours = Pick<Availability, "days" | "startTime" | "endTime">;
export type DateOverride = Pick<Availability, "date" | "startTime" | "endTime">;
export type AdvancedOptions = {
eventName?: string;

6
lib/types/schedule.ts Normal file
View File

@ -0,0 +1,6 @@
export type TimeRange = {
start: Date;
end: Date;
};
export type Schedule = TimeRange[][];

View File

@ -85,7 +85,7 @@
"react-phone-number-input": "^3.1.25",
"react-query": "^3.30.0",
"react-router-dom": "^5.2.0",
"react-select": "^4.3.1",
"react-select": "^5.1.0",
"react-timezone-select": "^1.1.13",
"react-use-intercom": "1.4.0",
"short-uuid": "^4.2.0",
@ -110,7 +110,6 @@
"@types/qrcode": "^1.4.1",
"@types/react": "^17.0.18",
"@types/react-phone-number-input": "^3.0.13",
"@types/react-select": "^4.0.17",
"@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1",
"@typescript-eslint/eslint-plugin": "^4.33.0",

View File

@ -156,7 +156,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}
}*/
const getWorkingHours = (availability: typeof user.availability | typeof eventType.availability) =>
availability && availability.length ? availability : null;
availability && availability.length
? availability.map((schedule) => ({
...schedule,
startTime: schedule.startTime.getUTCHours() * 60 + schedule.startTime.getUTCMinutes(),
endTime: schedule.endTime.getUTCHours() * 60 + schedule.endTime.getUTCMinutes(),
}))
: null;
const workingHours =
getWorkingHours(eventType.availability) ||
@ -176,6 +182,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
eventTypeObject.availability = [];
return {
props: {
profile: {

View File

@ -1,12 +1,17 @@
// import { getBusyVideoTimes } from "@lib/videoClient";
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getBusyCalendarTimes } from "@lib/calendarClient";
import prisma from "@lib/prisma";
dayjs.extend(utc);
dayjs.extend(timezone);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = asStringOrNull(req.query.user);
const dateFrom = dayjs(asStringOrNull(req.query.dateFrom));
@ -71,19 +76,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}));
const timeZone = eventType?.timeZone || currentUser.timeZone;
const defaultAvailability = {
startTime: currentUser.startTime,
endTime: currentUser.endTime,
days: [0, 1, 2, 3, 4, 5, 6],
};
const workingHours = eventType?.availability.length
? eventType.availability
: // currentUser.availability /* note(zomars) There's no UI nor default for this as of today */
[defaultAvailability]; /* note(zomars) For now, make every day available as fallback */
const workingHours = eventType?.availability.length ? eventType.availability : currentUser.availability;
// FIXME: Currently the organizer timezone is used for the logic
// refactor to be organizerTimezone unaware, use UTC instead.
res.status(200).json({
busy: bufferedBusyTimes,
timeZone,
workingHours,
workingHours: workingHours
// FIXME: Currently the organizer timezone is used for the logic
// refactor to be organizerTimezone unaware, use UTC instead.
.map((workingHour) => ({
days: workingHour.days,
startTime: dayjs(workingHour.startTime).tz(timeZone).toDate(),
endTime: dayjs(workingHour.endTime).tz(timeZone).toDate(),
}))
.map((workingHour) => ({
days: workingHour.days,
startTime: workingHour.startTime.getHours() * 60 + workingHour.startTime.getMinutes(),
endTime: workingHour.endTime.getHours() * 60 + workingHour.endTime.getMinutes(),
})),
});
}

View File

@ -1,8 +1,9 @@
import { Availability, EventTypeCustomInput, MembershipRole, Prisma } from "@prisma/client";
import { EventTypeCustomInput, MembershipRole, Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { OpeningHours } from "@lib/types/event-type";
function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
if (!customInputs || !customInputs?.length) return undefined;
@ -160,7 +161,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
if (req.body.availability) {
const openingHours = req.body.availability.openingHours || [];
const openingHours: OpeningHours[] = req.body.availability.openingHours || [];
// const overrides = req.body.availability.dateOverrides || [];
const eventTypeId = +req.body.id;
@ -172,20 +173,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
Promise.all(
openingHours.map((schedule: Pick<Availability, "days" | "startTime" | "endTime">) =>
prisma.availability.create({
data: {
eventTypeId: +req.body.id,
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
},
})
)
).catch((error) => {
console.log(error);
});
const availabilityToCreate = openingHours.map((openingHour) => ({
startTime: openingHour.startTime,
endTime: openingHour.endTime,
days: openingHour.days,
}));
data.availability = {
createMany: {
data: availabilityToCreate,
},
};
}
const eventType = await prisma.eventType.update({

View File

@ -1,32 +1,76 @@
import { Availability } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { TimeRange } from "@lib/types/schedule";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
const userId = session?.user?.id;
if (!userId) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (!req.body.schedule || req.body.schedule.length !== 7) {
return res.status(400).json({ message: "Bad Request." });
}
const availability = req.body.schedule.reduce(
(availability: Availability[], times: TimeRange[], day: number) => {
const addNewTime = (time: TimeRange) =>
({
days: [day],
startTime: time.start,
endTime: time.end,
} as Availability);
const filteredTimes = times.filter((time) => {
let idx;
if (
(idx = availability.findIndex(
(schedule) => schedule.startTime === time.start && schedule.endTime === time.end
)) !== -1
) {
availability[idx].days.push(day);
return false;
}
return true;
});
filteredTimes.forEach((time) => {
availability.push(addNewTime(time));
});
return availability;
},
[] as Availability[]
);
if (req.method === "POST") {
try {
const createdSchedule = await prisma.schedule.create({
data: {
freeBusyTimes: req.body.data.freeBusyTimes,
user: {
connect: {
id: session.user.id,
},
},
await prisma.availability.deleteMany({
where: {
userId,
},
});
await Promise.all(
availability.map((schedule: Availability) =>
prisma.availability.create({
data: {
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
user: {
connect: {
id: userId,
},
},
},
})
)
);
return res.status(200).json({
message: "created",
data: createdSchedule,
});
} catch (error) {
console.error(error);

View File

@ -1,245 +1,86 @@
import { ClockIcon } from "@heroicons/react/outline";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { QueryCell } from "@lib/QueryCell";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { Schedule as ScheduleType } from "@lib/types/schedule";
import { Dialog, DialogContent } from "@components/Dialog";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import { Alert } from "@components/ui/Alert";
import { Form } from "@components/form/fields";
import Button from "@components/ui/Button";
import Schedule, { DEFAULT_SCHEDULE } from "@components/ui/form/Schedule";
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hours = h < 10 ? "0" + h : h;
const minutes = m < 10 ? "0" + m : m;
return `${hours}:${minutes}`;
type FormValues = {
schedule: ScheduleType;
};
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">) {
const { t } = useLocale();
const createSchedule = async ({ schedule }: FormValues) => {
const res = await fetch(`/api/schedule`, {
method: "POST",
body: JSON.stringify({ schedule }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error((await res.json()).message);
}
const responseData = await res.json();
showToast(t("availability_updated_successfully"), "success");
return responseData.data;
};
const form = useForm({
defaultValues: {
schedule: props.schedule || DEFAULT_SCHEDULE,
},
});
return (
<div className="grid grid-cols-3 gap-2">
<Form
form={form}
handleSubmit={async (values) => {
await createSchedule(values);
}}
className="col-span-3 space-y-2 lg:col-span-2">
<div className="px-4 py-5 bg-white border border-gray-200 divide-y rounded-sm sm:p-6">
<h3 className="mb-4 text-lg font-semibold leading-6 text-gray-900">{t("change_start_end")}</h3>
<Schedule name="schedule" />
</div>
<div className="text-right">
<Button>{t("save")}</Button>
</div>
</Form>
<div className="col-span-3 ml-2 lg:col-span-1 min-w-40">
<div className="px-4 py-5 border border-gray-200 rounded-sm sm:p-6 ">
<h3 className="text-lg font-medium leading-6 text-gray-900">{t("something_doesnt_look_right")}</h3>
<div className="max-w-xl mt-2 text-sm text-gray-500">
<p>{t("troubleshoot_availability")}</p>
</div>
<div className="mt-5">
<Link href="/availability/troubleshoot">
<a className="btn btn-white">{t("launch_troubleshooter")}</a>
</Link>
</div>
</div>
</div>
</div>
);
}
export default function Availability() {
const { t } = useLocale();
const queryMe = trpc.useQuery(["viewer.me"]);
const formModal = useToggleQuery("edit");
const formMethods = useForm<{
startHours: string;
startMins: string;
endHours: string;
endMins: string;
bufferHours: string;
bufferMins: string;
}>({});
const router = useRouter();
useEffect(() => {
/**
* This hook populates the form with new values as soon as the user is loaded or changes
*/
const user = queryMe.data;
if (formMethods.formState.isDirty || !user) {
return;
}
formMethods.reset({
startHours: convertMinsToHrsMins(user.startTime).split(":")[0],
startMins: convertMinsToHrsMins(user.startTime).split(":")[1],
endHours: convertMinsToHrsMins(user.endTime).split(":")[0],
endMins: convertMinsToHrsMins(user.endTime).split(":")[1],
bufferHours: convertMinsToHrsMins(user.bufferTime).split(":")[0],
bufferMins: convertMinsToHrsMins(user.bufferTime).split(":")[1],
});
}, [formMethods, queryMe.data]);
if (queryMe.status === "loading") {
return <Loader />;
}
if (queryMe.status !== "success") {
return <Alert severity="error" title={t("something_went_wrong")} />;
}
const user = queryMe.data;
const query = trpc.useQuery(["viewer.availability"]);
return (
<div>
<Shell heading={t("availability")} subtitle={t("configure_availability")}>
<div className="flex">
<div className="w-1/2 mr-2 bg-white border border-gray-200 rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">{t("change_start_end")}</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>
{t("current_start_date")} {convertMinsToHrsMins(user.startTime)} {t("and_end_at")}{" "}
{convertMinsToHrsMins(user.endTime)}.
</p>
</div>
<div className="mt-5">
<Button href={formModal.hrefOn}>{t("change_available_times")}</Button>
</div>
</div>
</div>
<div className="w-1/2 ml-2 border border-gray-200 rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{t("something_doesnt_look_right")}
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{t("troubleshoot_availability")}</p>
</div>
<div className="mt-5">
<Link href="/availability/troubleshoot">
<a className="btn btn-white">{t("launch_troubleshooter")}</a>
</Link>
</div>
</div>
</div>
</div>
<Dialog
open={formModal.isOn}
onOpenChange={(isOpen) => {
router.push(isOpen ? formModal.hrefOn : formModal.hrefOff);
}}>
<DialogContent>
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<ClockIcon className="h-6 w-6 text-neutral-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{t("change_your_available_times")}
</h3>
<div>
<p className="text-sm text-gray-500">{t("change_start_end_buffer")}</p>
</div>
</div>
</div>
<form
onSubmit={formMethods.handleSubmit(async (values) => {
const startMins = parseInt(values.startHours) * 60 + parseInt(values.startMins);
const endMins = parseInt(values.endHours) * 60 + parseInt(values.endMins);
const bufferMins = parseInt(values.bufferHours) * 60 + parseInt(values.bufferMins);
// TODO: Add validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const response = await fetch("/api/availability/day", {
method: "PATCH",
body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }),
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
showToast(t("something_went_wrong"), "error");
return;
}
await queryMe.refetch();
router.push(formModal.hrefOff);
showToast(t("start_end_changed_successfully"), "success");
})}>
<div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">
{t("start_time")}
</label>
<div>
<label htmlFor="startHours" className="sr-only">
{t("hours")}
</label>
<input
{...formMethods.register("startHours")}
id="startHours"
type="number"
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="9"
defaultValue={convertMinsToHrsMins(user.startTime).split(":")[0]}
/>
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="startMins" className="sr-only">
{t("minutes")}
</label>
<input
{...formMethods.register("startMins")}
id="startMins"
type="number"
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="30"
/>
</div>
</div>
<div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">{t("end_time")}</label>
<div>
<label htmlFor="endHours" className="sr-only">
{t("hours")}
</label>
<input
{...formMethods.register("endHours")}
type="number"
id="endHours"
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="17"
/>
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="endMins" className="sr-only">
{t("minutes")}
</label>
<input
{...formMethods.register("endMins")}
type="number"
id="endMins"
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="30"
/>
</div>
</div>
<div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">{t("buffer")}</label>
<div>
<label htmlFor="bufferHours" className="sr-only">
{t("hours")}
</label>
<input
{...formMethods.register("bufferHours")}
type="number"
id="bufferHours"
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="0"
/>
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="bufferMins" className="sr-only">
{t("minutes")}
</label>
<input
{...formMethods.register("bufferMins")}
type="number"
id="bufferMins"
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="10"
/>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex space-x-2">
<Button href={formModal.hrefOff} color="secondary" tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={formMethods.formState.isSubmitting}>
{t("update")}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<QueryCell query={query} success={({ data }) => <AvailabilityForm {...data} />} />
</Shell>
</div>
);

View File

@ -44,7 +44,7 @@ import updateEventType from "@lib/mutations/event-types/update-event-type";
import showToast from "@lib/notification";
import prisma from "@lib/prisma";
import { defaultAvatarSrc } from "@lib/profile";
import { AdvancedOptions, EventTypeInput } from "@lib/types/event-type";
import { AdvancedOptions, DateOverride, EventTypeInput, OpeningHours } from "@lib/types/event-type";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
@ -112,7 +112,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [users, setUsers] = useState<AdvancedOptions["users"]>([]);
const [editIcon, setEditIcon] = useState(true);
const [enteredAvailability, setEnteredAvailability] = useState();
const [enteredAvailability, setEnteredAvailability] = useState<{
openingHours: OpeningHours[];
dateOverrides: DateOverride[];
}>();
const [showLocationModal, setShowLocationModal] = useState(false);
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
@ -851,7 +854,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
setAvailability={setEnteredAvailability}
setTimeZone={setSelectedTimeZone}
timeZone={selectedTimeZone}
availability={availability}
availability={availability.map((schedule) => ({
...schedule,
startTime: new Date(schedule.startTime),
endTime: new Date(schedule.endTime),
}))}
/>
</div>
</div>
@ -1253,7 +1260,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}
type Availability = typeof eventType["availability"];
const getAvailability = (availability: Availability) => (availability?.length ? availability : null);
const getAvailability = (availability: Availability) =>
availability?.length
? availability.map((schedule) => ({
...schedule,
startTime: new Date(new Date().toDateString() + " " + schedule.startTime.toTimeString()).valueOf(),
endTime: new Date(new Date().toDateString() + " " + schedule.endTime.toTimeString()).valueOf(),
}))
: null;
const availability = getAvailability(eventType.availability) || [];
availability.sort((a, b) => a.startTime - b.startTime);
@ -1261,6 +1275,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
availability,
});
const teamMembers = eventTypeObject.team

View File

@ -2,6 +2,7 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
import { Prisma } from "@prisma/client";
import classnames from "classnames";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import debounce from "lodash/debounce";
@ -11,6 +12,7 @@ import { useSession } from "next-auth/client";
import Head from "next/head";
import { useRouter } from "next/router";
import React, { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import TimezoneSelect from "react-timezone-select";
import { getSession } from "@lib/auth";
@ -18,14 +20,16 @@ import { useLocale } from "@lib/hooks/useLocale";
import getIntegrations from "@lib/integrations/getIntegrations";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { Schedule as ScheduleType } from "@lib/types/schedule";
import { ClientSuspense } from "@components/ClientSuspense";
import Loader from "@components/Loader";
import { Form } from "@components/form/fields";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
import SchedulerForm, { SCHEDULE_FORM_ID } from "@components/ui/Schedule/Schedule";
import Text from "@components/ui/Text";
import Schedule, { DEFAULT_SCHEDULE } from "@components/ui/form/Schedule";
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
@ -34,6 +38,11 @@ import getEventTypes from "../lib/queries/event-types/get-event-types";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
type ScheduleFormValues = {
schedule: ScheduleType;
};
export default function Onboarding(props: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
@ -96,10 +105,10 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
return responseData.data;
};
const createSchedule = async (data: Prisma.ScheduleCreateInput) => {
const createSchedule = async ({ schedule }: ScheduleFormValues) => {
const res = await fetch(`/api/schedule`, {
method: "POST",
body: JSON.stringify({ data: { ...data } }),
body: JSON.stringify({ schedule }),
headers: {
"Content-Type": "application/json",
},
@ -118,16 +127,13 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
/** End Name */
/** TimeZone */
const [selectedTimeZone, setSelectedTimeZone] = useState(props.user.timeZone ?? dayjs.tz.guess());
const currentTime = React.useMemo(() => {
return dayjs().tz(selectedTimeZone).format("H:mm A");
}, [selectedTimeZone]);
/** End TimeZone */
/** Onboarding Steps */
const [currentStep, setCurrentStep] = useState(0);
const detectStep = () => {
let step = 0;
const hasSetUserNameOrTimeZone = props.user.name && props.user.timeZone;
const hasSetUserNameOrTimeZone = props.user?.name && props.user?.timeZone;
if (hasSetUserNameOrTimeZone) {
step = 1;
}
@ -153,6 +159,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
steps[currentStep].onComplete &&
typeof steps[currentStep].onComplete === "function"
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await steps[currentStep].onComplete!();
}
incrementStep();
@ -222,6 +229,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
router.push("/event-types");
};
const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } });
const steps = [
{
id: t("welcome"),
@ -254,15 +262,13 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
</label>
<Text variant="caption">
{t("current_time")}:&nbsp;
<span className="text-black">{currentTime}</span>
<span className="text-black">{dayjs().tz(selectedTimeZone).format("LT")}</span>
</Text>
</section>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={({ value }) => {
setSelectedTimeZone(value);
}}
onChange={({ value }) => setSelectedTimeZone(value)}
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</fieldset>
@ -307,29 +313,30 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
title: t("set_availability"),
description: t("set_availability_instructions"),
Component: (
<>
<section className="max-w-lg mx-auto text-black bg-white dark:bg-opacity-5 dark:text-white">
<SchedulerForm
onSubmit={async (data) => {
try {
setSubmitting(true);
await createSchedule({
freeBusyTimes: data,
});
debouncedHandleConfirmStep();
setSubmitting(false);
} catch (error) {
setError(error as Error);
}
}}
/>
<Form
className="max-w-lg mx-auto text-black bg-white dark:bg-opacity-5 dark:text-white"
form={availabilityForm}
handleSubmit={async (values) => {
try {
setSubmitting(true);
await createSchedule({ ...values });
debouncedHandleConfirmStep();
setSubmitting(false);
} catch (error) {
if (error instanceof Error) {
setError(error);
}
}
}}>
<section>
<Schedule name="schedule" />
<footer className="flex flex-col py-6 space-y-6 sm:mx-auto sm:w-full">
<Button className="justify-center" EndIcon={ArrowRightIcon} type="submit">
{t("continue")}
</Button>
</footer>
</section>
<footer className="flex flex-col py-6 space-y-6 sm:mx-auto sm:w-full">
<Button className="justify-center" EndIcon={ArrowRightIcon} type="submit" form={SCHEDULE_FORM_ID}>
{t("continue")}
</Button>
</footer>
</>
</Form>
),
hideConfirm: true,
showCancel: false,
@ -401,6 +408,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
useEffect(() => {
detectStep();
setReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (Sess[1] || !ready) {
@ -471,12 +479,18 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
</section>
<section className="max-w-xl py-8 mx-auto">
<div className="flex flex-row-reverse justify-between">
<button disabled={isSubmitting} onClick={handleSkipStep}>
<Text variant="caption">Skip Step</Text>
<button
disabled={isSubmitting}
onClick={handleSkipStep}
className="text-sm leading-tight text-gray-500 dark:text-white">
{t("next_step")}
</button>
{currentStep !== 0 && (
<button disabled={isSubmitting} onClick={decrementStep}>
<Text variant="caption">Prev Step</Text>
<button
disabled={isSubmitting}
onClick={decrementStep}
className="text-sm leading-tight text-gray-500 dark:text-white">
{t("prev_step")}
</button>
)}
</div>

View File

@ -66,8 +66,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const [eventType] = team.eventTypes;
type Availability = typeof eventType["availability"];
const getWorkingHours = (availability: Availability) => (availability?.length ? availability : null);
const workingHours = getWorkingHours(eventType.availability) || [];
const getWorkingHours = (availability: Availability) =>
availability?.length
? availability.map((schedule) => ({
...schedule,
startTime: schedule.startTime.getUTCHours() * 60 + schedule.startTime.getUTCMinutes(),
endTime: schedule.endTime.getUTCHours() * 60 + schedule.endTime.getUTCMinutes(),
}))
: null;
const workingHours =
getWorkingHours(eventType.availability) ||
[
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: 0,
endTime: 1440,
},
].filter((availability): boolean => typeof availability["days"] !== "undefined");
workingHours.sort((a, b) => a.startTime - b.startTime);
@ -76,6 +91,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
eventTypeObject.availability = [];
return {
props: {
profile: {

View File

@ -0,0 +1,14 @@
-- This is an empty migration.
ALTER TABLE "Availability" RENAME COLUMN "startTime" to "old_startTime";
ALTER TABLE "Availability" RENAME COLUMN "endTime" to "old_endTime";
ALTER TABLE "Availability" ADD COLUMN "startTime" TIME;
ALTER TABLE "Availability" ADD COLUMN "endTime" TIME;
UPDATE "Availability" SET "startTime" = CAST(CONCAT(CAST(("old_startTime" / 60) AS text), ':00') AS time);
UPDATE "Availability" SET "endTime" = CAST(CONCAT(CAST(("old_endTime" / 60) AS text), ':00') AS time);
ALTER TABLE "Availability" DROP COLUMN "old_startTime";
ALTER TABLE "Availability" DROP COLUMN "old_endTime";
ALTER TABLE "Availability" ALTER COLUMN "startTime" SET NOT NULL;
ALTER TABLE "Availability" ALTER COLUMN "endTime" SET NOT NULL;

View File

@ -216,8 +216,8 @@ model Availability {
eventType EventType? @relation(fields: [eventTypeId], references: [id])
eventTypeId Int?
days Int[]
startTime Int
endTime Int
startTime DateTime @db.Time
endTime DateTime @db.Time
date DateTime? @db.Date
}

View File

@ -218,6 +218,7 @@
"booking_already_cancelled": "This booking was already cancelled",
"go_back_home": "Go back home",
"or_go_back_home": "Or go back home",
"no_availability": "Unavailable",
"no_meeting_found": "No Meeting Found",
"no_meeting_found_description": "This meeting does not exist. Contact the meeting owner for an updated link.",
"no_status_bookings_yet": "No {{status}} bookings, yet",
@ -461,6 +462,7 @@
"billing": "Billing",
"manage_your_billing_info": "Manage your billing information and cancel your subscription.",
"availability": "Availability",
"availability_updated_successfully": "Availability updated successfully",
"configure_availability": "Configure times when you are available for bookings.",
"change_weekly_schedule": "Change your weekly schedule",
"logo": "Logo",
@ -512,6 +514,8 @@
"confirm_delete_event_type": "Yes, delete event type",
"integrations": "Integrations",
"settings": "Settings",
"next_step": "Skip step",
"prev_step": "Prev step",
"installed": "Installed",
"disconnect": "Disconnect",
"embed_your_calendar": "Embed your calendar within your webpage",

View File

@ -141,6 +141,7 @@
"booking_already_cancelled": "Deze afspraak is reeds geannuleerd",
"go_back_home": "Terug naar startpagina",
"or_go_back_home": "Of ga terug naar de startpagina",
"no_availability": "Onbeschikbaar",
"no_meeting_found": "Afspraak Niet Gevonden",
"no_meeting_found_description": "Kan deze afspraak niet vinden. Neem contact op met de organisator voor een nieuwe link.",
"no_status_bookings_yet": "Nog geen {{status}} afspraken",
@ -435,5 +436,7 @@
"delete_event_type": "Verwijder Evenement",
"confirm_delete_event_type": "Ja, verwijder evenement",
"integrations": "Integraties",
"settings": "Instellingen"
"settings": "Instellingen",
"next_step": "Stap overslaan",
"prev_step": "Vorige stap"
}

View File

@ -6,6 +6,7 @@ import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
import slugify from "@lib/slugify";
import { Schedule } from "@lib/types/schedule";
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
@ -383,6 +384,31 @@ const loggedInViewerRouter = createProtectedRouter()
};
},
})
.query("availability", {
async resolve({ ctx }) {
const { prisma, user } = ctx;
const availabilityQuery = await prisma.availability.findMany({
where: {
userId: user.id,
},
});
const schedule = availabilityQuery.reduce(
(schedule: Schedule, availability) => {
availability.days.forEach((day) => {
schedule[day].push({
start: new Date(new Date().toDateString() + " " + availability.startTime.toTimeString()),
end: new Date(new Date().toDateString() + " " + availability.endTime.toTimeString()),
});
});
return schedule;
},
Array.from([...Array(7)]).map(() => [])
);
return {
schedule,
};
},
})
.mutation("updateProfile", {
input: z.object({
username: z.string().optional(),

3171
yarn.lock

File diff suppressed because it is too large Load Diff