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 { useId } from "@radix-ui/react-id";
|
||||||
import { forwardRef, ReactNode } from "react";
|
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 classNames from "@lib/classNames";
|
||||||
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
|
import showToast from "@lib/notification";
|
||||||
|
|
||||||
type InputProps = Omit<JSX.IntrinsicElements["input"], "name"> & { name: string };
|
type InputProps = Omit<JSX.IntrinsicElements["input"], "name"> & { name: string };
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
|
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"]>(
|
* Form helper that creates a rect-hook-form Provider and helps with submission handling & default error handling
|
||||||
function Form(props, ref) {
|
*/
|
||||||
const { form, ...passThrough } = props;
|
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 (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form ref={ref} {...passThrough}>
|
<form
|
||||||
|
onSubmit={
|
||||||
|
handleSubmit
|
||||||
|
? form.handleSubmit(async (...args) => {
|
||||||
|
try {
|
||||||
|
await handleSubmit(...args);
|
||||||
|
} catch (_err) {
|
||||||
|
const err = getErrorFromUnknown(_err);
|
||||||
|
handleError(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
{...passThrough}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
|
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
|
||||||
return (
|
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 timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import React, { useEffect, useState } from "react";
|
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 { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
import { OpeningHours, DateOverride } from "@lib/types/event-type";
|
||||||
|
|
||||||
import { WeekdaySelect } from "./WeekdaySelect";
|
import { WeekdaySelect } from "./WeekdaySelect";
|
||||||
import SetTimesModal from "./modal/SetTimesModal";
|
import SetTimesModal from "./modal/SetTimesModal";
|
||||||
|
@ -17,44 +18,30 @@ dayjs.extend(timezone);
|
||||||
type Props = {
|
type Props = {
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
availability: Availability[];
|
availability: Availability[];
|
||||||
setTimeZone: unknown;
|
setTimeZone: (timeZone: string) => void;
|
||||||
|
setAvailability: (schedule: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Scheduler = ({
|
/**
|
||||||
availability,
|
* @deprecated
|
||||||
setAvailability,
|
*/
|
||||||
timeZone: selectedTimeZone,
|
export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone }: Props) => {
|
||||||
setTimeZone,
|
const { t, i18n } = useLocale();
|
||||||
}: Props) => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const [editSchedule, setEditSchedule] = useState(-1);
|
const [editSchedule, setEditSchedule] = useState(-1);
|
||||||
const [dateOverrides, setDateOverrides] = useState([]);
|
const [openingHours, setOpeningHours] = useState<Availability[]>([]);
|
||||||
const [openingHours, setOpeningHours] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpeningHours(
|
setOpeningHours(availability.filter((item: Availability) => item.days.length !== 0));
|
||||||
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));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// updates availability to how it should be formatted outside this component.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAvailability({
|
setAvailability({ openingHours, dateOverrides: [] });
|
||||||
dateOverrides: dateOverrides,
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
openingHours: openingHours,
|
}, [openingHours]);
|
||||||
});
|
|
||||||
}, [dateOverrides, openingHours]);
|
|
||||||
|
|
||||||
const addNewSchedule = () => setEditSchedule(openingHours.length);
|
const addNewSchedule = () => setEditSchedule(openingHours.length);
|
||||||
|
|
||||||
const applyEditSchedule = (changed) => {
|
const applyEditSchedule = (changed: Availability) => {
|
||||||
// new entry
|
// new entry
|
||||||
if (!changed.days) {
|
if (!changed.days) {
|
||||||
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
|
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
|
||||||
|
@ -63,39 +50,33 @@ export const Scheduler = ({
|
||||||
// update
|
// update
|
||||||
const replaceWith = { ...openingHours[editSchedule], ...changed };
|
const replaceWith = { ...openingHours[editSchedule], ...changed };
|
||||||
openingHours.splice(editSchedule, 1, replaceWith);
|
openingHours.splice(editSchedule, 1, replaceWith);
|
||||||
setOpeningHours([].concat(openingHours));
|
setOpeningHours([...openingHours]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeScheduleAt = (toRemove: number) => {
|
const removeScheduleAt = (toRemove: number) => {
|
||||||
openingHours.splice(toRemove, 1);
|
openingHours.splice(toRemove, 1);
|
||||||
setOpeningHours([].concat(openingHours));
|
setOpeningHours([...openingHours]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const OpeningHours = ({ idx, item }) => (
|
const OpeningHours = ({ idx, item }: { idx: number; item: Availability }) => (
|
||||||
<li className="py-2 flex justify-between border-b">
|
<li className="flex justify-between py-2 border-b">
|
||||||
<div className="flex flex-col space-y-4 lg:inline-flex">
|
<div className="flex flex-col space-y-4 lg:inline-flex">
|
||||||
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
|
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
|
||||||
<button
|
<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"
|
type="button"
|
||||||
onClick={() => setEditSchedule(idx)}>
|
onClick={() => setEditSchedule(idx)}>
|
||||||
{dayjs()
|
{item.startTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
|
||||||
.startOf("day")
|
|
||||||
.add(item.startTime, "minutes")
|
|
||||||
.format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
|
|
||||||
{t("until")}
|
{t("until")}
|
||||||
{dayjs()
|
{item.endTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
|
||||||
.startOf("day")
|
|
||||||
.add(item.endTime, "minutes")
|
|
||||||
.format(item.endTime % 60 === 0 ? "ha" : "h:mma")}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeScheduleAt(idx)}
|
onClick={() => removeScheduleAt(idx)}
|
||||||
className="btn-sm bg-transparent px-2 py-1 ml-1">
|
className="px-2 py-1 ml-1 bg-transparent btn-sm">
|
||||||
<TrashIcon className="h-5 w-5 inline text-gray-400 -mt-1" />
|
<TrashIcon className="inline w-5 h-5 -mt-1 text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
@ -111,9 +92,9 @@ export const Scheduler = ({
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<TimezoneSelect
|
<TimezoneSelect
|
||||||
id="timeZone"
|
id="timeZone"
|
||||||
value={{ value: selectedTimeZone }}
|
value={timeZone}
|
||||||
onChange={(tz) => setTimeZone(tz.value)}
|
onChange={(tz: ITimezoneOption) => 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"
|
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -122,16 +103,36 @@ export const Scheduler = ({
|
||||||
<OpeningHours key={idx} idx={idx} item={item} />
|
<OpeningHours key={idx} idx={idx} item={item} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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")}
|
{t("add_another")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{editSchedule >= 0 && (
|
{editSchedule >= 0 && (
|
||||||
<SetTimesModal
|
<SetTimesModal
|
||||||
startTime={openingHours[editSchedule] ? openingHours[editSchedule].startTime : 540}
|
startTime={
|
||||||
endTime={openingHours[editSchedule] ? openingHours[editSchedule].endTime : 1020}
|
openingHours[editSchedule]
|
||||||
onChange={(times) => applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })}
|
? 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)}
|
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,10 +1,15 @@
|
||||||
import React, { PropsWithChildren } from "react";
|
import React from "react";
|
||||||
import Select, { components, NamedProps } from "react-select";
|
import ReactSelect, { components, GroupBase, Props } from "react-select";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
export const SelectComp = (props: PropsWithChildren<NamedProps>) => (
|
function Select<
|
||||||
<Select
|
Option,
|
||||||
|
IsMulti extends boolean = false,
|
||||||
|
Group extends GroupBase<Option> = GroupBase<Option>
|
||||||
|
>({ className, ...props }: Props<Option, IsMulti, Group>) {
|
||||||
|
return (
|
||||||
|
<ReactSelect
|
||||||
theme={(theme) => ({
|
theme={(theme) => ({
|
||||||
...theme,
|
...theme,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
|
@ -19,11 +24,10 @@ export const SelectComp = (props: PropsWithChildren<NamedProps>) => (
|
||||||
...components,
|
...components,
|
||||||
IndicatorSeparator: () => null,
|
IndicatorSeparator: () => null,
|
||||||
}}
|
}}
|
||||||
className={classNames("text-sm shadow-sm focus:border-primary-500", props.className)}
|
className={classNames("text-sm shadow-sm focus:border-primary-500", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
SelectComp.displayName = "Select";
|
export default Select;
|
||||||
|
|
||||||
export default SelectComp;
|
|
||||||
|
|
|
@ -9,6 +9,22 @@ const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
|
||||||
|
|
||||||
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
|
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 = [
|
export const ALL_INTEGRATIONS = [
|
||||||
{
|
{
|
||||||
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
||||||
|
@ -70,7 +86,7 @@ export const ALL_INTEGRATIONS = [
|
||||||
description: "Collect payments",
|
description: "Collect payments",
|
||||||
variant: "payment",
|
variant: "payment",
|
||||||
},
|
},
|
||||||
] as const;
|
] as Integration[];
|
||||||
|
|
||||||
function getIntegrations(userCredentials: CredentialData[]) {
|
function getIntegrations(userCredentials: CredentialData[]) {
|
||||||
const integrations = ALL_INTEGRATIONS.map((integration) => {
|
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 = {
|
export type OpeningHours = Pick<Availability, "days" | "startTime" | "endTime">;
|
||||||
days: number[];
|
export type DateOverride = Pick<Availability, "date" | "startTime" | "endTime">;
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DateOverride = {
|
|
||||||
date: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdvancedOptions = {
|
export type AdvancedOptions = {
|
||||||
eventName?: string;
|
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-phone-number-input": "^3.1.25",
|
||||||
"react-query": "^3.30.0",
|
"react-query": "^3.30.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-select": "^4.3.1",
|
"react-select": "^5.1.0",
|
||||||
"react-timezone-select": "^1.1.13",
|
"react-timezone-select": "^1.1.13",
|
||||||
"react-use-intercom": "1.4.0",
|
"react-use-intercom": "1.4.0",
|
||||||
"short-uuid": "^4.2.0",
|
"short-uuid": "^4.2.0",
|
||||||
|
@ -110,7 +110,6 @@
|
||||||
"@types/qrcode": "^1.4.1",
|
"@types/qrcode": "^1.4.1",
|
||||||
"@types/react": "^17.0.18",
|
"@types/react": "^17.0.18",
|
||||||
"@types/react-phone-number-input": "^3.0.13",
|
"@types/react-phone-number-input": "^3.0.13",
|
||||||
"@types/react-select": "^4.0.17",
|
|
||||||
"@types/stripe": "^8.0.417",
|
"@types/stripe": "^8.0.417",
|
||||||
"@types/uuid": "8.3.1",
|
"@types/uuid": "8.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
"@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) =>
|
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 =
|
const workingHours =
|
||||||
getWorkingHours(eventType.availability) ||
|
getWorkingHours(eventType.availability) ||
|
||||||
|
@ -176,6 +182,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventTypeObject.availability = [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
profile: {
|
profile: {
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
// import { getBusyVideoTimes } from "@lib/videoClient";
|
// import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const user = asStringOrNull(req.query.user);
|
const user = asStringOrNull(req.query.user);
|
||||||
const dateFrom = dayjs(asStringOrNull(req.query.dateFrom));
|
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 timeZone = eventType?.timeZone || currentUser.timeZone;
|
||||||
const defaultAvailability = {
|
const workingHours = eventType?.availability.length ? eventType.availability : currentUser.availability;
|
||||||
startTime: currentUser.startTime,
|
|
||||||
endTime: currentUser.endTime,
|
// FIXME: Currently the organizer timezone is used for the logic
|
||||||
days: [0, 1, 2, 3, 4, 5, 6],
|
// refactor to be organizerTimezone unaware, use UTC instead.
|
||||||
};
|
|
||||||
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 */
|
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
busy: bufferedBusyTimes,
|
busy: bufferedBusyTimes,
|
||||||
timeZone,
|
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 type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { OpeningHours } from "@lib/types/event-type";
|
||||||
|
|
||||||
function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
|
function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
|
||||||
if (!customInputs || !customInputs?.length) return undefined;
|
if (!customInputs || !customInputs?.length) return undefined;
|
||||||
|
@ -160,7 +161,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.body.availability) {
|
if (req.body.availability) {
|
||||||
const openingHours = req.body.availability.openingHours || [];
|
const openingHours: OpeningHours[] = req.body.availability.openingHours || [];
|
||||||
// const overrides = req.body.availability.dateOverrides || [];
|
// const overrides = req.body.availability.dateOverrides || [];
|
||||||
|
|
||||||
const eventTypeId = +req.body.id;
|
const eventTypeId = +req.body.id;
|
||||||
|
@ -172,20 +173,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all(
|
const availabilityToCreate = openingHours.map((openingHour) => ({
|
||||||
openingHours.map((schedule: Pick<Availability, "days" | "startTime" | "endTime">) =>
|
startTime: openingHour.startTime,
|
||||||
prisma.availability.create({
|
endTime: openingHour.endTime,
|
||||||
data: {
|
days: openingHour.days,
|
||||||
eventTypeId: +req.body.id,
|
}));
|
||||||
days: schedule.days,
|
|
||||||
startTime: schedule.startTime,
|
data.availability = {
|
||||||
endTime: schedule.endTime,
|
createMany: {
|
||||||
|
data: availabilityToCreate,
|
||||||
},
|
},
|
||||||
})
|
};
|
||||||
)
|
|
||||||
).catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = await prisma.eventType.update({
|
const eventType = await prisma.eventType.update({
|
||||||
|
|
|
@ -1,32 +1,76 @@
|
||||||
|
import { Availability } from "@prisma/client";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { TimeRange } from "@lib/types/schedule";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
|
const userId = session?.user?.id;
|
||||||
if (!session) {
|
if (!userId) {
|
||||||
res.status(401).json({ message: "Not authenticated" });
|
res.status(401).json({ message: "Not authenticated" });
|
||||||
return;
|
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") {
|
if (req.method === "POST") {
|
||||||
try {
|
try {
|
||||||
const createdSchedule = await prisma.schedule.create({
|
await prisma.availability.deleteMany({
|
||||||
data: {
|
where: {
|
||||||
freeBusyTimes: req.body.data.freeBusyTimes,
|
userId,
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: session.user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
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({
|
return res.status(200).json({
|
||||||
message: "created",
|
message: "created",
|
||||||
data: createdSchedule,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
@ -1,93 +1,66 @@
|
||||||
import { ClockIcon } from "@heroicons/react/outline";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
|
||||||
import showToast from "@lib/notification";
|
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 Shell from "@components/Shell";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Form } from "@components/form/fields";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
import Schedule, { DEFAULT_SCHEDULE } from "@components/ui/form/Schedule";
|
||||||
|
|
||||||
function convertMinsToHrsMins(mins: number) {
|
type FormValues = {
|
||||||
const h = Math.floor(mins / 60);
|
schedule: ScheduleType;
|
||||||
const m = mins % 60;
|
};
|
||||||
const hours = h < 10 ? "0" + h : h;
|
|
||||||
const minutes = m < 10 ? "0" + m : m;
|
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">) {
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
export default function Availability() {
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const queryMe = trpc.useQuery(["viewer.me"]);
|
|
||||||
const formModal = useToggleQuery("edit");
|
|
||||||
|
|
||||||
const formMethods = useForm<{
|
const createSchedule = async ({ schedule }: FormValues) => {
|
||||||
startHours: string;
|
const res = await fetch(`/api/schedule`, {
|
||||||
startMins: string;
|
method: "POST",
|
||||||
endHours: string;
|
body: JSON.stringify({ schedule }),
|
||||||
endMins: string;
|
headers: {
|
||||||
bufferHours: string;
|
"Content-Type": "application/json",
|
||||||
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") {
|
if (!res.ok) {
|
||||||
return <Loader />;
|
throw new Error((await res.json()).message);
|
||||||
}
|
}
|
||||||
if (queryMe.status !== "success") {
|
const responseData = await res.json();
|
||||||
return <Alert severity="error" title={t("something_went_wrong")} />;
|
showToast(t("availability_updated_successfully"), "success");
|
||||||
}
|
return responseData.data;
|
||||||
const user = queryMe.data;
|
};
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
schedule: props.schedule || DEFAULT_SCHEDULE,
|
||||||
|
},
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Shell heading={t("availability")} subtitle={t("configure_availability")}>
|
<Form
|
||||||
<div className="flex">
|
form={form}
|
||||||
<div className="w-1/2 mr-2 bg-white border border-gray-200 rounded-sm">
|
handleSubmit={async (values) => {
|
||||||
<div className="px-4 py-5 sm:p-6">
|
await createSchedule(values);
|
||||||
<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">
|
className="col-span-3 space-y-2 lg:col-span-2">
|
||||||
<p>
|
<div className="px-4 py-5 bg-white border border-gray-200 divide-y rounded-sm sm:p-6">
|
||||||
{t("current_start_date")} {convertMinsToHrsMins(user.startTime)} {t("and_end_at")}{" "}
|
<h3 className="mb-4 text-lg font-semibold leading-6 text-gray-900">{t("change_start_end")}</h3>
|
||||||
{convertMinsToHrsMins(user.endTime)}.
|
<Schedule name="schedule" />
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
<div className="text-right">
|
||||||
<Button href={formModal.hrefOn}>{t("change_available_times")}</Button>
|
<Button>{t("save")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Form>
|
||||||
</div>
|
<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 ">
|
||||||
<div className="w-1/2 ml-2 border border-gray-200 rounded-sm">
|
<h3 className="text-lg font-medium leading-6 text-gray-900">{t("something_doesnt_look_right")}</h3>
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
||||||
<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>
|
<p>{t("troubleshoot_availability")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
@ -98,148 +71,16 @@ export default function Availability() {
|
||||||
</div>
|
</div>
|
||||||
</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");
|
export default function Availability() {
|
||||||
})}>
|
const { t } = useLocale();
|
||||||
<div className="flex mb-4">
|
const query = trpc.useQuery(["viewer.availability"]);
|
||||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">
|
return (
|
||||||
{t("start_time")}
|
|
||||||
</label>
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="startHours" className="sr-only">
|
<Shell heading={t("availability")} subtitle={t("configure_availability")}>
|
||||||
{t("hours")}
|
<QueryCell query={query} success={({ data }) => <AvailabilityForm {...data} />} />
|
||||||
</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>
|
|
||||||
</Shell>
|
</Shell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -44,7 +44,7 @@ import updateEventType from "@lib/mutations/event-types/update-event-type";
|
||||||
import showToast from "@lib/notification";
|
import showToast from "@lib/notification";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { defaultAvatarSrc } from "@lib/profile";
|
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 { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
|
||||||
|
@ -112,7 +112,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
|
|
||||||
const [users, setUsers] = useState<AdvancedOptions["users"]>([]);
|
const [users, setUsers] = useState<AdvancedOptions["users"]>([]);
|
||||||
const [editIcon, setEditIcon] = useState(true);
|
const [editIcon, setEditIcon] = useState(true);
|
||||||
const [enteredAvailability, setEnteredAvailability] = useState();
|
const [enteredAvailability, setEnteredAvailability] = useState<{
|
||||||
|
openingHours: OpeningHours[];
|
||||||
|
dateOverrides: DateOverride[];
|
||||||
|
}>();
|
||||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
||||||
|
@ -851,7 +854,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
setAvailability={setEnteredAvailability}
|
setAvailability={setEnteredAvailability}
|
||||||
setTimeZone={setSelectedTimeZone}
|
setTimeZone={setSelectedTimeZone}
|
||||||
timeZone={selectedTimeZone}
|
timeZone={selectedTimeZone}
|
||||||
availability={availability}
|
availability={availability.map((schedule) => ({
|
||||||
|
...schedule,
|
||||||
|
startTime: new Date(schedule.startTime),
|
||||||
|
endTime: new Date(schedule.endTime),
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1253,7 +1260,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Availability = typeof eventType["availability"];
|
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) || [];
|
const availability = getAvailability(eventType.availability) || [];
|
||||||
availability.sort((a, b) => a.startTime - b.startTime);
|
availability.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
@ -1261,6 +1275,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
const eventTypeObject = Object.assign({}, eventType, {
|
const eventTypeObject = Object.assign({}, eventType, {
|
||||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||||
|
availability,
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamMembers = eventTypeObject.team
|
const teamMembers = eventTypeObject.team
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import debounce from "lodash/debounce";
|
import debounce from "lodash/debounce";
|
||||||
|
@ -11,6 +12,7 @@ import { useSession } from "next-auth/client";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import TimezoneSelect from "react-timezone-select";
|
import TimezoneSelect from "react-timezone-select";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
@ -18,14 +20,16 @@ import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import getIntegrations from "@lib/integrations/getIntegrations";
|
import getIntegrations from "@lib/integrations/getIntegrations";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
import { Schedule as ScheduleType } from "@lib/types/schedule";
|
||||||
|
|
||||||
import { ClientSuspense } from "@components/ClientSuspense";
|
import { ClientSuspense } from "@components/ClientSuspense";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
import { Form } from "@components/form/fields";
|
||||||
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
import SchedulerForm, { SCHEDULE_FORM_ID } from "@components/ui/Schedule/Schedule";
|
|
||||||
import Text from "@components/ui/Text";
|
import Text from "@components/ui/Text";
|
||||||
|
import Schedule, { DEFAULT_SCHEDULE } from "@components/ui/form/Schedule";
|
||||||
|
|
||||||
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
|
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
|
||||||
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
|
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(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
|
type ScheduleFormValues = {
|
||||||
|
schedule: ScheduleType;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Onboarding(props: inferSSRProps<typeof getServerSideProps>) {
|
export default function Onboarding(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
@ -96,10 +105,10 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
return responseData.data;
|
return responseData.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createSchedule = async (data: Prisma.ScheduleCreateInput) => {
|
const createSchedule = async ({ schedule }: ScheduleFormValues) => {
|
||||||
const res = await fetch(`/api/schedule`, {
|
const res = await fetch(`/api/schedule`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ data: { ...data } }),
|
body: JSON.stringify({ schedule }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
@ -118,16 +127,13 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
/** End Name */
|
/** End Name */
|
||||||
/** TimeZone */
|
/** TimeZone */
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState(props.user.timeZone ?? dayjs.tz.guess());
|
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 */
|
/** End TimeZone */
|
||||||
|
|
||||||
/** Onboarding Steps */
|
/** Onboarding Steps */
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const detectStep = () => {
|
const detectStep = () => {
|
||||||
let step = 0;
|
let step = 0;
|
||||||
const hasSetUserNameOrTimeZone = props.user.name && props.user.timeZone;
|
const hasSetUserNameOrTimeZone = props.user?.name && props.user?.timeZone;
|
||||||
if (hasSetUserNameOrTimeZone) {
|
if (hasSetUserNameOrTimeZone) {
|
||||||
step = 1;
|
step = 1;
|
||||||
}
|
}
|
||||||
|
@ -153,6 +159,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
steps[currentStep].onComplete &&
|
steps[currentStep].onComplete &&
|
||||||
typeof steps[currentStep].onComplete === "function"
|
typeof steps[currentStep].onComplete === "function"
|
||||||
) {
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
await steps[currentStep].onComplete!();
|
await steps[currentStep].onComplete!();
|
||||||
}
|
}
|
||||||
incrementStep();
|
incrementStep();
|
||||||
|
@ -222,6 +229,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
router.push("/event-types");
|
router.push("/event-types");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } });
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
id: t("welcome"),
|
id: t("welcome"),
|
||||||
|
@ -254,15 +262,13 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
</label>
|
</label>
|
||||||
<Text variant="caption">
|
<Text variant="caption">
|
||||||
{t("current_time")}:
|
{t("current_time")}:
|
||||||
<span className="text-black">{currentTime}</span>
|
<span className="text-black">{dayjs().tz(selectedTimeZone).format("LT")}</span>
|
||||||
</Text>
|
</Text>
|
||||||
</section>
|
</section>
|
||||||
<TimezoneSelect
|
<TimezoneSelect
|
||||||
id="timeZone"
|
id="timeZone"
|
||||||
value={selectedTimeZone}
|
value={selectedTimeZone}
|
||||||
onChange={({ value }) => {
|
onChange={({ value }) => setSelectedTimeZone(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"
|
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>
|
</fieldset>
|
||||||
|
@ -307,29 +313,30 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
title: t("set_availability"),
|
title: t("set_availability"),
|
||||||
description: t("set_availability_instructions"),
|
description: t("set_availability_instructions"),
|
||||||
Component: (
|
Component: (
|
||||||
<>
|
<Form
|
||||||
<section className="max-w-lg mx-auto text-black bg-white dark:bg-opacity-5 dark:text-white">
|
className="max-w-lg mx-auto text-black bg-white dark:bg-opacity-5 dark:text-white"
|
||||||
<SchedulerForm
|
form={availabilityForm}
|
||||||
onSubmit={async (data) => {
|
handleSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await createSchedule({
|
await createSchedule({ ...values });
|
||||||
freeBusyTimes: data,
|
|
||||||
});
|
|
||||||
debouncedHandleConfirmStep();
|
debouncedHandleConfirmStep();
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error as Error);
|
if (error instanceof Error) {
|
||||||
|
setError(error);
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
/>
|
}}>
|
||||||
</section>
|
<section>
|
||||||
|
<Schedule name="schedule" />
|
||||||
<footer className="flex flex-col py-6 space-y-6 sm:mx-auto sm:w-full">
|
<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}>
|
<Button className="justify-center" EndIcon={ArrowRightIcon} type="submit">
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</section>
|
||||||
|
</Form>
|
||||||
),
|
),
|
||||||
hideConfirm: true,
|
hideConfirm: true,
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
|
@ -401,6 +408,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
detectStep();
|
detectStep();
|
||||||
setReady(true);
|
setReady(true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (Sess[1] || !ready) {
|
if (Sess[1] || !ready) {
|
||||||
|
@ -471,12 +479,18 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
</section>
|
</section>
|
||||||
<section className="max-w-xl py-8 mx-auto">
|
<section className="max-w-xl py-8 mx-auto">
|
||||||
<div className="flex flex-row-reverse justify-between">
|
<div className="flex flex-row-reverse justify-between">
|
||||||
<button disabled={isSubmitting} onClick={handleSkipStep}>
|
<button
|
||||||
<Text variant="caption">Skip Step</Text>
|
disabled={isSubmitting}
|
||||||
|
onClick={handleSkipStep}
|
||||||
|
className="text-sm leading-tight text-gray-500 dark:text-white">
|
||||||
|
{t("next_step")}
|
||||||
</button>
|
</button>
|
||||||
{currentStep !== 0 && (
|
{currentStep !== 0 && (
|
||||||
<button disabled={isSubmitting} onClick={decrementStep}>
|
<button
|
||||||
<Text variant="caption">Prev Step</Text>
|
disabled={isSubmitting}
|
||||||
|
onClick={decrementStep}
|
||||||
|
className="text-sm leading-tight text-gray-500 dark:text-white">
|
||||||
|
{t("prev_step")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -66,8 +66,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
const [eventType] = team.eventTypes;
|
const [eventType] = team.eventTypes;
|
||||||
|
|
||||||
type Availability = typeof eventType["availability"];
|
type Availability = typeof eventType["availability"];
|
||||||
const getWorkingHours = (availability: Availability) => (availability?.length ? availability : null);
|
const getWorkingHours = (availability: Availability) =>
|
||||||
const workingHours = getWorkingHours(eventType.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);
|
workingHours.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
@ -76,6 +91,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventTypeObject.availability = [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
profile: {
|
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])
|
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||||
eventTypeId Int?
|
eventTypeId Int?
|
||||||
days Int[]
|
days Int[]
|
||||||
startTime Int
|
startTime DateTime @db.Time
|
||||||
endTime Int
|
endTime DateTime @db.Time
|
||||||
date DateTime? @db.Date
|
date DateTime? @db.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -218,6 +218,7 @@
|
||||||
"booking_already_cancelled": "This booking was already cancelled",
|
"booking_already_cancelled": "This booking was already cancelled",
|
||||||
"go_back_home": "Go back home",
|
"go_back_home": "Go back home",
|
||||||
"or_go_back_home": "Or go back home",
|
"or_go_back_home": "Or go back home",
|
||||||
|
"no_availability": "Unavailable",
|
||||||
"no_meeting_found": "No Meeting Found",
|
"no_meeting_found": "No Meeting Found",
|
||||||
"no_meeting_found_description": "This meeting does not exist. Contact the meeting owner for an updated link.",
|
"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",
|
"no_status_bookings_yet": "No {{status}} bookings, yet",
|
||||||
|
@ -461,6 +462,7 @@
|
||||||
"billing": "Billing",
|
"billing": "Billing",
|
||||||
"manage_your_billing_info": "Manage your billing information and cancel your subscription.",
|
"manage_your_billing_info": "Manage your billing information and cancel your subscription.",
|
||||||
"availability": "Availability",
|
"availability": "Availability",
|
||||||
|
"availability_updated_successfully": "Availability updated successfully",
|
||||||
"configure_availability": "Configure times when you are available for bookings.",
|
"configure_availability": "Configure times when you are available for bookings.",
|
||||||
"change_weekly_schedule": "Change your weekly schedule",
|
"change_weekly_schedule": "Change your weekly schedule",
|
||||||
"logo": "Logo",
|
"logo": "Logo",
|
||||||
|
@ -512,6 +514,8 @@
|
||||||
"confirm_delete_event_type": "Yes, delete event type",
|
"confirm_delete_event_type": "Yes, delete event type",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"next_step": "Skip step",
|
||||||
|
"prev_step": "Prev step",
|
||||||
"installed": "Installed",
|
"installed": "Installed",
|
||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"embed_your_calendar": "Embed your calendar within your webpage",
|
"embed_your_calendar": "Embed your calendar within your webpage",
|
||||||
|
|
|
@ -141,6 +141,7 @@
|
||||||
"booking_already_cancelled": "Deze afspraak is reeds geannuleerd",
|
"booking_already_cancelled": "Deze afspraak is reeds geannuleerd",
|
||||||
"go_back_home": "Terug naar startpagina",
|
"go_back_home": "Terug naar startpagina",
|
||||||
"or_go_back_home": "Of ga terug naar de startpagina",
|
"or_go_back_home": "Of ga terug naar de startpagina",
|
||||||
|
"no_availability": "Onbeschikbaar",
|
||||||
"no_meeting_found": "Afspraak Niet Gevonden",
|
"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_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",
|
"no_status_bookings_yet": "Nog geen {{status}} afspraken",
|
||||||
|
@ -435,5 +436,7 @@
|
||||||
"delete_event_type": "Verwijder Evenement",
|
"delete_event_type": "Verwijder Evenement",
|
||||||
"confirm_delete_event_type": "Ja, verwijder evenement",
|
"confirm_delete_event_type": "Ja, verwijder evenement",
|
||||||
"integrations": "Integraties",
|
"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 { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||||
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
||||||
import slugify from "@lib/slugify";
|
import slugify from "@lib/slugify";
|
||||||
|
import { Schedule } from "@lib/types/schedule";
|
||||||
|
|
||||||
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
|
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
|
||||||
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
|
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", {
|
.mutation("updateProfile", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
|
|
Loading…
Reference in a new issue