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