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:
parent
559ccb8ca7
commit
8664d217c9
23 changed files with 2550 additions and 2028 deletions
|
@ -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 (
|
||||
|
|
|
@ -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;
|
|
@ -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" })}
|
||||
{t("until")}
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
|
|
187
components/ui/form/Schedule.tsx
Normal file
187
components/ui/form/Schedule.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
6
lib/types/schedule.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type TimeRange = {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
|
||||
export type Schedule = TimeRange[][];
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")}:
|
||||
<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>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in a new issue