diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx
new file mode 100644
index 00000000..1bea6caf
--- /dev/null
+++ b/components/booking/DatePicker.tsx
@@ -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) => (
+
+ {null}
+
+ ));
+
+ // Combine placeholder days with actual days
+ setCalendar([
+ ...emptyDays,
+ ...days.map((day) => (
+
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}
+
+ )),
+ ]);
+ }, [selectedMonth, inviteeTimeZone, selectedDate]);
+
+ return selectedMonth ? (
+
+
+
{dayjs().month(selectedMonth).format("MMMM YYYY")}
+
+
+
+
+
+
+
+
+
+
+ {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
+ .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
+ .map((weekDay) => (
+
+ {weekDay}
+
+ ))}
+ {calendar}
+
+
+ ) : null;
+};
+
+export default DatePicker;
diff --git a/components/booking/Slots.tsx b/components/booking/Slots.tsx
new file mode 100644
index 00000000..8f92aaeb
--- /dev/null
+++ b/components/booking/Slots.tsx
@@ -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;
diff --git a/components/booking/TimeOptions.tsx b/components/booking/TimeOptions.tsx
index 38aafd8b..580e174e 100644
--- a/components/booking/TimeOptions.tsx
+++ b/components/booking/TimeOptions.tsx
@@ -1,73 +1,72 @@
-import {Switch} from "@headlessui/react";
+import { Switch } from "@headlessui/react";
import TimezoneSelect from "react-timezone-select";
-import {useEffect, useState} from "react";
-import {timeZone, is24h} from '../../lib/clock';
+import { useEffect, useState } from "react";
+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( () => {
+ useEffect(() => {
setIs24hClock(is24h());
setSelectedTimeZone(timeZone());
}, []);
- useEffect( () => {
- props.onSelectTimeZone(timeZone(selectedTimeZone));
+ useEffect(() => {
+ if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
+ props.onSelectTimeZone(timeZone(selectedTimeZone));
+ }
}, [selectedTimeZone]);
- useEffect( () => {
+ useEffect(() => {
props.onToggle24hClock(is24h(is24hClock));
}, [is24hClock]);
- return selectedTimeZone !== "" && (
-
-
-
Time Options
-
-
-
- am/pm
-
-
- Use setting
-
+
+
Time Options
+
+
+
+ am/pm
+
+
-
-
- 24h
-
-
+ 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"
+ )}>
+ Use setting
+
+
+
+ 24h
+
+
+
+ setSelectedTimeZone(tz.value)}
+ 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"
+ />
-
setSelectedTimeZone(tz.value)}
- 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"
- />
-
+ )
);
-}
+};
-export default TimeOptions;
\ No newline at end of file
+export default TimeOptions;
diff --git a/components/ui/PoweredByCalendso.tsx b/components/ui/PoweredByCalendso.tsx
new file mode 100644
index 00000000..2e890fa8
--- /dev/null
+++ b/components/ui/PoweredByCalendso.tsx
@@ -0,0 +1,22 @@
+import Link from "next/link";
+
+const PoweredByCalendso = () => (
+
+);
+
+export default PoweredByCalendso;
\ No newline at end of file
diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx
new file mode 100644
index 00000000..045c726d
--- /dev/null
+++ b/components/ui/Scheduler.tsx
@@ -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 }) => (
+
+
+ (item.days = selected)} />
+ 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")}
+
+
+ removeScheduleAt(idx)}
+ className="btn-sm bg-transparent px-2 py-1 ml-1">
+
+
+
+ );
+
+ return (
+
+
+
+
+
+ Timezone
+
+
+ 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"
+ />
+
+
+
+ {openingHours.map((item, idx) => (
+
+ ))}
+
+
+
+ Add another
+
+
+
+ {/*
Add date overrides
+
+ Add dates when your availability changes from your weekly hours
+
+
Add a date override */}
+
+
+ {editSchedule >= 0 && (
+
applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })}
+ onExit={() => setEditSchedule(-1)}
+ />
+ )}
+ {/*{showDateOverrideModal &&
+
+ }*/}
+
+ );
+};
diff --git a/components/ui/WeekdaySelect.tsx b/components/ui/WeekdaySelect.tsx
new file mode 100644
index 00000000..a9f371d8
--- /dev/null
+++ b/components/ui/WeekdaySelect.tsx
@@ -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 (
+
+
+ {days.map((day, idx) =>
+ activeDays[idx] ? (
+ 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}
+
+ ) : (
+ 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}
+
+ )
+ )}
+
+
+ );
+};
diff --git a/components/ui/modal/SetTimesModal.tsx b/components/ui/modal/SetTimesModal.tsx
new file mode 100644
index 00000000..2334802e
--- /dev/null
+++ b/components/ui/modal/SetTimesModal.tsx
@@ -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
();
+ const startMinsRef = useRef();
+ const endHoursRef = useRef();
+ const endMinsRef = useRef();
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Change when you are available for bookings
+
+
+
Set your work schedule
+
+
+
+
+
Start time
+
+
+ Hours
+
+
+
+
:
+
+
+ Minutes
+
+
+
+
+
+
End time
+
+
+ Hours
+
+
+
+
:
+
+
+ Minutes
+
+
+
+
+
+
+ Save
+
+
+ Cancel
+
+
+
+
+
+ );
+}
diff --git a/lib/jsonUtils.ts b/lib/jsonUtils.ts
new file mode 100644
index 00000000..3f617cb0
--- /dev/null
+++ b/lib/jsonUtils.ts
@@ -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;
+};
diff --git a/lib/slots.ts b/lib/slots.ts
index 157b56a6..5beeb9f8 100644
--- a/lib/slots.ts
+++ b/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;
};
-const getSlots = ({
- calendarTimeZone,
- eventLength,
- selectedTimeZone,
- selectedDate,
- dayStartTime,
- dayEndTime
-}) => {
+type GetSlots = {
+ inviteeDate: Dayjs;
+ frequency: number;
+ workingHours: WorkingHour[];
+ minimumBookingNotice?: number;
+ organizerTimeZone: string;
+};
- if(!selectedDate) return []
-
- const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day");
+type Boundary = {
+ lowerBound: number;
+ upperBound: number;
+};
- // 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 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),
+ };
+};
- const upperBound = selectedDate.tz(selectedTimeZone).endOf("day");
+// 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);
- // We need to start generating slots from the start of the calendarTimeZone day
- const startDateTime = lowerBound
- .tz(calendarTimeZone)
+const organizerBoundaries = (
+ workingHours: [],
+ inviteeDate: Dayjs,
+ inviteeBounds: Boundary,
+ organizerTimeZone
+): Boundary[] => {
+ const boundaries: Boundary[] = [];
+
+ const startDay: number = +inviteeDate
+ .utc()
.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;
- 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;
+ 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;
+};
- slots.push(slot.tz(selectedTimeZone));
+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
+ .utc()
+ .startOf("day")
+ .add(lowerBound + minutes, "minutes")
+ );
}
-
return slots;
};
-export default getSlots
+const getSlots = ({
+ inviteeDate,
+ frequency,
+ minimumBookingNotice,
+ workingHours,
+ organizerTimeZone,
+}: GetSlots): Dayjs[] => {
+ const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
+ ? inviteeDate.hour() * 60 + inviteeDate.minute() + (minimumBookingNotice || 0)
+ : 0;
+
+ const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
+
+ 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;
diff --git a/package.json b/package.json
index c59a83a0..b0ac1af3 100644
--- a/package.json
+++ b/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(.*)$": "/components$1",
+ "^@lib(.*)$": "/lib$1"
+ }
}
}
diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx
index bf09b987..7f81af14 100644
--- a/pages/[user]/[type].tsx
+++ b/pages/[user]/[type].tsx
@@ -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();
- 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) => (
-
- {null}
-
- ));
-
- 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) => (
- 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}
-
- )),
- ];
-
const handleSelectTimeZone = (selectedTimeZone: string): void => {
if (selectedDate) {
setSelectedDate(selectedDate.tz(selectedTimeZone));
}
+ setIsTimeOptionsOpen(false);
};
- const handleToggle24hClock = (is24hClock: boolean): void => {
- if (selectedDate) {
- setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
- }
+ const handleToggle24hClock = (is24hClock: boolean) => {
+ setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
};
return (
@@ -162,10 +94,10 @@ export default function Type(props): Type {
-
+
@@ -190,63 +122,27 @@ export default function Type(props): Type {
)}
{props.eventType.description}
-
-
-
{dayjs().month(selectedMonth).format("MMMM YYYY")}
-
-
-
-
-
-
-
-
-
-
- {props.user.weekStart !== "Monday" ? (
-
Sun
- ) : null}
-
Mon
-
Tue
-
Wed
-
Thu
-
Fri
-
Sat
- {props.user.weekStart === "Monday" ? (
-
Sun
- ) : null}
- {calendar}
-
-
+
{selectedDate && (
)}
- {!props.user.hideBranding && (
-
- )}
+ {!props.user.hideBranding &&
}
);
@@ -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,29 @@ 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");
+
+ workingHours.sort((a, b) => a.startTime - b.startTime);
+
return {
props: {
user,
eventType,
+ workingHours,
},
};
};
diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx
index 38ca732a..614bc9af 100644
--- a/pages/[user]/book.tsx
+++ b/pages/[user]/book.tsx
@@ -147,8 +147,8 @@ export default function Book(props: any): JSX.Element {
-
-
+
+
@@ -171,9 +171,9 @@ export default function Book(props: any): JSX.Element {
.tz(preferredTimeZone)
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
-
{props.eventType.description}
+
{props.eventType.description}
-