Merge pull request #293 from emrysal/feature/scheduling
Feature/scheduling
This commit is contained in:
commit
d701d3905d
23 changed files with 3484 additions and 1004 deletions
3
.babelrc
Normal file
3
.babelrc
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"presets": ["next/babel"]
|
||||
}
|
|
@ -24,7 +24,8 @@
|
|||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es6": true
|
||||
"es6": true,
|
||||
"jest": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
|
|
|
@ -1,112 +1,40 @@
|
|||
import dayjs from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
dayjs.extend(isBetween);
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import getSlots from "../../lib/slots";
|
||||
import Link from "next/link";
|
||||
import { timeZone } from "../../lib/clock";
|
||||
import { useRouter } from "next/router";
|
||||
import Slots from "./Slots";
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
|
||||
const AvailableTimes = (props) => {
|
||||
const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat, user }) => {
|
||||
const router = useRouter();
|
||||
const { user, rescheduleUid } = router.query;
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const times = useMemo(() => {
|
||||
const slots = getSlots({
|
||||
calendarTimeZone: props.user.timeZone,
|
||||
selectedTimeZone: timeZone(),
|
||||
eventLength: props.eventType.length,
|
||||
selectedDate: props.date,
|
||||
dayStartTime: props.user.startTime,
|
||||
dayEndTime: props.user.endTime,
|
||||
});
|
||||
|
||||
return slots;
|
||||
}, [props.date]);
|
||||
|
||||
const handleAvailableSlots = (busyTimes: []) => {
|
||||
// Check for conflicts
|
||||
for (let i = times.length - 1; i >= 0; i -= 1) {
|
||||
busyTimes.forEach((busyTime) => {
|
||||
const startTime = dayjs(busyTime.start);
|
||||
const endTime = dayjs(busyTime.end);
|
||||
|
||||
// Check if start times are the same
|
||||
if (dayjs(times[i]).format("HH:mm") == startTime.format("HH:mm")) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if time is between start and end times
|
||||
if (dayjs(times[i]).isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if slot end time is between start and end time
|
||||
if (dayjs(times[i]).add(props.eventType.length, "minutes").isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if startTime is between slot
|
||||
if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, "minutes"))) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Display available times
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
// Re-render only when invitee changes date
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setError(false);
|
||||
fetch(
|
||||
`/api/availability/${user}?dateFrom=${props.date.startOf("day").utc().format()}&dateTo=${props.date
|
||||
.endOf("day")
|
||||
.utc()
|
||||
.format()}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then(handleAvailableSlots)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setError(true);
|
||||
});
|
||||
}, [props.date]);
|
||||
|
||||
const { rescheduleUid } = router.query;
|
||||
const { slots, isFullyBooked, hasErrors } = Slots({ date, eventLength, workingHours });
|
||||
return (
|
||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
||||
<div className="text-gray-600 font-light text-xl mb-4 text-left">
|
||||
<span className="w-1/2">{props.date.format("dddd DD MMMM YYYY")}</span>
|
||||
<span className="w-1/2">{date.format("dddd DD MMMM YYYY")}</span>
|
||||
</div>
|
||||
{!error &&
|
||||
loaded &&
|
||||
times.length > 0 &&
|
||||
times.map((time) => (
|
||||
<div key={dayjs(time).utc().format()}>
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => (
|
||||
<div key={slot.format()}>
|
||||
<Link
|
||||
href={
|
||||
`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` +
|
||||
`/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
|
||||
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
|
||||
}>
|
||||
<a
|
||||
key={dayjs(time).format("hh:mma")}
|
||||
className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
|
||||
{dayjs(time).tz(timeZone()).format(props.timeFormat)}
|
||||
<a className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
|
||||
{slot.format(timeFormat)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
{!error && loaded && times.length == 0 && (
|
||||
{isFullyBooked && (
|
||||
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
|
||||
<h1 className="text-xl font">{props.user.name} is all booked today.</h1>
|
||||
<h1 className="text-xl font">{user.name} is all booked today.</h1>
|
||||
</div>
|
||||
)}
|
||||
{!error && !loaded && <div className="loader" />}
|
||||
{error && (
|
||||
|
||||
{!isFullyBooked && slots.length === 0 && !hasErrors && <div className="loader" />}
|
||||
|
||||
{hasErrors && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
|
@ -116,9 +44,9 @@ const AvailableTimes = (props) => {
|
|||
<p className="text-sm text-yellow-700">
|
||||
Could not load the available time slots.{" "}
|
||||
<a
|
||||
href={"mailto:" + props.user.email}
|
||||
href={"mailto:" + user.email}
|
||||
className="font-medium underline text-yellow-700 hover:text-yellow-600">
|
||||
Contact {props.user.name} via e-mail
|
||||
Contact {user.name} via e-mail
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
|
134
components/booking/DatePicker.tsx
Normal file
134
components/booking/DatePicker.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import { useEffect, useState } from "react";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import getSlots from "@lib/slots";
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const DatePicker = ({
|
||||
weekStart,
|
||||
onDatePicked,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
inviteeTimeZone,
|
||||
eventLength,
|
||||
}) => {
|
||||
const [calendar, setCalendar] = useState([]);
|
||||
const [selectedMonth, setSelectedMonth]: number = useState();
|
||||
const [selectedDate, setSelectedDate]: Dayjs = useState();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedMonth(dayjs().tz(inviteeTimeZone).month());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) onDatePicked(selectedDate);
|
||||
}, [selectedDate]);
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth + 1);
|
||||
};
|
||||
|
||||
const decrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMonth) {
|
||||
// wish next had a way of dealing with this magically;
|
||||
return;
|
||||
}
|
||||
|
||||
const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth);
|
||||
|
||||
const isDisabled = (day: number) => {
|
||||
const date: Dayjs = inviteeDate.date(day);
|
||||
return (
|
||||
date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
}).length
|
||||
);
|
||||
};
|
||||
|
||||
// Set up calendar
|
||||
const daysInMonth = inviteeDate.daysInMonth();
|
||||
const days = [];
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = inviteeDate.date(1).day();
|
||||
if (weekStart === "Monday") {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||
}
|
||||
const emptyDays = Array(weekdayOfFirst)
|
||||
.fill(null)
|
||||
.map((day, i) => (
|
||||
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
||||
{null}
|
||||
</div>
|
||||
));
|
||||
|
||||
// Combine placeholder days with actual days
|
||||
setCalendar([
|
||||
...emptyDays,
|
||||
...days.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => setSelectedDate(inviteeDate.date(day))}
|
||||
disabled={isDisabled(day)}
|
||||
className={
|
||||
"text-center w-10 h-10 rounded-full mx-auto" +
|
||||
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
|
||||
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
|
||||
? " bg-blue-600 text-white-important"
|
||||
: !isDisabled(day)
|
||||
? " bg-blue-50"
|
||||
: "")
|
||||
}>
|
||||
{day}
|
||||
</button>
|
||||
)),
|
||||
]);
|
||||
}, [selectedMonth, inviteeTimeZone, selectedDate]);
|
||||
|
||||
return selectedMonth ? (
|
||||
<div className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}>
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
||||
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
|
||||
<div className="w-1/2 text-right">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={"mr-4 " + (selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400")}
|
||||
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={incrementMonth}>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||
.map((weekDay) => (
|
||||
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
{weekDay}
|
||||
</div>
|
||||
))}
|
||||
{calendar}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default DatePicker;
|
96
components/booking/Slots.tsx
Normal file
96
components/booking/Slots.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import getSlots from "../../lib/slots";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(utc);
|
||||
|
||||
type Props = {
|
||||
eventLength: number;
|
||||
minimumBookingNotice?: number;
|
||||
date: Dayjs;
|
||||
};
|
||||
|
||||
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerUtcOffset }: Props) => {
|
||||
minimumBookingNotice = minimumBookingNotice || 0;
|
||||
|
||||
const router = useRouter();
|
||||
const { user } = router.query;
|
||||
const [slots, setSlots] = useState([]);
|
||||
const [isFullyBooked, setIsFullyBooked] = useState(false);
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSlots([]);
|
||||
setIsFullyBooked(false);
|
||||
setHasErrors(false);
|
||||
fetch(
|
||||
`/api/availability/${user}?dateFrom=${date.startOf("day").utc().startOf("day").format()}&dateTo=${date
|
||||
.endOf("day")
|
||||
.utc()
|
||||
.endOf("day")
|
||||
.format()}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then(handleAvailableSlots)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setHasErrors(true);
|
||||
});
|
||||
}, [date]);
|
||||
|
||||
const handleAvailableSlots = (busyTimes: []) => {
|
||||
const times = getSlots({
|
||||
frequency: eventLength,
|
||||
inviteeDate: date,
|
||||
workingHours,
|
||||
minimumBookingNotice,
|
||||
organizerUtcOffset,
|
||||
});
|
||||
|
||||
const timesLengthBeforeConflicts: number = times.length;
|
||||
|
||||
// Check for conflicts
|
||||
for (let i = times.length - 1; i >= 0; i -= 1) {
|
||||
busyTimes.every((busyTime): boolean => {
|
||||
const startTime = dayjs(busyTime.start).utc();
|
||||
const endTime = dayjs(busyTime.end).utc();
|
||||
// Check if start times are the same
|
||||
if (times[i].utc().isSame(startTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if time is between start and end times
|
||||
else if (times[i].utc().isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if slot end time is between start and end time
|
||||
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if startTime is between slot
|
||||
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
|
||||
times.splice(i, 1);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
|
||||
setIsFullyBooked(true);
|
||||
}
|
||||
// Display available times
|
||||
setSlots(times);
|
||||
};
|
||||
|
||||
return {
|
||||
slots,
|
||||
isFullyBooked,
|
||||
hasErrors,
|
||||
};
|
||||
};
|
||||
|
||||
export default Slots;
|
|
@ -1,15 +1,14 @@
|
|||
import { Switch } from "@headlessui/react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import { useEffect, useState } from "react";
|
||||
import {timeZone, is24h} from '../../lib/clock';
|
||||
import { timeZone, is24h } from "../../lib/clock";
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const TimeOptions = (props) => {
|
||||
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState('');
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [is24hClock, setIs24hClock] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -18,22 +17,22 @@ const TimeOptions = (props) => {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
|
||||
props.onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
}
|
||||
}, [selectedTimeZone]);
|
||||
|
||||
useEffect(() => {
|
||||
props.onToggle24hClock(is24h(is24hClock));
|
||||
}, [is24hClock]);
|
||||
|
||||
return selectedTimeZone !== "" && (
|
||||
return (
|
||||
selectedTimeZone !== "" && (
|
||||
<div className="w-full rounded shadow border bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group
|
||||
as="div"
|
||||
className="flex items-center justify-end"
|
||||
>
|
||||
<Switch.Group as="div" className="flex items-center justify-end">
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm text-gray-500">am/pm</span>
|
||||
</Switch.Label>
|
||||
|
@ -43,8 +42,7 @@ const TimeOptions = (props) => {
|
|||
className={classNames(
|
||||
is24hClock ? "bg-blue-600" : "bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
@ -67,7 +65,8 @@ const TimeOptions = (props) => {
|
|||
className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TimeOptions;
|
22
components/ui/PoweredByCalendso.tsx
Normal file
22
components/ui/PoweredByCalendso.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Link from "next/link";
|
||||
|
||||
const PoweredByCalendso = (props) => (
|
||||
<div className="text-xs text-right pt-1">
|
||||
<Link href="https://calendso.com">
|
||||
<a
|
||||
style={{ color: "#104D86" }}
|
||||
className="opacity-50 hover:opacity-100"
|
||||
>
|
||||
powered by{" "}
|
||||
<img
|
||||
style={{ top: -2 }}
|
||||
className="w-auto inline h-3 relative"
|
||||
src="/calendso-logo-word.svg"
|
||||
alt="Calendso Logo"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PoweredByCalendso;
|
143
components/ui/Scheduler.tsx
Normal file
143
components/ui/Scheduler.tsx
Normal file
|
@ -0,0 +1,143 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { WeekdaySelect } from "./WeekdaySelect";
|
||||
import SetTimesModal from "./modal/SetTimesModal";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { Availability } from "@prisma/client";
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type Props = {
|
||||
timeZone: string;
|
||||
availability: Availability[];
|
||||
setTimeZone: unknown;
|
||||
};
|
||||
|
||||
export const Scheduler = ({
|
||||
availability,
|
||||
setAvailability,
|
||||
timeZone: selectedTimeZone,
|
||||
setTimeZone,
|
||||
}: Props) => {
|
||||
const [editSchedule, setEditSchedule] = useState(-1);
|
||||
const [dateOverrides, setDateOverrides] = useState([]);
|
||||
const [openingHours, setOpeningHours] = useState([]);
|
||||
|
||||
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));
|
||||
}, []);
|
||||
|
||||
// updates availability to how it should be formatted outside this component.
|
||||
useEffect(() => {
|
||||
setAvailability({
|
||||
dateOverrides: dateOverrides,
|
||||
openingHours: openingHours,
|
||||
});
|
||||
}, [dateOverrides, openingHours]);
|
||||
|
||||
const addNewSchedule = () => setEditSchedule(openingHours.length);
|
||||
|
||||
const applyEditSchedule = (changed) => {
|
||||
// new entry
|
||||
if (!changed.days) {
|
||||
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
|
||||
setOpeningHours(openingHours.concat(changed));
|
||||
} else {
|
||||
// update
|
||||
const replaceWith = { ...openingHours[editSchedule], ...changed };
|
||||
openingHours.splice(editSchedule, 1, replaceWith);
|
||||
setOpeningHours([].concat(openingHours));
|
||||
}
|
||||
};
|
||||
|
||||
const removeScheduleAt = (toRemove: number) => {
|
||||
openingHours.splice(toRemove, 1);
|
||||
setOpeningHours([].concat(openingHours));
|
||||
};
|
||||
|
||||
const OpeningHours = ({ idx, item }) => (
|
||||
<li className="py-2 flex justify-between border-t">
|
||||
<div className="inline-flex ml-2">
|
||||
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
|
||||
<button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}>
|
||||
{dayjs()
|
||||
.startOf("day")
|
||||
.add(item.startTime, "minutes")
|
||||
.format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
|
||||
until
|
||||
{dayjs()
|
||||
.startOf("day")
|
||||
.add(item.endTime, "minutes")
|
||||
.format(item.endTime % 60 === 0 ? "ha" : "h:mma")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeScheduleAt(idx)}
|
||||
className="btn-sm bg-transparent px-2 py-1 ml-1">
|
||||
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded border flex">
|
||||
<div className="w-3/5">
|
||||
<div className="w-3/4 p-2">
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
Timezone
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz) => setTimeZone(tz.value)}
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{openingHours.map((item, idx) => (
|
||||
<OpeningHours key={idx} idx={idx} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
<hr />
|
||||
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">
|
||||
Add another
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-l p-2 w-2/5 text-sm bg-gray-50">
|
||||
{/*<p className="font-bold mb-2">Add date overrides</p>
|
||||
<p className="mb-2">
|
||||
Add dates when your availability changes from your weekly hours
|
||||
</p>
|
||||
<button className="btn-sm btn-white">Add a date override</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 })}
|
||||
onExit={() => setEditSchedule(-1)}
|
||||
/>
|
||||
)}
|
||||
{/*{showDateOverrideModal &&
|
||||
<DateOverrideModal />
|
||||
}*/}
|
||||
</div>
|
||||
);
|
||||
};
|
53
components/ui/WeekdaySelect.tsx
Normal file
53
components/ui/WeekdaySelect.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export const WeekdaySelect = (props) => {
|
||||
const [activeDays, setActiveDays] = useState(
|
||||
[...Array(7).keys()].map((v, i) => (props.defaultValue || []).includes(i))
|
||||
);
|
||||
|
||||
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
useEffect(() => {
|
||||
props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
|
||||
}, [activeDays]);
|
||||
|
||||
const toggleDay = (e, idx: number) => {
|
||||
e.preventDefault();
|
||||
activeDays[idx] = !activeDays[idx];
|
||||
setActiveDays([].concat(activeDays));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="weekdaySelect">
|
||||
<div className="inline-flex">
|
||||
{days.map((day, idx) =>
|
||||
activeDays[idx] ? (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={(e) => toggleDay(e, idx)}
|
||||
style={{ marginLeft: "-2px" }}
|
||||
className={`
|
||||
active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded
|
||||
${activeDays[idx + 1] ? "rounded-r-none" : ""}
|
||||
${activeDays[idx - 1] ? "rounded-l-none" : ""}
|
||||
${idx === 0 ? "rounded-l" : ""}
|
||||
${idx === days.length - 1 ? "rounded-r" : ""}
|
||||
`}>
|
||||
{day}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={(e) => toggleDay(e, idx)}
|
||||
style={{ marginTop: "1px", marginBottom: "1px" }}
|
||||
className={`border focus:outline-none px-2 py-1 rounded-none ${
|
||||
idx === 0 ? "rounded-l" : "border-l-0"
|
||||
} ${idx === days.length - 1 ? "rounded-r" : ""}`}>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
146
components/ui/modal/SetTimesModal.tsx
Normal file
146
components/ui/modal/SetTimesModal.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { ClockIcon } from "@heroicons/react/outline";
|
||||
import { useRef } from "react";
|
||||
|
||||
export default function SetTimesModal(props) {
|
||||
const [startHours, startMinutes] = [Math.floor(props.startTime / 60), props.startTime % 60];
|
||||
const [endHours, endMinutes] = [Math.floor(props.endTime / 60), props.endTime % 60];
|
||||
|
||||
const startHoursRef = useRef<HTMLInputElement>();
|
||||
const startMinsRef = useRef<HTMLInputElement>();
|
||||
const endHoursRef = useRef<HTMLInputElement>();
|
||||
const endMinsRef = useRef<HTMLInputElement>();
|
||||
|
||||
function updateStartEndTimesHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const enteredStartHours = parseInt(startHoursRef.current.value);
|
||||
const enteredStartMins = parseInt(startMinsRef.current.value);
|
||||
const enteredEndHours = parseInt(endHoursRef.current.value);
|
||||
const enteredEndMins = parseInt(endMinsRef.current.value);
|
||||
|
||||
props.onChange({
|
||||
startTime: enteredStartHours * 60 + enteredStartMins,
|
||||
endTime: enteredEndHours * 60 + enteredEndMins,
|
||||
});
|
||||
|
||||
props.onExit(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<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-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ClockIcon className="h-6 w-6 text-blue-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">
|
||||
Change when you are available for bookings
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Set your work schedule</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mb-4">
|
||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label>
|
||||
<div>
|
||||
<label htmlFor="startHours" className="sr-only">
|
||||
Hours
|
||||
</label>
|
||||
<input
|
||||
ref={startHoursRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
maxLength="2"
|
||||
name="hours"
|
||||
id="startHours"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="9"
|
||||
defaultValue={startHours}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<div>
|
||||
<label htmlFor="startMinutes" className="sr-only">
|
||||
Minutes
|
||||
</label>
|
||||
<input
|
||||
ref={startMinsRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
step="15"
|
||||
maxLength="2"
|
||||
name="minutes"
|
||||
id="startMinutes"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="30"
|
||||
defaultValue={startMinutes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label>
|
||||
<div>
|
||||
<label htmlFor="endHours" className="sr-only">
|
||||
Hours
|
||||
</label>
|
||||
<input
|
||||
ref={endHoursRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
maxLength="2"
|
||||
name="hours"
|
||||
id="endHours"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="17"
|
||||
defaultValue={endHours}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<div>
|
||||
<label htmlFor="endMinutes" className="sr-only">
|
||||
Minutes
|
||||
</label>
|
||||
<input
|
||||
ref={endMinsRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
maxLength="2"
|
||||
step="15"
|
||||
name="minutes"
|
||||
id="endMinutes"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="30"
|
||||
defaultValue={endMinutes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button onClick={updateStartEndTimesHandler} type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
11
lib/jsonUtils.ts
Normal file
11
lib/jsonUtils.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export const validJson = (jsonString: string) => {
|
||||
try {
|
||||
const o = JSON.parse(jsonString);
|
||||
if (o && typeof o === "object") {
|
||||
return o;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Invalid JSON:", e);
|
||||
}
|
||||
return false;
|
||||
};
|
208
lib/slots.ts
208
lib/slots.ts
|
@ -1,94 +1,134 @@
|
|||
const dayjs = require("dayjs");
|
||||
|
||||
const isToday = require("dayjs/plugin/isToday");
|
||||
const utc = require("dayjs/plugin/utc");
|
||||
const timezone = require("dayjs/plugin/timezone");
|
||||
|
||||
dayjs.extend(isToday);
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const getMinutesFromMidnight = (date) => {
|
||||
return date.hour() * 60 + date.minute();
|
||||
type WorkingHour = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
type GetSlots = {
|
||||
inviteeDate: Dayjs;
|
||||
frequency: number;
|
||||
workingHours: WorkingHour[];
|
||||
minimumBookingNotice?: number;
|
||||
organizerTimeZone: string;
|
||||
};
|
||||
|
||||
type Boundary = {
|
||||
lowerBound: number;
|
||||
upperBound: number;
|
||||
};
|
||||
|
||||
const freqApply = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency;
|
||||
|
||||
const intersectBoundary = (a: Boundary, b: Boundary) => {
|
||||
if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
lowerBound: Math.max(b.lowerBound, a.lowerBound),
|
||||
upperBound: Math.min(b.upperBound, a.upperBound),
|
||||
};
|
||||
};
|
||||
|
||||
// say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
|
||||
const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
|
||||
boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
|
||||
|
||||
const organizerBoundaries = (
|
||||
workingHours: [],
|
||||
inviteeDate: Dayjs,
|
||||
inviteeBounds: Boundary,
|
||||
organizerTimeZone
|
||||
): Boundary[] => {
|
||||
const boundaries: Boundary[] = [];
|
||||
|
||||
const startDay: number = +inviteeDate
|
||||
.utc()
|
||||
.startOf("day")
|
||||
.add(inviteeBounds.lowerBound, "minutes")
|
||||
.format("d");
|
||||
const endDay: number = +inviteeDate
|
||||
.utc()
|
||||
.startOf("day")
|
||||
.add(inviteeBounds.upperBound, "minutes")
|
||||
.format("d");
|
||||
|
||||
workingHours.forEach((item) => {
|
||||
const lowerBound: number = item.startTime - dayjs().tz(organizerTimeZone).utcOffset();
|
||||
const upperBound: number = item.endTime - dayjs().tz(organizerTimeZone).utcOffset();
|
||||
if (startDay !== endDay) {
|
||||
if (inviteeBounds.lowerBound < 0) {
|
||||
// lowerBound edges into the previous day
|
||||
if (item.days.includes(startDay)) {
|
||||
boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 });
|
||||
}
|
||||
if (item.days.includes(endDay)) {
|
||||
boundaries.push({ lowerBound, upperBound });
|
||||
}
|
||||
} else {
|
||||
// upperBound edges into the next day
|
||||
if (item.days.includes(endDay)) {
|
||||
boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 });
|
||||
}
|
||||
if (item.days.includes(startDay)) {
|
||||
boundaries.push({ lowerBound, upperBound });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
boundaries.push({ lowerBound, upperBound });
|
||||
}
|
||||
});
|
||||
return boundaries;
|
||||
};
|
||||
|
||||
const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => {
|
||||
const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency);
|
||||
const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency);
|
||||
return {
|
||||
lowerBound,
|
||||
upperBound,
|
||||
};
|
||||
};
|
||||
|
||||
const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => {
|
||||
const slots: Dayjs[] = [];
|
||||
for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
|
||||
slots.push(
|
||||
<Dayjs>dayjs
|
||||
.utc()
|
||||
.startOf("day")
|
||||
.add(lowerBound + minutes, "minutes")
|
||||
);
|
||||
}
|
||||
return slots;
|
||||
};
|
||||
|
||||
const getSlots = ({
|
||||
calendarTimeZone,
|
||||
eventLength,
|
||||
selectedTimeZone,
|
||||
selectedDate,
|
||||
dayStartTime,
|
||||
dayEndTime
|
||||
}) => {
|
||||
inviteeDate,
|
||||
frequency,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
}: GetSlots): Dayjs[] => {
|
||||
const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
|
||||
? inviteeDate.hour() * 60 + inviteeDate.minute() + (minimumBookingNotice || 0)
|
||||
: 0;
|
||||
|
||||
if(!selectedDate) return []
|
||||
const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
|
||||
|
||||
const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day");
|
||||
|
||||
// Simple case, same timezone
|
||||
if (calendarTimeZone === selectedTimeZone) {
|
||||
const slots = [];
|
||||
const now = dayjs();
|
||||
for (
|
||||
let minutes = dayStartTime;
|
||||
minutes <= dayEndTime - eventLength;
|
||||
minutes += parseInt(eventLength, 10)
|
||||
) {
|
||||
const slot = lowerBound.add(minutes, "minutes");
|
||||
if (slot > now) {
|
||||
slots.push(slot);
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
const upperBound = selectedDate.tz(selectedTimeZone).endOf("day");
|
||||
|
||||
// We need to start generating slots from the start of the calendarTimeZone day
|
||||
const startDateTime = lowerBound
|
||||
.tz(calendarTimeZone)
|
||||
.startOf("day")
|
||||
.add(dayStartTime, "minutes");
|
||||
|
||||
let phase = 0;
|
||||
if (startDateTime < lowerBound) {
|
||||
// Getting minutes of the first event in the day of the chooser
|
||||
const diff = lowerBound.diff(startDateTime, "minutes");
|
||||
|
||||
// finding first event
|
||||
phase = diff + eventLength - (diff % eventLength);
|
||||
}
|
||||
|
||||
// We can stop as soon as the selectedTimeZone day ends
|
||||
const endDateTime = upperBound
|
||||
.tz(calendarTimeZone)
|
||||
.subtract(eventLength, "minutes");
|
||||
|
||||
const maxMinutes = endDateTime.diff(startDateTime, "minutes");
|
||||
|
||||
const slots = [];
|
||||
const now = dayjs();
|
||||
for (
|
||||
let minutes = phase;
|
||||
minutes <= maxMinutes;
|
||||
minutes += parseInt(eventLength, 10)
|
||||
) {
|
||||
const slot = startDateTime.add(minutes, "minutes");
|
||||
|
||||
const minutesFromMidnight = getMinutesFromMidnight(slot);
|
||||
|
||||
if (
|
||||
minutesFromMidnight < dayStartTime ||
|
||||
minutesFromMidnight > dayEndTime - eventLength ||
|
||||
slot < now
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
slots.push(slot.tz(selectedTimeZone));
|
||||
}
|
||||
|
||||
return slots;
|
||||
return getOverlaps(
|
||||
inviteeBounds,
|
||||
organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone)
|
||||
)
|
||||
.reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
|
||||
.map((slot) =>
|
||||
slot.month(inviteeDate.month()).date(inviteeDate.date()).utcOffset(inviteeDate.utcOffset())
|
||||
);
|
||||
};
|
||||
|
||||
export default getSlots
|
||||
export default getSlots;
|
||||
|
|
18
package.json
18
package.json
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "prisma generate",
|
||||
|
@ -38,6 +39,7 @@
|
|||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/node": "^14.14.33",
|
||||
"@types/nodemailer": "^6.4.2",
|
||||
"@types/react": "^17.0.3",
|
||||
|
@ -50,7 +52,9 @@
|
|||
"eslint-plugin-react": "^7.24.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"husky": "^6.0.0",
|
||||
"jest": "^27.0.5",
|
||||
"lint-staged": "^11.0.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"postcss": "^8.2.8",
|
||||
"prettier": "^2.3.1",
|
||||
"prisma": "^2.23.0",
|
||||
|
@ -62,5 +66,19 @@
|
|||
"prettier --write",
|
||||
"eslint"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"verbose": true,
|
||||
"extensionsToTreatAsEsm": [
|
||||
".ts"
|
||||
],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"ts"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@components(.*)$": "<rootDir>/components$1",
|
||||
"^@lib(.*)$": "<rootDir>/lib$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,115 +1,47 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { useRouter } from "next/router";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import {
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
import { Dayjs } from "dayjs";
|
||||
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||
import AvailableTimes from "../../components/booking/AvailableTimes";
|
||||
import TimeOptions from "../../components/booking/TimeOptions";
|
||||
import Avatar from "../../components/Avatar";
|
||||
import { timeZone } from "../../lib/clock";
|
||||
import DatePicker from "../../components/booking/DatePicker";
|
||||
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
|
||||
|
||||
export default function Type(props): Type {
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
|
||||
// Initialise state
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState("h:mma");
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect((): void => {
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
||||
}, [telemetry]);
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth + 1);
|
||||
};
|
||||
|
||||
const decrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth - 1);
|
||||
};
|
||||
|
||||
// Set up calendar
|
||||
const daysInMonth = dayjs().month(selectedMonth).daysInMonth();
|
||||
const days = [];
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
|
||||
if (props.user.weekStart === "Monday") {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||
}
|
||||
const emptyDays = Array(weekdayOfFirst)
|
||||
.fill(null)
|
||||
.map((day, i) => (
|
||||
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
||||
{null}
|
||||
</div>
|
||||
));
|
||||
|
||||
const changeDate = (day): void => {
|
||||
const changeDate = (date: Dayjs) => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
|
||||
setSelectedDate(dayjs().month(selectedMonth).date(day));
|
||||
setSelectedDate(date);
|
||||
};
|
||||
|
||||
// Combine placeholder days with actual days
|
||||
const calendar = [
|
||||
...emptyDays,
|
||||
...days.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => changeDate(day)}
|
||||
disabled={
|
||||
selectedMonth < parseInt(dayjs().format("MM")) && dayjs().month(selectedMonth).format("D") > day
|
||||
}
|
||||
className={
|
||||
"text-center w-10 h-10 rounded-full mx-auto " +
|
||||
(dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth))
|
||||
? "bg-blue-50 text-blue-600 font-medium"
|
||||
: "text-gray-400 font-light") +
|
||||
(dayjs(selectedDate).month(selectedMonth).format("D") == day
|
||||
? " bg-blue-600 text-white-important"
|
||||
: "")
|
||||
}>
|
||||
{day}
|
||||
</button>
|
||||
)),
|
||||
];
|
||||
|
||||
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
||||
if (selectedDate) {
|
||||
setSelectedDate(selectedDate.tz(selectedTimeZone));
|
||||
}
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
const handleToggle24hClock = (is24hClock: boolean): void => {
|
||||
if (selectedDate) {
|
||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -190,63 +122,27 @@ export default function Type(props): Type {
|
|||
)}
|
||||
<p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}>
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
||||
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
|
||||
<div className="w-1/2 text-right">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")}
|
||||
disabled={selectedMonth < parseInt(dayjs().format("MM"))}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={incrementMonth}>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||
{props.user.weekStart !== "Monday" ? (
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Sun</div>
|
||||
) : null}
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Mon</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Tue</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Wed</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Thu</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Fri</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Sat</div>
|
||||
{props.user.weekStart === "Monday" ? (
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Sun</div>
|
||||
) : null}
|
||||
{calendar}
|
||||
</div>
|
||||
</div>
|
||||
<DatePicker
|
||||
weekStart={props.user.weekStart}
|
||||
onDatePicked={changeDate}
|
||||
workingHours={props.workingHours}
|
||||
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
|
||||
inviteeTimeZone={timeZone()}
|
||||
eventLength={props.eventType.length}
|
||||
/>
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
workingHours={props.workingHours}
|
||||
timeFormat={timeFormat}
|
||||
user={props.user}
|
||||
eventType={props.eventType}
|
||||
eventLength={props.eventType.length}
|
||||
eventTypeId={props.eventType.id}
|
||||
date={selectedDate}
|
||||
user={props.user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!props.user.hideBranding && (
|
||||
<div className="text-xs text-right pt-1">
|
||||
<Link href="https://calendso.com">
|
||||
<a style={{ color: "#104D86" }} className="opacity-50 hover:opacity-100">
|
||||
powered by{" "}
|
||||
<img
|
||||
style={{ top: -2 }}
|
||||
className="w-auto inline h-3 relative"
|
||||
src="/calendso-logo-word.svg"
|
||||
alt="Calendso Logo"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!props.user.hideBranding && <PoweredByCalendso />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
@ -269,6 +165,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||
timeZone: true,
|
||||
endTime: true,
|
||||
weekStart: true,
|
||||
availability: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
@ -291,6 +188,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||
title: true,
|
||||
description: true,
|
||||
length: true,
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -300,10 +199,27 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||
};
|
||||
}
|
||||
|
||||
const getWorkingHours = (providesAvailability) =>
|
||||
providesAvailability.availability && providesAvailability.availability.length
|
||||
? providesAvailability.availability
|
||||
: null;
|
||||
|
||||
const workingHours: [] =
|
||||
getWorkingHours(eventType) ||
|
||||
getWorkingHours(user) ||
|
||||
[
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: user.startTime,
|
||||
endTime: user.endTime,
|
||||
},
|
||||
].filter((availability): boolean => typeof availability["days"] !== "undefined");
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
eventType,
|
||||
workingHours,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import {getSession} from 'next-auth/client';
|
||||
import prisma from '../../../lib/prisma';
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/client";
|
||||
import prisma from "../../../lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
@ -10,7 +10,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}
|
||||
|
||||
if (req.method == "PATCH" || req.method == "POST") {
|
||||
|
||||
const data = {
|
||||
title: req.body.title,
|
||||
slug: req.body.slug,
|
||||
|
@ -25,57 +24,88 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
deleteMany: {
|
||||
eventTypeId: req.body.id,
|
||||
NOT: {
|
||||
id: {in: req.body.customInputs.filter(input => !!input.id).map(e => e.id)}
|
||||
}
|
||||
id: { in: req.body.customInputs.filter((input) => !!input.id).map((e) => e.id) },
|
||||
},
|
||||
},
|
||||
createMany: {
|
||||
data: req.body.customInputs.filter(input => !input.id).map(input => ({
|
||||
data: req.body.customInputs
|
||||
.filter((input) => !input.id)
|
||||
.map((input) => ({
|
||||
type: input.type,
|
||||
label: input.label,
|
||||
required: input.required
|
||||
}))
|
||||
required: input.required,
|
||||
})),
|
||||
},
|
||||
update: req.body.customInputs.filter(input => !!input.id).map(input => ({
|
||||
update: req.body.customInputs
|
||||
.filter((input) => !!input.id)
|
||||
.map((input) => ({
|
||||
data: {
|
||||
type: input.type,
|
||||
label: input.label,
|
||||
required: input.required
|
||||
required: input.required,
|
||||
},
|
||||
where: {
|
||||
id: input.id
|
||||
}
|
||||
}))
|
||||
id: input.id,
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
if (req.method == "POST") {
|
||||
const createEventType = await prisma.eventType.create({
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
res.status(200).json({message: 'Event created successfully'});
|
||||
res.status(200).json({ message: "Event created successfully" });
|
||||
} else if (req.method == "PATCH") {
|
||||
if (req.body.timeZone) {
|
||||
data.timeZone = req.body.timeZone;
|
||||
}
|
||||
else if (req.method == "PATCH") {
|
||||
const updateEventType = await prisma.eventType.update({
|
||||
|
||||
if (req.body.availability) {
|
||||
const openingHours = req.body.availability.openingHours || [];
|
||||
// const overrides = req.body.availability.dateOverrides || [];
|
||||
|
||||
await prisma.availability.deleteMany({
|
||||
where: {
|
||||
eventTypeId: +req.body.id,
|
||||
},
|
||||
});
|
||||
Promise.all(
|
||||
openingHours.map((schedule) =>
|
||||
prisma.availability.create({
|
||||
data: {
|
||||
eventTypeId: +req.body.id,
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
},
|
||||
})
|
||||
)
|
||||
).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
res.status(200).json({message: 'Event updated successfully'});
|
||||
res.status(200).json({ message: "Event updated successfully" });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method == "DELETE") {
|
||||
|
||||
const deleteEventType = await prisma.eventType.delete({
|
||||
await prisma.eventType.delete({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({message: 'Event deleted successfully'});
|
||||
res.status(200).json({ message: "Event deleted successfully" });
|
||||
}
|
||||
}
|
||||
|
|
30
pages/api/availability/week.ts
Normal file
30
pages/api/availability/week.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getSession } from 'next-auth/client';
|
||||
import prisma from '../../../lib/prisma';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({req: req});
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({message: "Not authenticated"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method == "PATCH") {
|
||||
|
||||
const startMins = req.body.start;
|
||||
const endMins = req.body.end;
|
||||
|
||||
const updateWeek = await prisma.schedule.update({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
startTime: startMins,
|
||||
endTime: endMins
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({message: 'Start and end times updated successfully'});
|
||||
}
|
||||
}
|
|
@ -1,17 +1,66 @@
|
|||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Select, { OptionBase } from "react-select";
|
||||
import prisma from "../../../lib/prisma";
|
||||
import { LocationType } from "../../../lib/location";
|
||||
import Shell from "../../../components/Shell";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import { LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput";
|
||||
import prisma from "@lib/prisma";
|
||||
import { LocationType } from "@lib/location";
|
||||
import Shell from "@components/Shell";
|
||||
import { getSession } from "next-auth/client";
|
||||
import { Scheduler } from "@components/ui/Scheduler";
|
||||
|
||||
import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline";
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
|
||||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
|
||||
export default function EventType(props: any): JSX.Element {
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
dayjs.extend(utc);
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { EventType, User, Availability } from "@prisma/client";
|
||||
import { validJson } from "@lib/jsonUtils";
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
eventType: EventType;
|
||||
locationOptions: OptionBase[];
|
||||
availability: Availability[];
|
||||
};
|
||||
|
||||
type OpeningHours = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
type DateOverride = {
|
||||
date: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
type EventTypeInput = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
length: number;
|
||||
hidden: boolean;
|
||||
locations: unknown;
|
||||
eventName: string;
|
||||
customInputs: EventTypeCustomInput[];
|
||||
timeZone: string;
|
||||
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
|
||||
};
|
||||
|
||||
export default function EventTypePage({
|
||||
user,
|
||||
eventType,
|
||||
locationOptions,
|
||||
availability,
|
||||
}: Props): JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const inputOptions: OptionBase[] = [
|
||||
|
@ -21,17 +70,17 @@ export default function EventType(props: any): JSX.Element {
|
|||
{ value: EventTypeCustomInputType.Bool, label: "Checkbox" },
|
||||
];
|
||||
|
||||
const [, loading] = useSession();
|
||||
const [enteredAvailability, setEnteredAvailability] = useState();
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [showAddCustomModal, setShowAddCustomModal] = useState(false);
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined);
|
||||
const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]);
|
||||
const [locations, setLocations] = useState(eventType.locations || []);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
||||
const [locations, setLocations] = useState(props.eventType.locations || []);
|
||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||
props.eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
);
|
||||
const locationOptions = props.locationOptions;
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>();
|
||||
const slugRef = useRef<HTMLInputElement>();
|
||||
|
@ -40,25 +89,23 @@ export default function EventType(props: any): JSX.Element {
|
|||
const isHiddenRef = useRef<HTMLInputElement>();
|
||||
const eventNameRef = useRef<HTMLInputElement>();
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
}
|
||||
useEffect(() => {
|
||||
setSelectedTimeZone(eventType.timeZone || user.timeZone);
|
||||
}, []);
|
||||
|
||||
async function updateEventTypeHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const enteredTitle = titleRef.current.value;
|
||||
const enteredSlug = slugRef.current.value;
|
||||
const enteredDescription = descriptionRef.current.value;
|
||||
const enteredLength = lengthRef.current.value;
|
||||
const enteredIsHidden = isHiddenRef.current.checked;
|
||||
const enteredEventName = eventNameRef.current.value;
|
||||
const enteredTitle: string = titleRef.current.value;
|
||||
const enteredSlug: string = slugRef.current.value;
|
||||
const enteredDescription: string = descriptionRef.current.value;
|
||||
const enteredLength: number = parseInt(lengthRef.current.value);
|
||||
const enteredIsHidden: boolean = isHiddenRef.current.checked;
|
||||
const enteredEventName: string = eventNameRef.current.value;
|
||||
// TODO: Add validation
|
||||
|
||||
await fetch("/api/availability/eventtype", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
id: props.eventType.id,
|
||||
const payload: EventTypeInput = {
|
||||
id: eventType.id,
|
||||
title: enteredTitle,
|
||||
slug: enteredSlug,
|
||||
description: enteredDescription,
|
||||
|
@ -67,7 +114,16 @@ export default function EventType(props: any): JSX.Element {
|
|||
locations,
|
||||
eventName: enteredEventName,
|
||||
customInputs,
|
||||
}),
|
||||
timeZone: selectedTimeZone,
|
||||
};
|
||||
|
||||
if (enteredAvailability) {
|
||||
payload.availability = enteredAvailability;
|
||||
}
|
||||
|
||||
await fetch("/api/availability/eventtype", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
@ -81,7 +137,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
|
||||
await fetch("/api/availability/eventtype", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.eventType.id }),
|
||||
body: JSON.stringify({ id: eventType.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
@ -106,6 +162,30 @@ export default function EventType(props: any): JSX.Element {
|
|||
setSelectedCustomInput(undefined);
|
||||
};
|
||||
|
||||
const updateLocations = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
let details = {};
|
||||
if (e.target.location.value === LocationType.InPerson) {
|
||||
details = { address: e.target.address.value };
|
||||
}
|
||||
|
||||
const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type);
|
||||
if (existingIdx !== -1) {
|
||||
const copy = locations;
|
||||
copy[existingIdx] = { ...locations[existingIdx], ...details };
|
||||
setLocations(copy);
|
||||
} else {
|
||||
setLocations(locations.concat({ type: e.target.location.value, ...details }));
|
||||
}
|
||||
|
||||
setShowLocationModal(false);
|
||||
};
|
||||
|
||||
const removeLocation = (selectedLocation) => {
|
||||
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
||||
};
|
||||
|
||||
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
|
||||
setSelectedCustomInput(customInput);
|
||||
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
|
||||
|
@ -147,30 +227,6 @@ export default function EventType(props: any): JSX.Element {
|
|||
return null;
|
||||
};
|
||||
|
||||
const updateLocations = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
let details = {};
|
||||
if (e.target.location.value === LocationType.InPerson) {
|
||||
details = { address: e.target.address.value };
|
||||
}
|
||||
|
||||
const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type);
|
||||
if (existingIdx !== -1) {
|
||||
const copy = locations;
|
||||
copy[existingIdx] = { ...locations[existingIdx], ...details };
|
||||
setLocations(copy);
|
||||
} else {
|
||||
setLocations(locations.concat({ type: e.target.location.value, ...details }));
|
||||
}
|
||||
|
||||
setShowLocationModal(false);
|
||||
};
|
||||
|
||||
const removeLocation = (selectedLocation) => {
|
||||
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
||||
};
|
||||
|
||||
const updateCustom = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
@ -207,13 +263,13 @@ export default function EventType(props: any): JSX.Element {
|
|||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{props.eventType.title} | Event Type | Calendso</title>
|
||||
<title>{eventType.title} | Event Type | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Shell heading={"Event Type - " + props.eventType.title}>
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<Shell heading={"Event Type - " + eventType.title}>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg mb-4">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<form onSubmit={updateEventTypeHandler}>
|
||||
<div className="mb-4">
|
||||
|
@ -229,7 +285,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
required
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="Quick Chat"
|
||||
defaultValue={props.eventType.title}
|
||||
defaultValue={eventType.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -240,7 +296,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
<div className="mt-1">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
|
||||
{location.hostname}/{props.user.username}/
|
||||
{typeof location !== "undefined" ? location.hostname : ""}/{user.username}/
|
||||
</span>
|
||||
<input
|
||||
ref={slugRef}
|
||||
|
@ -249,7 +305,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
id="slug"
|
||||
required
|
||||
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
|
||||
defaultValue={props.eventType.slug}
|
||||
defaultValue={eventType.slug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -390,7 +446,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
id="description"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="A quick video meeting."
|
||||
defaultValue={props.eventType.description}></textarea>
|
||||
defaultValue={eventType.description}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
|
@ -406,7 +462,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
required
|
||||
className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="15"
|
||||
defaultValue={props.eventType.length}
|
||||
defaultValue={eventType.length}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
|
||||
minutes
|
||||
|
@ -425,7 +481,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
id="title"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="Meeting with {USER}"
|
||||
defaultValue={props.eventType.eventName}
|
||||
defaultValue={eventType.eventName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -484,7 +540,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
name="ishidden"
|
||||
type="checkbox"
|
||||
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||
defaultChecked={props.eventType.hidden}
|
||||
defaultChecked={eventType.hidden}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
|
@ -497,12 +553,24 @@ export default function EventType(props: any): JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-4" />
|
||||
<div>
|
||||
<h3 className="mb-2">How do you want to offer your availability for this event type?</h3>
|
||||
<Scheduler
|
||||
setAvailability={setEnteredAvailability}
|
||||
setTimeZone={setSelectedTimeZone}
|
||||
timeZone={selectedTimeZone}
|
||||
availability={availability}
|
||||
/>
|
||||
<div className="py-4 flex justify-end">
|
||||
<Link href="/availability">
|
||||
<a className="mr-2 btn btn-white">Cancel</a>
|
||||
</Link>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Update
|
||||
</button>
|
||||
<Link href="/availability">
|
||||
<a className="ml-2 btn btn-white">Cancel</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -649,9 +717,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
Is required
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="id" id="id" value={selectedCustomInput?.id} />
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
|
@ -670,32 +736,55 @@ export default function EventType(props: any): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
const validJson = (jsonString: string) => {
|
||||
try {
|
||||
const o = JSON.parse(jsonString);
|
||||
if (o && typeof o === "object") {
|
||||
return o;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Invalid JSON:", e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const session = await getSession(context);
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async ({ req, query }) => {
|
||||
const session = await getSession({ req });
|
||||
if (!session) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/auth/login",
|
||||
},
|
||||
};
|
||||
}
|
||||
const user = await prisma.user.findFirst({
|
||||
|
||||
const user: User = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
timeZone: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
|
||||
const eventType: EventType | null = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: parseInt(query.type as string),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
locations: true,
|
||||
eventName: true,
|
||||
availability: true,
|
||||
customInputs: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
@ -747,28 +836,26 @@ export async function getServerSideProps(context) {
|
|||
// Assuming it's Microsoft Teams.
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: parseInt(context.query.type),
|
||||
const getAvailability = (providesAvailability) =>
|
||||
providesAvailability.availability && providesAvailability.availability.length
|
||||
? providesAvailability.availability
|
||||
: null;
|
||||
|
||||
const availability: Availability[] = getAvailability(eventType) ||
|
||||
getAvailability(user) || [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: user.startTime,
|
||||
endTime: user.endTime,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
locations: true,
|
||||
eventName: true,
|
||||
customInputs: true,
|
||||
},
|
||||
});
|
||||
];
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
eventType,
|
||||
locationOptions,
|
||||
availability,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "timeZone" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Availability" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"label" TEXT,
|
||||
"userId" INTEGER,
|
||||
"eventTypeId" INTEGER,
|
||||
"days" INTEGER[],
|
||||
"startTime" INTEGER NOT NULL,
|
||||
"endTime" INTEGER NOT NULL,
|
||||
"date" DATE,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Availability" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Availability" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -21,8 +21,10 @@ model EventType {
|
|||
user User? @relation(fields: [userId], references: [id])
|
||||
userId Int?
|
||||
bookings Booking[]
|
||||
availability Availability[]
|
||||
eventName String?
|
||||
customInputs EventTypeCustomInput[]
|
||||
timeZone String?
|
||||
}
|
||||
|
||||
model Credential {
|
||||
|
@ -53,7 +55,9 @@ model User {
|
|||
credentials Credential[]
|
||||
teams Membership[]
|
||||
bookings Booking[]
|
||||
availability Availability[]
|
||||
selectedCalendars SelectedCalendar[]
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
|
@ -126,6 +130,19 @@ model Booking {
|
|||
updatedAt DateTime?
|
||||
}
|
||||
|
||||
model Availability {
|
||||
id Int @default(autoincrement()) @id
|
||||
label String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId Int?
|
||||
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||
eventTypeId Int?
|
||||
days Int[]
|
||||
startTime Int
|
||||
endTime Int
|
||||
date DateTime? @db.Date
|
||||
}
|
||||
|
||||
model SelectedCalendar {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
|
@ -150,4 +167,3 @@ model ResetPasswordRequest {
|
|||
email String
|
||||
expires DateTime
|
||||
}
|
||||
|
||||
|
|
|
@ -136,3 +136,23 @@ body {
|
|||
#timeZone input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.weekdaySelect {
|
||||
font-family: "Courier New", sans-serif;
|
||||
}
|
||||
|
||||
.weekdaySelect button.active:first-child {
|
||||
margin-left: -1px !important;
|
||||
}
|
||||
|
||||
.weekdaySelect button:not(.active) {
|
||||
padding-left: calc(0.5rem + 0px);
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.weekdaySelect button.active + button.active {
|
||||
border-color: rgba(3, 169, 244, var(--tw-border-opacity))
|
||||
rgba(3, 169, 244, var(--tw-border-opacity))
|
||||
rgba(3, 169, 244, var(--tw-border-opacity))
|
||||
white;
|
||||
}
|
56
test/lib/slots.test.ts
Normal file
56
test/lib/slots.test.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import getSlots from '@lib/slots';
|
||||
import {it, expect} from '@jest/globals';
|
||||
import MockDate from 'mockdate';
|
||||
import dayjs, {Dayjs} from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
MockDate.set('2021-06-20T11:59:59Z');
|
||||
|
||||
it('can fit 24 hourly slots for an empty day', async () => {
|
||||
// 24h in a day.
|
||||
expect(getSlots({
|
||||
inviteeDate: dayjs().add(1, 'day'),
|
||||
frequency: 60,
|
||||
workingHours: [
|
||||
{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }
|
||||
],
|
||||
organizerTimeZone: 'Europe/London'
|
||||
})).toHaveLength(24);
|
||||
});
|
||||
|
||||
it('only shows future booking slots on the same day', async () => {
|
||||
// The mock date is 1s to midday, so 12 slots should be open given 0 booking notice.
|
||||
expect(getSlots({
|
||||
inviteeDate: dayjs(),
|
||||
frequency: 60,
|
||||
workingHours: [
|
||||
{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }
|
||||
],
|
||||
organizerTimeZone: 'GMT'
|
||||
})).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('can cut off dates that due to invitee timezone differences fall on the next day', async () => {
|
||||
expect(getSlots({
|
||||
inviteeDate: dayjs().tz('Europe/Amsterdam').startOf('day'), // time translation +01:00
|
||||
frequency: 60,
|
||||
workingHours: [
|
||||
{ days: [0], startTime: 1380, endTime: 1440 }
|
||||
],
|
||||
organizerTimeZone: 'Europe/London'
|
||||
})).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can cut off dates that due to invitee timezone differences fall on the previous day', async () => {
|
||||
expect(getSlots({
|
||||
inviteeDate: dayjs().startOf('day'), // time translation -01:00
|
||||
frequency: 60,
|
||||
workingHours: [
|
||||
{ days: [0], startTime: 0, endTime: 60 }
|
||||
],
|
||||
organizerTimeZone: 'Europe/London'
|
||||
})).toHaveLength(0);
|
||||
});
|
|
@ -6,6 +6,11 @@
|
|||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@lib/*": ["lib/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
|
|
Loading…
Reference in a new issue