Working version ready for testing

* More tests to be added to verify slots logic
* Adds Jest
* Implements logic to the booking code to take into account grayed days
* Slots take workhours into account

TODO: Improve the tests, evaluate the structure, small re-orgs here and
there for improved readability / better code
This commit is contained in:
Alex van Andel 2021-06-24 22:15:18 +00:00
parent 1dce84fa8f
commit ef3274d8f3
14 changed files with 2992 additions and 872 deletions

View file

@ -18,12 +18,14 @@
"rules": { "rules": {
"prettier/prettier": ["error"], "prettier/prettier": ["error"],
"@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-unused-vars": "error",
"react/react-in-jsx-scope": "off" "react/react-in-jsx-scope": "off",
"react/prop-types": "off"
}, },
"env": { "env": {
"browser": true, "browser": true,
"node": true, "node": true,
"es6": true "es6": true,
"jest": true
}, },
"settings": { "settings": {
"react": { "react": {

View file

@ -1,87 +1,35 @@
import dayjs, {Dayjs} from "dayjs";
import isBetween from 'dayjs/plugin/isBetween';
dayjs.extend(isBetween);
import {useEffect, useMemo, useState} from "react";
import getSlots from "../../lib/slots";
import Link from "next/link"; import Link from "next/link";
import {timeZone} from "../../lib/clock"; import { useRouter } from "next/router";
import {useRouter} from "next/router"; import Slots from "./Slots";
const AvailableTimes = (props) => {
const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat }) => {
const router = useRouter(); const router = useRouter();
const { user, rescheduleUid } = router.query; const { user, rescheduleUid } = router.query;
const [loaded, setLoaded] = useState(false); const { slots } = Slots({ date, eventLength, workingHours });
const times = getSlots({
calendarTimeZone: props.user.timeZone,
selectedTimeZone: timeZone(),
eventLength: props.eventType.length,
selectedDate: props.date,
dayStartTime: props.user.startTime,
dayEndTime: props.user.endTime,
});
const handleAvailableSlots = (busyTimes: []) => {
// Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) {
busyTimes.forEach(busyTime => {
let startTime = dayjs(busyTime.start);
let 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);
fetch(`/api/availability/${user}?dateFrom=${props.date.startOf('day').utc().format()}&dateTo=${props.date.endOf('day').utc().format()}`)
.then( res => res.json())
.then(handleAvailableSlots);
}, [props.date]);
return ( 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="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"> <div className="text-gray-600 font-light text-xl mb-4 text-left">
<span className="w-1/2"> <span className="w-1/2">{date.format("dddd DD MMMM YYYY")}</span>
{props.date.format("dddd DD MMMM YYYY")}
</span>
</div> </div>
{ {slots.length > 0 ? (
loaded ? times.map((time) => slots.map((slot) => (
<div key={dayjs(time).utc().format()}> <div key={slot.format()}>
<Link <Link
href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")}> href={
<a key={dayjs(time).format("hh:mma")} `/${user}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
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> (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
}>
<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> </Link>
</div> </div>
) : <div className="loader"></div> ))
} ) : (
<div className="loader" />
)}
</div> </div>
); );
} };
export default AvailableTimes; export default AvailableTimes;

View file

@ -1,90 +1,90 @@
import dayjs from "dayjs"; import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
import {ChevronLeftIcon, ChevronRightIcon} from "@heroicons/react/solid"; import { useEffect, useState } from "react";
import {useEffect, useState} from "react"; import dayjs, { Dayjs } from "dayjs";
import isToday from "dayjs/plugin/isToday";
const DatePicker = ({ weekStart, onDatePicked }) => { dayjs.extend(isToday);
const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) => {
const workingDays = workingHours.reduce((workingDays: number[], wh) => [...workingDays, ...wh.days], []);
const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
const [selectedDay, setSelectedDay] = useState(dayjs().date()); const [selectedDate, setSelectedDate] = useState();
const [hasPickedDate, setHasPickedDate] = useState(false);
useEffect(() => { useEffect(() => {
if (hasPickedDate) { if (selectedDate) onDatePicked(selectedDate);
onDatePicked(dayjs().month(selectedMonth).date(selectedDay)); }, [selectedDate, onDatePicked]);
}
}, [hasPickedDate, selectedDay]);
// Handle month changes // Handle month changes
const incrementMonth = () => { const incrementMonth = () => {
setSelectedMonth(selectedMonth + 1); setSelectedMonth(selectedMonth + 1);
} };
const decrementMonth = () => { const decrementMonth = () => {
setSelectedMonth(selectedMonth - 1); setSelectedMonth(selectedMonth - 1);
} };
// Set up calendar // Set up calendar
var daysInMonth = dayjs().month(selectedMonth).daysInMonth(); const daysInMonth = dayjs().month(selectedMonth).daysInMonth();
var days = []; const days = [];
for (let i = 1; i <= daysInMonth; i++) { for (let i = 1; i <= daysInMonth; i++) {
days.push(i); days.push(i);
} }
// Create placeholder elements for empty days in first week // Create placeholder elements for empty days in first week
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
if (weekStart === 'Monday') { if (weekStart === "Monday") {
weekdayOfFirst -= 1; weekdayOfFirst -= 1;
if (weekdayOfFirst < 0) if (weekdayOfFirst < 0) weekdayOfFirst = 6;
weekdayOfFirst = 6;
} }
const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) => const emptyDays = Array(weekdayOfFirst)
.fill(null)
.map((day, i) => (
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}> <div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
{null} {null}
</div> </div>
));
const isDisabled = (day: number) => {
const date: Dayjs = dayjs().month(selectedMonth).date(day);
return (
date.isBefore(dayjs()) || !workingDays.includes(+date.format("d")) || (date.isToday() && disableToday)
); );
};
// Combine placeholder days with actual days // Combine placeholder days with actual days
const calendar = [...emptyDays, ...days.map((day) => const calendar = [
<button key={day} ...emptyDays,
onClick={() => { setHasPickedDate(true); setSelectedDay(day) }} ...days.map((day) => (
<button
key={day}
onClick={() => setSelectedDate(dayjs().month(selectedMonth).date(day))}
disabled={ disabled={
selectedMonth < parseInt(dayjs().format('MM')) && dayjs().month(selectedMonth).format("D") > day (selectedMonth < parseInt(dayjs().format("MM")) &&
dayjs().month(selectedMonth).format("D") > day) ||
isDisabled(day)
} }
className={ className={
"text-center w-10 h-10 rounded-full mx-auto " + ( "text-center w-10 h-10 rounded-full mx-auto" +
dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth) (isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
) ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-400 font-light' (selectedDate && selectedDate.isSame(dayjs().month(selectedMonth).date(day), "day")
) + ( ? " bg-blue-600 text-white-important"
dayjs().date(selectedDay).month(selectedMonth).format("D") == day ? ' bg-blue-600 text-white-important' : '' : !isDisabled(day)
) ? " bg-blue-50"
: "")
}> }>
{day} {day}
</button> </button>
)]; )),
];
return ( return (
<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")}>
className={
"mt-8 sm:mt-0 " +
(hasPickedDate
? "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"> <div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
<span className="w-1/2"> <span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
{dayjs().month(selectedMonth).format("MMMM YYYY")}
</span>
<div className="w-1/2 text-right"> <div className="w-1/2 text-right">
<button <button
onClick={decrementMonth} onClick={decrementMonth}
className={ className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")}
"mr-4 " + disabled={selectedMonth < parseInt(dayjs().format("MM"))}>
(selectedMonth < parseInt(dayjs().format("MM")) &&
"text-gray-400")
}
disabled={selectedMonth < parseInt(dayjs().format("MM"))}
>
<ChevronLeftIcon className="w-5 h-5" /> <ChevronLeftIcon className="w-5 h-5" />
</button> </button>
<button onClick={incrementMonth}> <button onClick={incrementMonth}>
@ -93,17 +93,17 @@ const DatePicker = ({ weekStart, onDatePicked }) => {
</div> </div>
</div> </div>
<div className="grid grid-cols-7 gap-y-4 text-center"> <div className="grid grid-cols-7 gap-y-4 text-center">
{ {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
.sort( (a, b) => weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0 ) .map((weekDay) => (
.map( (weekDay) => <div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">{weekDay}</div> {weekDay}
) </div>
} ))}
{calendar} {calendar}
</div> </div>
</div> </div>
); );
} };
export default DatePicker; export default DatePicker;

View file

@ -0,0 +1,66 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
import getSlots from "../../lib/slots";
const Slots = (props) => {
const router = useRouter();
const { user } = router.query;
const [slots, setSlots] = useState([]);
useEffect(() => {
setSlots([]);
fetch(
`/api/availability/${user}?dateFrom=${props.date.startOf("day").utc().format()}&dateTo=${props.date
.endOf("day")
.utc()
.format()}`
)
.then((res) => res.json())
.then(handleAvailableSlots);
}, [props.date]);
const handleAvailableSlots = (busyTimes: []) => {
const times = getSlots({
frequency: props.eventLength,
inviteeDate: props.date,
workingHours: props.workingHours,
minimumBookingNotice: 0,
});
// 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
setSlots(times);
};
return {
slots,
};
};
export default Slots;

View file

@ -1,45 +1,48 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import TimezoneSelect from "react-timezone-select"; import TimezoneSelect from "react-timezone-select";
import {PencilAltIcon, TrashIcon} from "@heroicons/react/outline"; import { TrashIcon } from "@heroicons/react/outline";
import {WeekdaySelect, Weekday} from "./WeekdaySelect"; import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal"; import SetTimesModal from "./modal/SetTimesModal";
import Schedule from '../../lib/schedule.model'; import Schedule from "../../lib/schedule.model";
import dayjs, {Dayjs} from "dayjs"; import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from "dayjs/plugin/utc";
import timezone from 'dayjs/plugin/timezone'; import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export const Scheduler = (props) => { export const Scheduler = (props) => {
const [schedules, setSchedules]: Schedule[] = useState(
const [ schedules, setSchedules ]: Schedule[] = useState(props.schedules.map( schedule => { props.schedules.map((schedule) => {
const startDate = schedule.isOverride ? dayjs(schedule.startDate) : dayjs.utc().startOf('day').add(schedule.startTime, 'minutes') const startDate = schedule.isOverride
return ( ? dayjs(schedule.startDate)
{ : dayjs.utc().startOf("day").add(schedule.startTime, "minutes").tz(props.timeZone);
return {
days: schedule.days, days: schedule.days,
startDate, startDate,
endDate: startDate.add(schedule.length, 'minutes') endDate: startDate.add(schedule.length, "minutes"),
} };
) })
})); );
const [ timeZone, setTimeZone ] = useState(props.timeZone); const [timeZone, setTimeZone] = useState(props.timeZone);
const [ editSchedule, setEditSchedule ] = useState(-1); const [editSchedule, setEditSchedule] = useState(-1);
useEffect( () => { useEffect(() => {
props.onChange(schedules); props.onChange(schedules);
}, [schedules]) }, [schedules]);
const addNewSchedule = () => setEditSchedule(schedules.length); const addNewSchedule = () => setEditSchedule(schedules.length);
const applyEditSchedule = (changed: Schedule) => { const applyEditSchedule = (changed: Schedule) => {
const replaceWith = { const replaceWith = {
...schedules[editSchedule], ...schedules[editSchedule],
...changed ...changed,
}; };
schedules.splice(editSchedule, 1, replaceWith); schedules.splice(editSchedule, 1, replaceWith);
setSchedules([].concat(schedules)); setSchedules([].concat(schedules));
} };
const removeScheduleAt = (toRemove: number) => { const removeScheduleAt = (toRemove: number) => {
schedules.splice(toRemove, 1); schedules.splice(toRemove, 1);
@ -49,7 +52,7 @@ export const Scheduler = (props) => {
const setWeekdays = (idx: number, days: number[]) => { const setWeekdays = (idx: number, days: number[]) => {
schedules[idx].days = days; schedules[idx].days = days;
setSchedules([].concat(schedules)); setSchedules([].concat(schedules));
} };
return ( return (
<div> <div>
@ -60,26 +63,40 @@ export const Scheduler = (props) => {
Timezone Timezone
</label> </label>
<div className="mt-1"> <div className="mt-1">
<TimezoneSelect id="timeZone" value={timeZone} onChange={setTimeZone} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" /> <TimezoneSelect
id="timeZone"
value={timeZone}
onChange={setTimeZone}
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>
</div> </div>
<ul> <ul>
{schedules.map( (schedule, idx) => {schedules.map((schedule, idx) => (
<li key={idx} className="py-2 flex justify-between border-t"> <li key={idx} className="py-2 flex justify-between border-t">
<div className="inline-flex ml-2"> <div className="inline-flex ml-2">
<WeekdaySelect defaultValue={schedules[idx].days} onSelect={(days: number[]) => setWeekdays(idx, days)} /> <WeekdaySelect
defaultValue={schedules[idx].days}
onSelect={(days: number[]) => setWeekdays(idx, days)}
/>
<button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}> <button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}>
{schedule.startDate.format(schedule.startDate.minute() === 0 ? 'ha' : 'h:mma')} until {schedule.endDate.format(schedule.endDate.minute() === 0 ? 'ha' : 'h:mma')} {dayjs(schedule.startDate).format(schedule.startDate.minute() === 0 ? "ha" : "h:mma")}{" "}
until {dayjs(schedule.endDate).format(schedule.endDate.minute() === 0 ? "ha" : "h:mma")}
</button> </button>
</div> </div>
<button type="button" onClick={() => removeScheduleAt(idx)} <button
type="button"
onClick={() => removeScheduleAt(idx)}
className="btn-sm bg-transparent px-2 py-1 ml-1"> className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" /> <TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
</button> </button>
</li>)} </li>
))}
</ul> </ul>
<hr /> <hr />
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">Add another</button> <button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">
Add another
</button>
</div> </div>
<div className="border-l p-2 w-2/5 text-sm bg-gray-50"> <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="font-bold mb-2">Add date overrides</p>
@ -89,14 +106,16 @@ export const Scheduler = (props) => {
<button className="btn-sm btn-white">Add a date override</button>*/} <button className="btn-sm btn-white">Add a date override</button>*/}
</div> </div>
</div> </div>
{editSchedule >= 0 && {editSchedule >= 0 && (
<SetTimesModal schedule={schedules[editSchedule]} <SetTimesModal
schedule={schedules[editSchedule]}
onChange={applyEditSchedule} onChange={applyEditSchedule}
onExit={() => setEditSchedule(-1)} /> onExit={() => setEditSchedule(-1)}
} />
)}
{/*{showDateOverrideModal && {/*{showDateOverrideModal &&
<DateOverrideModal /> <DateOverrideModal />
}*/} }*/}
</div> </div>
); );
} };

View file

@ -1,42 +1,53 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
export const WeekdaySelect = (props) => { export const WeekdaySelect = (props) => {
const [activeDays, setActiveDays] = useState(
[...Array(7).keys()].map((v, i) => (props.defaultValue || []).includes(i))
);
const [ activeDays, setActiveDays ] = useState([1,2,3,4,5,6,7].map( (v) => (props.defaultValue || []).indexOf(v) !== -1)); const days = ["S", "M", "T", "W", "T", "F", "S"];
const days = [ 'S', 'M', 'T', 'W', 'T', 'F', 'S' ];
useEffect( () => { useEffect(() => {
props.onSelect(activeDays.map( (isActive, idx) => isActive ? idx + 1 : 0).filter( (v) => 0 !== v )); props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
}, [activeDays]); }, [activeDays]);
const toggleDay = (e, idx: number) => { const toggleDay = (e, idx: number) => {
e.preventDefault(); e.preventDefault();
activeDays[idx] = !activeDays[idx]; activeDays[idx] = !activeDays[idx];
setActiveDays([].concat(activeDays)); setActiveDays([].concat(activeDays));
} };
return ( return (
<div className="weekdaySelect"> <div className="weekdaySelect">
<div className="inline-flex"> <div className="inline-flex">
{days.map( (day, idx) => activeDays[idx] ? {days.map((day, idx) =>
<button key={idx} onClick={(e) => toggleDay(e, idx)} activeDays[idx] ? (
style={ {"marginLeft": "-2px"} } <button
key={idx}
onClick={(e) => toggleDay(e, idx)}
style={{ marginLeft: "-2px" }}
className={` className={`
active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded
${activeDays[idx+1] ? 'rounded-r-none': ''} ${activeDays[idx + 1] ? "rounded-r-none" : ""}
${activeDays[idx-1] ? 'rounded-l-none': ''} ${activeDays[idx - 1] ? "rounded-l-none" : ""}
${idx === 0 ? 'rounded-l' : ''} ${idx === 0 ? "rounded-l" : ""}
${idx === days.length-1 ? 'rounded-r' : ''} ${idx === days.length - 1 ? "rounded-r" : ""}
`}> `}>
{day} {day}
</button> </button>
: ) : (
<button key={idx} onClick={(e) => toggleDay(e, idx)} <button
style={ {"marginTop": "1px", "marginBottom": "1px"} } key={idx}
className={`border focus:outline-none px-2 py-1 rounded-none ${idx === 0 ? 'rounded-l' : 'border-l-0'} ${idx === days.length-1 ? 'rounded-r' : ''}`}> 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} {day}
</button> </button>
)
)} )}
</div> </div>
</div>); </div>
} );
};

View file

@ -1,14 +1,20 @@
import {ClockIcon} from "@heroicons/react/outline"; import { ClockIcon } from "@heroicons/react/outline";
import {useRef} from "react"; import { useRef } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/utc";
dayjs.extend(utc);
dayjs.extend(timezone);
export default function SetTimesModal(props) { export default function SetTimesModal(props) {
const { startDate, endDate } = props.schedule || {
const {startDate, endDate} = props.schedule || { startDate: dayjs.utc().startOf("day").add(540, "minutes"),
startDate: dayjs().startOf('day').add(540, 'minutes'), endDate: dayjs.utc().startOf("day").add(1020, "minutes"),
endDate: dayjs().startOf('day').add(1020, 'minutes'),
}; };
startDate.tz(props.timeZone);
endDate.tz(props.timeZone);
const startHoursRef = useRef<HTMLInputElement>(); const startHoursRef = useRef<HTMLInputElement>();
const startMinsRef = useRef<HTMLInputElement>(); const startMinsRef = useRef<HTMLInputElement>();
const endHoursRef = useRef<HTMLInputElement>(); const endHoursRef = useRef<HTMLInputElement>();
@ -31,60 +37,108 @@ export default function SetTimesModal(props) {
} }
return ( return (
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <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="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> <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">&#8203;</span> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div <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">
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="sm:flex sm:items-start mb-4">
<div <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">
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" />
<ClockIcon className="h-6 w-6 text-blue-600"/>
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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"> <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Change when you are available for bookings Change when you are available for bookings
</h3> </h3>
<div> <div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">Set your work schedule</p>
Set your work schedule
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex mb-4"> <div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label> <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label>
<div> <div>
<label htmlFor="startHours" className="sr-only">Hours</label> <label htmlFor="startHours" className="sr-only">
<input ref={startHoursRef} type="number" min="0" max="23" maxLength="2" name="hours" id="startHours" 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" 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={startDate.format('H')} /> placeholder="9"
defaultValue={startDate.format("H")}
/>
</div> </div>
<span className="mx-2 pt-1">:</span> <span className="mx-2 pt-1">:</span>
<div> <div>
<label htmlFor="startMinutes" className="sr-only">Minutes</label> <label htmlFor="startMinutes" className="sr-only">
<input ref={startMinsRef} type="number" min="0" max="59" step="15" maxLength="2" name="minutes" id="startMinutes" 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" 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={startDate.format('m')} /> placeholder="30"
defaultValue={startDate.format("m")}
/>
</div> </div>
</div> </div>
<div className="flex"> <div className="flex">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label> <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label>
<div> <div>
<label htmlFor="endHours" className="sr-only">Hours</label> <label htmlFor="endHours" className="sr-only">
<input ref={endHoursRef} type="number" min="0" max="23" maxLength="2" name="hours" id="endHours" 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" 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={endDate.format('H')} /> placeholder="17"
defaultValue={endDate.format("H")}
/>
</div> </div>
<span className="mx-2 pt-1">:</span> <span className="mx-2 pt-1">:</span>
<div> <div>
<label htmlFor="endMinutes" className="sr-only">Minutes</label> <label htmlFor="endMinutes" className="sr-only">
<input ref={endMinsRef} type="number" min="0" max="59" maxLength="2" step="15" name="minutes" id="endMinutes" 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" 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={endDate.format('m')} /> placeholder="30"
defaultValue={endDate.format("m")}
/>
</div> </div>
</div> </div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"> <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
@ -97,5 +151,6 @@ export default function SetTimesModal(props) {
</div> </div>
</div> </div>
</div> </div>
</div>); </div>
);
} }

View file

@ -1,94 +1,108 @@
const dayjs = require("dayjs"); import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
const isToday = require("dayjs/plugin/isToday");
const utc = require("dayjs/plugin/utc");
const timezone = require("dayjs/plugin/timezone");
dayjs.extend(isToday);
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone);
const getMinutesFromMidnight = (date) => { interface GetSlotsType {
return date.hour() * 60 + date.minute(); inviteeDate: Dayjs;
frequency: number;
workingHours: { [WeekDay]: Boundary[] };
minimumBookingNotice?: number;
}
interface Boundary {
lowerBound: number;
upperBound: number;
}
const freqApply: number = (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),
};
}; };
const getSlots = ({ // say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
calendarTimeZone, const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
eventLength, boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
selectedTimeZone,
selectedDate,
dayStartTime,
dayEndTime
}) => {
if(!selectedDate) return [] const organizerBoundaries = (workingHours: [], inviteeDate: Dayjs, inviteeBounds: Boundary): Boundary[] => {
const boundaries: Boundary[] = [];
const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day"); const startDay: number = +inviteeDate
.utc()
// 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") .startOf("day")
.add(dayStartTime, "minutes"); .add(inviteeBounds.lowerBound, "minutes")
.format("d");
const endDay: number = +inviteeDate
.utc()
.startOf("day")
.add(inviteeBounds.upperBound, "minutes")
.format("d");
let phase = 0; workingHours.forEach((item) => {
if (startDateTime < lowerBound) { const lowerBound: number = item.startTime;
// Getting minutes of the first event in the day of the chooser const upperBound: number = lowerBound + item.length;
const diff = lowerBound.diff(startDateTime, "minutes"); if (startDay !== endDay) {
if (inviteeBounds.lowerBound < 0) {
// finding first event // lowerBound edges into the previous day
phase = diff + eventLength - (diff % eventLength); if (item.days.includes(startDay)) {
boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 });
} }
if (item.days.includes(endDay)) {
// We can stop as soon as the selectedTimeZone day ends boundaries.push({ lowerBound, upperBound });
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;
} }
} else {
slots.push(slot.tz(selectedTimeZone)); // 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; return slots;
}; };
export default getSlots const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlotsType): Dayjs[] => {
const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
? inviteeDate.hour() * 60 + inviteeDate.minute() + minimumBookingNotice
: 0;
const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
return getOverlaps(inviteeBounds, organizerBoundaries(workingHours, inviteeDate, inviteeBounds))
.reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
.map((slot) => slot.utcOffset(dayjs(inviteeDate).utcOffset()));
};
export default getSlots;

View file

@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"postinstall": "prisma generate", "postinstall": "prisma generate",
@ -36,6 +37,7 @@
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^26.0.23",
"@types/node": "^14.14.33", "@types/node": "^14.14.33",
"@types/react": "^17.0.3", "@types/react": "^17.0.3",
"@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/eslint-plugin": "^4.27.0",
@ -47,7 +49,9 @@
"eslint-plugin-react": "^7.24.0", "eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
"husky": "^6.0.0", "husky": "^6.0.0",
"jest": "^27.0.5",
"lint-staged": "^11.0.0", "lint-staged": "^11.0.0",
"mockdate": "^3.0.5",
"postcss": "^8.2.8", "postcss": "^8.2.8",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"prisma": "^2.23.0", "prisma": "^2.23.0",
@ -59,5 +63,19 @@
"prettier --write", "prettier --write",
"eslint" "eslint"
] ]
},
"jest": {
"verbose": true,
"extensionsToTreatAsEsm": [
".ts"
],
"moduleFileExtensions": [
"js",
"ts"
],
"moduleNameMapper": {
"^@components(.*)$": "<rootDir>/components$1",
"^@lib(.*)$": "<rootDir>/lib$1"
}
} }
} }

View file

@ -1,58 +1,64 @@
import {useEffect, useState, useMemo} from 'react'; import { useEffect, useState, useMemo } from "react";
import Head from 'next/head'; import Head from "next/head";
import Link from 'next/link'; import prisma from "../../lib/prisma";
import prisma from '../../lib/prisma'; import dayjs, { Dayjs } from "dayjs";
import dayjs, { Dayjs } from 'dayjs'; import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'; import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import utc from "dayjs/plugin/utc";
import utc from 'dayjs/plugin/utc'; import timezone from "dayjs/plugin/timezone";
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrBefore);
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
import AvailableTimes from "../../components/booking/AvailableTimes"; import AvailableTimes from "../../components/booking/AvailableTimes";
import TimeOptions from "../../components/booking/TimeOptions" import TimeOptions from "../../components/booking/TimeOptions";
import Avatar from '../../components/Avatar'; import Avatar from "../../components/Avatar";
import {timeZone} from "../../lib/clock"; import { timeZone } from "../../lib/clock";
import DatePicker from "../../components/booking/DatePicker"; import DatePicker from "../../components/booking/DatePicker";
import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import getSlots from "@lib/slots";
export default function Type(props) { export default function Type(props) {
// Get router variables
const router = useRouter(); const router = useRouter();
const { rescheduleUid } = router.query; const { rescheduleUid } = router.query;
// Initialise state
const [selectedDate, setSelectedDate] = useState<Dayjs>(); const [selectedDate, setSelectedDate] = useState<Dayjs>();
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState('h:mma'); const [timeFormat, setTimeFormat] = useState("h:mma");
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const today: string = dayjs().utc().format("YYYY-MM-DDTHH:mm");
const noSlotsToday = useMemo(
() =>
getSlots({
frequency: props.eventType.length,
inviteeDate: dayjs.utc(today) as Dayjs,
workingHours: props.workingHours,
minimumBookingNotice: 0,
}).length === 0,
[today, props.eventType.length, props.workingHours]
);
useEffect(() => { useEffect(() => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())) telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
}, []); }, [telemetry]);
const changeDate = (date: Dayjs) => { const changeDate = (date: Dayjs) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())) telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
setSelectedDate(date); setSelectedDate(date);
}; };
const handleSelectTimeZone = (selectedTimeZone: string) => { const handleSelectTimeZone = (selectedTimeZone: string) => {
if (selectedDate) { if (selectedDate) {
setSelectedDate(selectedDate.tz(selectedTimeZone)) setSelectedDate(selectedDate.tz(selectedTimeZone));
} }
}; };
const handleToggle24hClock = (is24hClock: boolean) => { const handleToggle24hClock = (is24hClock: boolean) => {
if (selectedDate) { setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
setTimeFormat(is24hClock ? 'HH:mm' : 'h:mma'); };
}
}
return ( return (
<div> <div>
@ -67,40 +73,47 @@ export default function Type(props) {
className={ className={
"mx-auto my-24 transition-max-width ease-in-out duration-500 " + "mx-auto my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-6xl" : "max-w-3xl") (selectedDate ? "max-w-6xl" : "max-w-3xl")
} }>
>
<div className="bg-white shadow rounded-lg"> <div className="bg-white shadow rounded-lg">
<div className="sm:flex px-4 py-5 sm:p-4"> <div className="sm:flex px-4 py-5 sm:p-4">
<div <div className={"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")}>
className={
"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}
>
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" /> <Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
<h2 className="font-medium text-gray-500">{props.user.name}</h2> <h2 className="font-medium text-gray-500">{props.user.name}</h2>
<h1 className="text-3xl font-semibold text-gray-800 mb-4"> <h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1>
{props.eventType.title}
</h1>
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2"> <p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> <ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes {props.eventType.length} minutes
</p> </p>
<button <button
onClick={() => setIsTimeOptionsOpen(true)} onClick={() => setIsTimeOptionsOpen(true)}
className="text-gray-500 mb-1 px-2 py-1 -ml-2" className="text-gray-500 mb-1 px-2 py-1 -ml-2">
>
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> <GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{timeZone()} {timeZone()}
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" /> <ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
</button> </button>
{ isTimeOptionsOpen && <TimeOptions onSelectTimeZone={handleSelectTimeZone} {isTimeOptionsOpen && (
onToggle24hClock={handleToggle24hClock} />} <TimeOptions
<p className="text-gray-600 mt-3 mb-8"> onSelectTimeZone={handleSelectTimeZone}
{props.eventType.description} onToggle24hClock={handleToggle24hClock}
</p> />
)}
<p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
</div> </div>
<DatePicker weekStart={props.user.weekStart} onDatePicked={changeDate} /> <DatePicker
{selectedDate && <AvailableTimes timeFormat={timeFormat} user={props.user} eventType={props.eventType} date={selectedDate} />} disableToday={noSlotsToday}
weekStart={props.user.weekStart}
onDatePicked={changeDate}
workingHours={props.workingHours}
/>
{selectedDate && (
<AvailableTimes
workingHours={props.workingHours}
timeFormat={timeFormat}
eventLength={props.eventType.length}
eventTypeId={props.eventType.id}
date={selectedDate}
/>
)}
</div> </div>
</div> </div>
{/* note(peer): {/* note(peer):
@ -112,6 +125,14 @@ export default function Type(props) {
); );
} }
interface WorkingHours {
days: number[];
startTime: number;
length: number;
}
type Availability = WorkingHours;
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
@ -129,13 +150,14 @@ export async function getServerSideProps(context) {
timeZone: true, timeZone: true,
endTime: true, endTime: true,
weekStart: true, weekStart: true,
} availability: true,
},
}); });
if (!user) { if (!user) {
return { return {
notFound: true, notFound: true,
} };
} }
const eventType = await prisma.eventType.findFirst({ const eventType = await prisma.eventType.findFirst({
@ -149,20 +171,38 @@ export async function getServerSideProps(context) {
id: true, id: true,
title: true, title: true,
description: true, description: true,
length: true length: true,
} availability: true,
},
}); });
if (!eventType) { if (!eventType) {
return { return {
notFound: true, notFound: true,
};
} }
}
const getWorkingHours = (providesAvailability) =>
providesAvailability.availability && providesAvailability.availability.length
? providesAvailability.availability
: null;
const workingHours: WorkingHours[] =
getWorkingHours(eventType) ||
getWorkingHours(user) ||
[
{
days: [1, 2, 3, 4, 5, 6, 7],
startTime: user.startTime,
length: user.endTime,
},
].filter((availability: Availability): boolean => typeof availability["days"] !== "undefined");
return { return {
props: { props: {
user, user,
eventType, eventType,
workingHours,
}, },
} };
} }

View file

@ -1,48 +1,44 @@
import Head from 'next/head'; import Head from "next/head";
import Link from 'next/link'; import Link from "next/link";
import { useRouter } from 'next/router'; import { useRouter } from "next/router";
import { useRef, useState, useEffect } from 'react'; import { useRef, useState } from "react";
import Select, { OptionBase } from 'react-select'; import Select, { OptionBase } from "react-select";
import prisma from '../../../lib/prisma'; import prisma from "../../../lib/prisma";
import {LocationType} from '../../../lib/location'; import { LocationType } from "../../../lib/location";
import Shell from '../../../components/Shell'; import Shell from "../../../components/Shell";
import { useSession, getSession } from 'next-auth/client'; import { getSession } from "next-auth/client";
import {Scheduler} from "../../../components/ui/Scheduler"; import { Scheduler } from "../../../components/ui/Scheduler";
import { import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline";
LocationMarkerIcon, import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput";
PlusCircleIcon, import { PlusIcon } from "@heroicons/react/solid";
XIcon,
PhoneIcon,
} from '@heroicons/react/outline';
import {EventTypeCustomInput, EventTypeCustomInputType} from "../../../lib/eventTypeInput";
import {PlusIcon} from "@heroicons/react/solid";
import dayjs, {Dayjs} from "dayjs"; import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from "dayjs/plugin/utc";
dayjs.extend(utc); dayjs.extend(utc);
import timezone from 'dayjs/plugin/timezone'; import timezone from "dayjs/plugin/timezone";
dayjs.extend(timezone); dayjs.extend(timezone);
export default function EventType(props) { export default function EventType(props) {
const router = useRouter(); const router = useRouter();
const inputOptions: OptionBase[] = [ const inputOptions: OptionBase[] = [
{ value: EventTypeCustomInputType.Text, label: 'Text' }, { value: EventTypeCustomInputType.Text, label: "Text" },
{ value: EventTypeCustomInputType.TextLong, label: 'Multiline Text' }, { value: EventTypeCustomInputType.TextLong, label: "Multiline Text" },
{ value: EventTypeCustomInputType.Number, label: 'Number', }, { value: EventTypeCustomInputType.Number, label: "Number" },
{ value: EventTypeCustomInputType.Bool, label: 'Checkbox', }, { value: EventTypeCustomInputType.Bool, label: "Checkbox" },
] ];
const [ session, loading ] = useSession(); const [showLocationModal, setShowLocationModal] = useState(false);
const [ showLocationModal, setShowLocationModal ] = useState(false); const [showAddCustomModal, setShowAddCustomModal] = useState(false);
const [ showAddCustomModal, setShowAddCustomModal ] = useState(false); const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined);
const [ selectedLocation, setSelectedLocation ] = useState<OptionBase | undefined>(undefined); const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]);
const [ selectedInputOption, setSelectedInputOption ] = useState<OptionBase>(inputOptions[0]); const [locations, setLocations] = useState(props.eventType.locations || []);
const [ locations, setLocations ] = useState(props.eventType.locations || []); const [schedule, setSchedule] = useState(undefined);
const [ schedule, setSchedule ] = useState(undefined); const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []); props.eventType.customInputs.sort((a, b) => a.id - b.id) || []
const locationOptions = props.locationOptions );
const locationOptions = props.locationOptions;
const titleRef = useRef<HTMLInputElement>(); const titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>(); const slugRef = useRef<HTMLInputElement>();
@ -51,10 +47,6 @@ export default function EventType(props) {
const isHiddenRef = useRef<HTMLInputElement>(); const isHiddenRef = useRef<HTMLInputElement>();
const eventNameRef = useRef<HTMLInputElement>(); const eventNameRef = useRef<HTMLInputElement>();
if (loading) {
return <p className="text-gray-400">Loading...</p>;
}
async function updateEventTypeHandler(event) { async function updateEventTypeHandler(event) {
event.preventDefault(); event.preventDefault();
@ -66,60 +58,70 @@ export default function EventType(props) {
const enteredEventName = eventNameRef.current.value; const enteredEventName = eventNameRef.current.value;
// TODO: Add validation // TODO: Add validation
const response = await fetch('/api/availability/eventtype', { await fetch("/api/availability/eventtype", {
method: 'PATCH', method: "PATCH",
body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations, eventName: enteredEventName, customInputs }), body: JSON.stringify({
id: props.eventType.id,
title: enteredTitle,
slug: enteredSlug,
description: enteredDescription,
length: enteredLength,
hidden: enteredIsHidden,
locations,
eventName: enteredEventName,
customInputs,
}),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}); });
if (schedule) { if (schedule) {
const schedulePayload = { overrides: [], timeZone: props.user.timeZone, openingHours: [] };
let schedulePayload = { "overrides": [], "timeZone": props.user.timeZone, "openingHours": [] }; schedule.forEach((item) => {
schedule.forEach( (item) => {
if (item.isOverride) { if (item.isOverride) {
delete item.isOverride; delete item.isOverride;
schedulePayload.overrides.push(item); schedulePayload.overrides.push(item);
} else { } else {
const endTime = item.endDate.hour() * 60 + item.endDate.minute() || 1440; // also handles 00:00
schedulePayload.openingHours.push({ schedulePayload.openingHours.push({
days: item.days, days: item.days,
startTime: item.startDate.hour() * 60 + item.startDate.minute(), startTime: item.startDate.hour() * 60 + item.startDate.minute() - item.startDate.utcOffset(),
endTime: item.endDate.hour() * 60 + item.endDate.minute() endTime: endTime - item.endDate.utcOffset(),
}); });
} }
}); });
const response = await fetch('/api/availability/schedule/' + props.eventType.id, { await fetch("/api/availability/schedule/" + props.eventType.id, {
method: 'PUT', method: "PUT",
body: JSON.stringify(schedulePayload), body: JSON.stringify(schedulePayload),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}); });
} }
router.push('/availability'); router.push("/availability");
} }
async function deleteEventTypeHandler(event) { async function deleteEventTypeHandler(event) {
event.preventDefault(); event.preventDefault();
const response = await fetch('/api/availability/eventtype', { await fetch("/api/availability/eventtype", {
method: 'DELETE', method: "DELETE",
body: JSON.stringify({id: props.eventType.id}), body: JSON.stringify({ id: props.eventType.id }),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}); });
router.push('/availability'); router.push("/availability");
} }
const openLocationModal = (type: LocationType) => { const openLocationModal = (type: LocationType) => {
setSelectedLocation(locationOptions.find( (option) => option.value === type)); setSelectedLocation(locationOptions.find((option) => option.value === type));
setShowLocationModal(true); setShowLocationModal(true);
} };
const closeLocationModal = () => { const closeLocationModal = () => {
setSelectedLocation(undefined); setSelectedLocation(undefined);
@ -136,27 +138,32 @@ export default function EventType(props) {
return null; return null;
} }
switch (selectedLocation.value) { switch (selectedLocation.value) {
case LocationType.InPerson: case LocationType.InPerson: {
const address = locations.find( const address = locations.find((location) => location.type === LocationType.InPerson)?.address;
(location) => location.type === LocationType.InPerson
)?.address;
return ( return (
<div> <div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">Set an address or place</label> <label htmlFor="address" className="block text-sm font-medium text-gray-700">
Set an address or place
</label>
<div className="mt-1"> <div className="mt-1">
<input type="text" name="address" id="address" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" defaultValue={address} /> <input
type="text"
name="address"
id="address"
required
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
defaultValue={address}
/>
</div> </div>
</div> </div>
) );
}
case LocationType.Phone: case LocationType.Phone:
return ( return (
<p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p> <p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p>
) );
case LocationType.GoogleMeet: case LocationType.GoogleMeet:
return ( return <p className="text-sm">Calendso will provide a Google Meet location.</p>;
<p className="text-sm">Calendso will provide a Google Meet location.</p>
)
} }
return null; return null;
}; };
@ -169,10 +176,10 @@ export default function EventType(props) {
details = { address: e.target.address.value }; details = { address: e.target.address.value };
} }
const existingIdx = locations.findIndex( (loc) => e.target.location.value === loc.type ); const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type);
if (existingIdx !== -1) { if (existingIdx !== -1) {
let copy = locations; const copy = locations;
copy[ existingIdx ] = { ...locations[ existingIdx ], ...details }; copy[existingIdx] = { ...locations[existingIdx], ...details };
setLocations(copy); setLocations(copy);
} else { } else {
setLocations(locations.concat({ type: e.target.location.value, ...details })); setLocations(locations.concat({ type: e.target.location.value, ...details }));
@ -182,7 +189,7 @@ export default function EventType(props) {
}; };
const removeLocation = (selectedLocation) => { const removeLocation = (selectedLocation) => {
setLocations(locations.filter( (location) => location.type !== selectedLocation.type )); setLocations(locations.filter((location) => location.type !== selectedLocation.type));
}; };
const updateCustom = (e) => { const updateCustom = (e) => {
@ -191,7 +198,7 @@ export default function EventType(props) {
const customInput: EventTypeCustomInput = { const customInput: EventTypeCustomInput = {
label: e.target.label.value, label: e.target.label.value,
required: e.target.required.checked, required: e.target.required.checked,
type: e.target.type.value type: e.target.type.value,
}; };
setCustomInputs(customInputs.concat(customInput)); setCustomInputs(customInputs.concat(customInput));
@ -205,20 +212,33 @@ export default function EventType(props) {
<title>{props.eventType.title} | Event Type | Calendso</title> <title>{props.eventType.title} | Event Type | Calendso</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Shell heading={'Event Type - ' + props.eventType.title}> <Shell heading={"Event Type - " + props.eventType.title}>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="col-span-3 sm:col-span-2"> <div className="col-span-3 sm:col-span-2">
<div className="bg-white overflow-hidden shadow rounded-lg mb-4"> <div className="bg-white overflow-hidden shadow rounded-lg mb-4">
<div className="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
<form onSubmit={updateEventTypeHandler}> <form onSubmit={updateEventTypeHandler}>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label> <label htmlFor="title" className="block text-sm font-medium text-gray-700">
Title
</label>
<div className="mt-1"> <div className="mt-1">
<input ref={titleRef} type="text" name="title" id="title" 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} /> <input
ref={titleRef}
type="text"
name="title"
id="title"
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}
/>
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">URL</label> <label htmlFor="slug" className="block text-sm font-medium text-gray-700">
URL
</label>
<div className="mt-1"> <div className="mt-1">
<div className="flex rounded-md shadow-sm"> <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"> <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">
@ -237,8 +257,11 @@ export default function EventType(props) {
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="location" className="block text-sm font-medium text-gray-700">Location</label> <label htmlFor="location" className="block text-sm font-medium text-gray-700">
{locations.length === 0 && <div className="mt-1 mb-2"> Location
</label>
{locations.length === 0 && (
<div className="mt-1 mb-2">
<div className="flex rounded-md shadow-sm"> <div className="flex rounded-md shadow-sm">
<Select <Select
name="location" name="location"
@ -249,9 +272,11 @@ export default function EventType(props) {
onChange={(e) => openLocationModal(e.value)} onChange={(e) => openLocationModal(e.value)}
/> />
</div> </div>
</div>} </div>
{locations.length > 0 && <ul className="w-96 mt-1"> )}
{locations.map( (location) => ( {locations.length > 0 && (
<ul className="w-96 mt-1">
{locations.map((location) => (
<li key={location.type} className="bg-blue-50 mb-2 p-2 border"> <li key={location.type} className="bg-blue-50 mb-2 p-2 border">
<div className="flex justify-between"> <div className="flex justify-between">
{location.type === LocationType.InPerson && ( {location.type === LocationType.InPerson && (
@ -268,12 +293,29 @@ export default function EventType(props) {
)} )}
{location.type === LocationType.GoogleMeet && ( {location.type === LocationType.GoogleMeet && (
<div className="flex-grow flex"> <div className="flex-grow flex">
<svg className="h-6 w-6" stroke="currentColor" fill="currentColor" stroke-width="0" role="img" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><title></title><path d="M12 0C6.28 0 1.636 4.641 1.636 10.364c0 5.421 4.945 9.817 10.364 9.817V24c6.295-3.194 10.364-8.333 10.364-13.636C22.364 4.64 17.72 0 12 0zM7.5 6.272h6.817a1.363 1.363 0 0 1 1.365 1.365v1.704l2.728-2.727v7.501l-2.726-2.726v1.703a1.362 1.362 0 0 1-1.365 1.365H7.5c-.35 0-.698-.133-.965-.4a1.358 1.358 0 0 1-.4-.965V7.637A1.362 1.362 0 0 1 7.5 6.272Z"></path></svg> <svg
className="h-6 w-6"
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
role="img"
viewBox="0 0 24 24"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg">
<title></title>
<path d="M12 0C6.28 0 1.636 4.641 1.636 10.364c0 5.421 4.945 9.817 10.364 9.817V24c6.295-3.194 10.364-8.333 10.364-13.636C22.364 4.64 17.72 0 12 0zM7.5 6.272h6.817a1.363 1.363 0 0 1 1.365 1.365v1.704l2.728-2.727v7.501l-2.726-2.726v1.703a1.362 1.362 0 0 1-1.365 1.365H7.5c-.35 0-.698-.133-.965-.4a1.358 1.358 0 0 1-.4-.965V7.637A1.362 1.362 0 0 1 7.5 6.272Z"></path>
</svg>
<span className="ml-2 text-sm">Google Meet</span> <span className="ml-2 text-sm">Google Meet</span>
</div> </div>
)} )}
<div className="flex"> <div className="flex">
<button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button> <button
type="button"
onClick={() => openLocationModal(location.type)}
className="mr-2 text-sm text-blue-600">
Edit
</button>
<button onClick={() => removeLocation(location)}> <button onClick={() => removeLocation(location)}>
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " /> <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " />
</button> </button>
@ -281,39 +323,76 @@ export default function EventType(props) {
</div> </div>
</li> </li>
))} ))}
{locations.length > 0 && locations.length !== locationOptions.length && <li> {locations.length > 0 && locations.length !== locationOptions.length && (
<button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowLocationModal(true)}> <li>
<button
type="button"
className="sm:flex sm:items-start text-sm text-blue-600"
onClick={() => setShowLocationModal(true)}>
<PlusCircleIcon className="h-6 w-6" /> <PlusCircleIcon className="h-6 w-6" />
<span className="ml-1">Add another location option</span> <span className="ml-1">Add another location option</span>
</button> </button>
</li>} </li>
</ul>} )}
</ul>
)}
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label> <label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<div className="mt-1"> <div className="mt-1">
<textarea ref={descriptionRef} name="description" 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> <textarea
ref={descriptionRef}
name="description"
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>
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="length" className="block text-sm font-medium text-gray-700">Length</label> <label htmlFor="length" className="block text-sm font-medium text-gray-700">
Length
</label>
<div className="mt-1 relative rounded-md shadow-sm"> <div className="mt-1 relative rounded-md shadow-sm">
<input ref={lengthRef} type="number" name="length" id="length" 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} /> <input
ref={lengthRef}
type="number"
name="length"
id="length"
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}
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
minutes minutes
</div> </div>
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="eventName" className="block text-sm font-medium text-gray-700">Calendar entry name</label> <label htmlFor="eventName" className="block text-sm font-medium text-gray-700">
Calendar entry name
</label>
<div className="mt-1 relative rounded-md shadow-sm"> <div className="mt-1 relative rounded-md shadow-sm">
<input ref={eventNameRef} type="text" name="title" 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} /> <input
ref={eventNameRef}
type="text"
name="title"
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}
/>
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="additionalFields" className="block text-sm font-medium text-gray-700">Additional Inputs</label> <label htmlFor="additionalFields" className="block text-sm font-medium text-gray-700">
Additional Inputs
</label>
<ul className="w-96 mt-1"> <ul className="w-96 mt-1">
{customInputs.map( (customInput) => ( {customInputs.map((customInput) => (
<li key={customInput.type} className="bg-blue-50 mb-2 p-2 border"> <li key={customInput.type} className="bg-blue-50 mb-2 p-2 border">
<div className="flex justify-between"> <div className="flex justify-between">
<div> <div>
@ -324,24 +403,27 @@ export default function EventType(props) {
<span className="ml-2 text-sm">Type: {customInput.type}</span> <span className="ml-2 text-sm">Type: {customInput.type}</span>
</div> </div>
<div> <div>
<span <span className="ml-2 text-sm">
className="ml-2 text-sm">{customInput.required ? "Required" : "Optional"}</span> {customInput.required ? "Required" : "Optional"}
</span>
</div> </div>
</div> </div>
<div className="flex"> <div className="flex">
<button type="button" onClick={() => { <button type="button" className="mr-2 text-sm text-blue-600">
}} className="mr-2 text-sm text-blue-600">Edit Edit
</button> </button>
<button onClick={() => { <button>
}}> <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " />
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 "/>
</button> </button>
</div> </div>
</div> </div>
</li> </li>
))} ))}
<li> <li>
<button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowAddCustomModal(true)}> <button
type="button"
className="sm:flex sm:items-start text-sm text-blue-600"
onClick={() => setShowAddCustomModal(true)}>
<PlusCircleIcon className="h-6 w-6" /> <PlusCircleIcon className="h-6 w-6" />
<span className="ml-1">Add another input</span> <span className="ml-1">Add another input</span>
</button> </button>
@ -364,17 +446,27 @@ export default function EventType(props) {
<label htmlFor="ishidden" className="font-medium text-gray-700"> <label htmlFor="ishidden" className="font-medium text-gray-700">
Hide this event type Hide this event type
</label> </label>
<p className="text-gray-500">Hide the event type from your page, so it can only be booked through it's URL.</p> <p className="text-gray-500">
Hide the event type from your page, so it can only be booked through it&apos;s URL.
</p>
</div> </div>
</div> </div>
</div> </div>
<hr className="my-4"/> <hr className="my-4" />
<div> <div>
<h3 className="mb-2">How do you want to offer your availability for this event type?</h3> <h3 className="mb-2">How do you want to offer your availability for this event type?</h3>
<Scheduler onChange={setSchedule} timeZone={props.user.timeZone} schedules={props.schedules} /> <Scheduler
onChange={setSchedule}
timeZone={props.user.timeZone}
schedules={props.schedules}
/>
<div className="py-4 flex justify-end"> <div className="py-4 flex justify-end">
<Link href="/availability"><a className="mr-2 btn btn-white">Cancel</a></Link> <Link href="/availability">
<button type="submit" className="btn btn-primary">Update</button> <a className="mr-2 btn btn-white">Cancel</a>
</Link>
<button type="submit" className="btn btn-primary">
Update
</button>
</div> </div>
</div> </div>
</form> </form>
@ -384,16 +476,15 @@ export default function EventType(props) {
<div> <div>
<div className="bg-white shadow sm:rounded-lg"> <div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
<h3 className="text-lg mb-2 leading-6 font-medium text-gray-900"> <h3 className="text-lg mb-2 leading-6 font-medium text-gray-900">Delete this event type</h3>
Delete this event type
</h3>
<div className="mb-4 max-w-xl text-sm text-gray-500"> <div className="mb-4 max-w-xl text-sm text-gray-500">
<p> <p>Once you delete this event type, it will be permanently removed.</p>
Once you delete this event type, it will be permanently removed.
</p>
</div> </div>
<div> <div>
<button onClick={deleteEventTypeHandler} type="button" className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"> <button
onClick={deleteEventTypeHandler}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm">
Delete event type Delete event type
</button> </button>
</div> </div>
@ -401,12 +492,20 @@ export default function EventType(props) {
</div> </div>
</div> </div>
</div> </div>
{showLocationModal && {showLocationModal && (
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <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="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> <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">&#8203;</span> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left 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="sm:flex sm:items-start mb-4">
@ -414,7 +513,9 @@ export default function EventType(props) {
<LocationMarkerIcon className="h-6 w-6 text-blue-600" /> <LocationMarkerIcon className="h-6 w-6 text-blue-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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">Edit location</h3> <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Edit location
</h3>
</div> </div>
</div> </div>
<form onSubmit={updateLocations}> <form onSubmit={updateLocations}>
@ -439,13 +540,22 @@ export default function EventType(props) {
</div> </div>
</div> </div>
</div> </div>
} )}
{showAddCustomModal && {showAddCustomModal && (
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <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="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
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
/>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left 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="sm:flex sm:items-start mb-4">
@ -453,7 +563,9 @@ export default function EventType(props) {
<PlusIcon className="h-6 w-6 text-blue-600" /> <PlusIcon className="h-6 w-6 text-blue-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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">Add new custom input field</h3> <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Add new custom input field
</h3>
<div> <div>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
This input will be shown when booking this event This input will be shown when booking this event
@ -463,7 +575,9 @@ export default function EventType(props) {
</div> </div>
<form onSubmit={updateCustom}> <form onSubmit={updateCustom}>
<div className="mb-2"> <div className="mb-2">
<label htmlFor="type" className="block text-sm font-medium text-gray-700">Input type</label> <label htmlFor="type" className="block text-sm font-medium text-gray-700">
Input type
</label>
<Select <Select
name="type" name="type"
defaultValue={selectedInputOption} defaultValue={selectedInputOption}
@ -475,13 +589,27 @@ export default function EventType(props) {
/> />
</div> </div>
<div className="mb-2"> <div className="mb-2">
<label htmlFor="label" className="block text-sm font-medium text-gray-700">Label</label> <label htmlFor="label" className="block text-sm font-medium text-gray-700">
Label
</label>
<div className="mt-1"> <div className="mt-1">
<input type="text" name="label" id="label" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" /> <input
type="text"
name="label"
id="label"
required
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div> </div>
</div> </div>
<div className="flex items-center h-5"> <div className="flex items-center h-5">
<input id="required" name="required" type="checkbox" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2" defaultChecked={true}/> <input
id="required"
name="required"
type="checkbox"
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
defaultChecked={true}
/>
<label htmlFor="required" className="block text-sm font-medium text-gray-700"> <label htmlFor="required" className="block text-sm font-medium text-gray-700">
Is required Is required
</label> </label>
@ -499,7 +627,7 @@ export default function EventType(props) {
</div> </div>
</div> </div>
</div> </div>
} )}
</Shell> </Shell>
</div> </div>
); );
@ -511,15 +639,16 @@ const validJson = (jsonString: string) => {
if (o && typeof o === "object") { if (o && typeof o === "object") {
return o; return o;
} }
} catch (e) {
// no longer empty
} }
catch (e) {}
return false; return false;
} };
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const session = await getSession(context); const session = await getSession(context);
if (!session) { if (!session) {
return { redirect: { permanent: false, destination: '/auth/login' } }; return { redirect: { permanent: false, destination: "/auth/login" } };
} }
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
@ -532,7 +661,7 @@ export async function getServerSideProps(context) {
startTime: true, startTime: true,
endTime: true, endTime: true,
availability: true, availability: true,
} },
}); });
const eventType = await prisma.eventType.findUnique({ const eventType = await prisma.eventType.findUnique({
@ -549,8 +678,8 @@ export async function getServerSideProps(context) {
locations: true, locations: true,
eventName: true, eventName: true,
availability: true, availability: true,
customInputs: true customInputs: true,
} },
}); });
const credentials = await prisma.credential.findMany({ const credentials = await prisma.credential.findMany({
@ -560,75 +689,68 @@ export async function getServerSideProps(context) {
select: { select: {
id: true, id: true,
type: true, type: true,
key: true key: true,
} },
}); });
const integrations = [ { const 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)),
enabled: credentials.find( (integration) => integration.type === "google_calendar" ) != null, enabled: credentials.find((integration) => integration.type === "google_calendar") != null,
type: "google_calendar", type: "google_calendar",
title: "Google Calendar", title: "Google Calendar",
imageSrc: "integrations/google-calendar.png", imageSrc: "integrations/google-calendar.png",
description: "For personal and business accounts", description: "For personal and business accounts",
}, { },
{
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_calendar", type: "office365_calendar",
enabled: credentials.find( (integration) => integration.type === "office365_calendar" ) != null, enabled: credentials.find((integration) => integration.type === "office365_calendar") != null,
title: "Office 365 / Outlook.com Calendar", title: "Office 365 / Outlook.com Calendar",
imageSrc: "integrations/office-365.png", imageSrc: "integrations/office-365.png",
description: "For personal and business accounts", description: "For personal and business accounts",
} ]; },
let locationOptions: OptionBase[] = [
{ value: LocationType.InPerson, label: 'In-person meeting' },
{ value: LocationType.Phone, label: 'Phone call', },
]; ];
const hasGoogleCalendarIntegration = integrations.find((i) => i.type === "google_calendar" && i.installed === true && i.enabled) const locationOptions: OptionBase[] = [
{ value: LocationType.InPerson, label: "In-person meeting" },
{ value: LocationType.Phone, label: "Phone call" },
];
const hasGoogleCalendarIntegration = integrations.find(
(i) => i.type === "google_calendar" && i.installed === true && i.enabled
);
if (hasGoogleCalendarIntegration) { if (hasGoogleCalendarIntegration) {
locationOptions.push( { value: LocationType.GoogleMeet, label: 'Google Meet' }) locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" });
} }
const hasOfficeIntegration = integrations.find((i) => i.type === "office365_calendar" && i.installed === true && i.enabled) const hasOfficeIntegration = integrations.find(
(i) => i.type === "office365_calendar" && i.installed === true && i.enabled
);
if (hasOfficeIntegration) { if (hasOfficeIntegration) {
// TODO: Add default meeting option of the office integration. // TODO: Add default meeting option of the office integration.
// Assuming it's Microsoft Teams. // Assuming it's Microsoft Teams.
} }
const eventType = await prisma.eventType.findUnique({
where: {
id: parseInt(context.query.type),
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
hidden: true,
locations: true,
eventName: true,
customInputs: true,
availability: true,
}
});
if (!eventType) { if (!eventType) {
return { return {
notFound: true, notFound: true,
} };
} }
const getAvailability = (providesAvailability) => ( const getAvailability = (providesAvailability) =>
providesAvailability.availability && providesAvailability.availability.length providesAvailability.availability && providesAvailability.availability.length
) ? providesAvailability.availability : null; ? providesAvailability.availability
: null;
const schedules = getAvailability(eventType) || getAvailability(user) || [ { const schedules = getAvailability(eventType) ||
days: [ 1, 2, 3, 4, 5, 6, 7 ], getAvailability(user) || [
{
days: [1, 2, 3, 4, 5, 6, 7],
startTime: user.startTime, startTime: user.startTime,
length: user.endTime >= 1440 ? 1439 : user.endTime, length: user.endTime >= 1440 ? 1439 : user.endTime,
} ]; },
];
return { return {
props: { props: {
@ -637,5 +759,5 @@ export async function getServerSideProps(context) {
schedules, schedules,
locationOptions, locationOptions,
}, },
} };
} }

39
test/lib/slots.test.ts Normal file
View file

@ -0,0 +1,39 @@
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-20T12:00:00Z');
it('can fit 24 hourly slots for an empty day', async () => {
// 24h in a day.
expect(getSlots({
inviteeDate: dayjs().add(1, 'day'),
length: 60,
})).toHaveLength(24);
});
it('has slots that be in the same timezone as the invitee', async() => {
expect(getSlots({
inviteeDate: dayjs().add(1, 'day'),
length: 60
})[0].utcOffset()).toBe(-0);
expect(getSlots({
inviteeDate: dayjs().tz('Europe/London').add(1, 'day'),
length: 60
})[0].utcOffset()).toBe(dayjs().tz('Europe/London').utcOffset());
})
it('excludes slots that have already passed when invitee day equals today', async () => {
expect(getSlots({ inviteeDate: dayjs(), length: 60 })).toHaveLength(12);
});
it('supports having slots in different utc offset than the invitee', async () => {
expect(getSlots({ inviteeDate: dayjs(), length: 60 })).toHaveLength(12);
expect(getSlots({ inviteeDate: dayjs().tz('Europe/Brussels'), length: 60 })).toHaveLength(14);
});

View file

@ -6,6 +6,11 @@
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@lib/*": ["lib/*"]
},
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": false, "strict": false,

1839
yarn.lock

File diff suppressed because it is too large Load diff