Working version ready for testing
* More tests to be added to verify slots logic * Adds Jest * Implements logic to the booking code to take into account grayed days * Slots take workhours into account TODO: Improve the tests, evaluate the structure, small re-orgs here and there for improved readability / better code
This commit is contained in:
		
							parent
							
								
									1dce84fa8f
								
							
						
					
					
						commit
						ef3274d8f3
					
				
					 14 changed files with 2992 additions and 872 deletions
				
			
		|  | @ -18,12 +18,14 @@ | ||||||
|   "rules": { |   "rules": { | ||||||
|     "prettier/prettier": ["error"], |     "prettier/prettier": ["error"], | ||||||
|     "@typescript-eslint/no-unused-vars": "error", |     "@typescript-eslint/no-unused-vars": "error", | ||||||
|     "react/react-in-jsx-scope": "off" |     "react/react-in-jsx-scope": "off", | ||||||
|  |     "react/prop-types": "off" | ||||||
|   }, |   }, | ||||||
|   "env": { |   "env": { | ||||||
|     "browser": true, |     "browser": true, | ||||||
|     "node": true, |     "node": true, | ||||||
|     "es6": true |     "es6": true, | ||||||
|  |     "jest": true | ||||||
|   }, |   }, | ||||||
|   "settings": { |   "settings": { | ||||||
|     "react": { |     "react": { | ||||||
|  |  | ||||||
|  | @ -1,87 +1,35 @@ | ||||||
| import dayjs, {Dayjs} from "dayjs"; |  | ||||||
| import isBetween from 'dayjs/plugin/isBetween'; |  | ||||||
| dayjs.extend(isBetween); |  | ||||||
| import {useEffect, useMemo, useState} from "react"; |  | ||||||
| import getSlots from "../../lib/slots"; |  | ||||||
| import Link from "next/link"; | import Link from "next/link"; | ||||||
| import {timeZone} from "../../lib/clock"; |  | ||||||
| import { useRouter } from "next/router"; | import { useRouter } from "next/router"; | ||||||
|  | import Slots from "./Slots"; | ||||||
| 
 | 
 | ||||||
| const AvailableTimes = (props) => { | const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat }) => { | ||||||
| 
 |  | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const { user, rescheduleUid } = router.query; |   const { user, rescheduleUid } = router.query; | ||||||
|   const [loaded, setLoaded] = useState(false); |   const { slots } = Slots({ date, eventLength, workingHours }); | ||||||
| 
 |  | ||||||
|   const times = getSlots({ |  | ||||||
|       calendarTimeZone: props.user.timeZone, |  | ||||||
|       selectedTimeZone: timeZone(), |  | ||||||
|       eventLength: props.eventType.length, |  | ||||||
|       selectedDate: props.date, |  | ||||||
|       dayStartTime: props.user.startTime, |  | ||||||
|       dayEndTime: props.user.endTime, |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|   const handleAvailableSlots = (busyTimes: []) => { |  | ||||||
|     // Check for conflicts
 |  | ||||||
|     for (let i = times.length - 1; i >= 0; i -= 1) { |  | ||||||
|       busyTimes.forEach(busyTime => { |  | ||||||
|         let startTime = dayjs(busyTime.start); |  | ||||||
|         let endTime = dayjs(busyTime.end); |  | ||||||
| 
 |  | ||||||
|         // Check if start times are the same
 |  | ||||||
|         if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) { |  | ||||||
|           times.splice(i, 1); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Check if time is between start and end times
 |  | ||||||
|         if (dayjs(times[i]).isBetween(startTime, endTime)) { |  | ||||||
|           times.splice(i, 1); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Check if slot end time is between start and end time
 |  | ||||||
|         if (dayjs(times[i]).add(props.eventType.length, 'minutes').isBetween(startTime, endTime)) { |  | ||||||
|           times.splice(i, 1); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Check if startTime is between slot
 |  | ||||||
|         if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, 'minutes'))) { |  | ||||||
|           times.splice(i, 1); |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|     // Display available times
 |  | ||||||
|     setLoaded(true); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   // Re-render only when invitee changes date
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     setLoaded(false); |  | ||||||
|     fetch(`/api/availability/${user}?dateFrom=${props.date.startOf('day').utc().format()}&dateTo=${props.date.endOf('day').utc().format()}`) |  | ||||||
|       .then( res => res.json()) |  | ||||||
|       .then(handleAvailableSlots); |  | ||||||
|   }, [props.date]); |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3  md:max-h-97 overflow-y-auto"> |     <div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3  md:max-h-97 overflow-y-auto"> | ||||||
|       <div className="text-gray-600 font-light text-xl mb-4 text-left"> |       <div className="text-gray-600 font-light text-xl mb-4 text-left"> | ||||||
|         <span className="w-1/2"> |         <span className="w-1/2">{date.format("dddd DD MMMM YYYY")}</span> | ||||||
|           {props.date.format("dddd DD MMMM YYYY")} |  | ||||||
|         </span> |  | ||||||
|       </div> |       </div> | ||||||
|       { |       {slots.length > 0 ? ( | ||||||
|         loaded ? times.map((time) => |         slots.map((slot) => ( | ||||||
|           <div key={dayjs(time).utc().format()}> |           <div key={slot.format()}> | ||||||
|             <Link |             <Link | ||||||
|               href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")}> |               href={ | ||||||
|               <a key={dayjs(time).format("hh:mma")} |                 `/${user}/book?date=${slot.utc().format()}&type=${eventTypeId}` + | ||||||
|                  className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">{dayjs(time).tz(timeZone()).format(props.timeFormat)}</a> |                 (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "") | ||||||
|  |               }> | ||||||
|  |               <a className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4"> | ||||||
|  |                 {slot.format(timeFormat)} | ||||||
|  |               </a> | ||||||
|             </Link> |             </Link> | ||||||
|           </div> |           </div> | ||||||
|         ) : <div className="loader"></div> |         )) | ||||||
|       } |       ) : ( | ||||||
|  |         <div className="loader" /> | ||||||
|  |       )} | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | }; | ||||||
| 
 | 
 | ||||||
| export default AvailableTimes; | export default AvailableTimes; | ||||||
|  |  | ||||||
|  | @ -1,90 +1,90 @@ | ||||||
| import dayjs from "dayjs"; |  | ||||||
| import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; | import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; | ||||||
| import { useEffect, useState } from "react"; | import { useEffect, useState } from "react"; | ||||||
|  | import dayjs, { Dayjs } from "dayjs"; | ||||||
|  | import isToday from "dayjs/plugin/isToday"; | ||||||
|  | dayjs.extend(isToday); | ||||||
| 
 | 
 | ||||||
| const DatePicker = ({ weekStart, onDatePicked }) => { | const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) => { | ||||||
| 
 |   const workingDays = workingHours.reduce((workingDays: number[], wh) => [...workingDays, ...wh.days], []); | ||||||
|   const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); |   const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); | ||||||
|   const [selectedDay, setSelectedDay] = useState(dayjs().date()); |   const [selectedDate, setSelectedDate] = useState(); | ||||||
|   const [hasPickedDate, setHasPickedDate] = useState(false); |  | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (hasPickedDate) { |     if (selectedDate) onDatePicked(selectedDate); | ||||||
|       onDatePicked(dayjs().month(selectedMonth).date(selectedDay)); |   }, [selectedDate, onDatePicked]); | ||||||
|     } |  | ||||||
|   }, [hasPickedDate, selectedDay]); |  | ||||||
| 
 | 
 | ||||||
|   // Handle month changes
 |   // Handle month changes
 | ||||||
|   const incrementMonth = () => { |   const incrementMonth = () => { | ||||||
|     setSelectedMonth(selectedMonth + 1); |     setSelectedMonth(selectedMonth + 1); | ||||||
|   } |   }; | ||||||
| 
 | 
 | ||||||
|   const decrementMonth = () => { |   const decrementMonth = () => { | ||||||
|     setSelectedMonth(selectedMonth - 1); |     setSelectedMonth(selectedMonth - 1); | ||||||
|   } |   }; | ||||||
| 
 | 
 | ||||||
|   // Set up calendar
 |   // Set up calendar
 | ||||||
|   var daysInMonth = dayjs().month(selectedMonth).daysInMonth(); |   const daysInMonth = dayjs().month(selectedMonth).daysInMonth(); | ||||||
|   var days = []; |   const days = []; | ||||||
|   for (let i = 1; i <= daysInMonth; i++) { |   for (let i = 1; i <= daysInMonth; i++) { | ||||||
|     days.push(i); |     days.push(i); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Create placeholder elements for empty days in first week
 |   // Create placeholder elements for empty days in first week
 | ||||||
|   let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); |   let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); | ||||||
|   if (weekStart === 'Monday') { |   if (weekStart === "Monday") { | ||||||
|     weekdayOfFirst -= 1; |     weekdayOfFirst -= 1; | ||||||
|     if (weekdayOfFirst < 0) |     if (weekdayOfFirst < 0) weekdayOfFirst = 6; | ||||||
|       weekdayOfFirst = 6; |  | ||||||
|   } |   } | ||||||
|   const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) => |   const emptyDays = Array(weekdayOfFirst) | ||||||
|  |     .fill(null) | ||||||
|  |     .map((day, i) => ( | ||||||
|       <div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}> |       <div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}> | ||||||
|         {null} |         {null} | ||||||
|       </div> |       </div> | ||||||
|  |     )); | ||||||
|  | 
 | ||||||
|  |   const isDisabled = (day: number) => { | ||||||
|  |     const date: Dayjs = dayjs().month(selectedMonth).date(day); | ||||||
|  |     return ( | ||||||
|  |       date.isBefore(dayjs()) || !workingDays.includes(+date.format("d")) || (date.isToday() && disableToday) | ||||||
|     ); |     ); | ||||||
|  |   }; | ||||||
| 
 | 
 | ||||||
|   // Combine placeholder days with actual days
 |   // Combine placeholder days with actual days
 | ||||||
|   const calendar = [...emptyDays, ...days.map((day) => |   const calendar = [ | ||||||
|     <button key={day} |     ...emptyDays, | ||||||
|             onClick={() => { setHasPickedDate(true); setSelectedDay(day) }} |     ...days.map((day) => ( | ||||||
|  |       <button | ||||||
|  |         key={day} | ||||||
|  |         onClick={() => setSelectedDate(dayjs().month(selectedMonth).date(day))} | ||||||
|         disabled={ |         disabled={ | ||||||
|               selectedMonth < parseInt(dayjs().format('MM')) && dayjs().month(selectedMonth).format("D") > day |           (selectedMonth < parseInt(dayjs().format("MM")) && | ||||||
|  |             dayjs().month(selectedMonth).format("D") > day) || | ||||||
|  |           isDisabled(day) | ||||||
|         } |         } | ||||||
|         className={ |         className={ | ||||||
|               "text-center w-10 h-10 rounded-full mx-auto " + ( |           "text-center w-10 h-10 rounded-full mx-auto" + | ||||||
|                 dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth) |           (isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") + | ||||||
|                 ) ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-400 font-light' |           (selectedDate && selectedDate.isSame(dayjs().month(selectedMonth).date(day), "day") | ||||||
|               ) + ( |             ? " bg-blue-600 text-white-important" | ||||||
|                 dayjs().date(selectedDay).month(selectedMonth).format("D") == day ? ' bg-blue-600 text-white-important' : '' |             : !isDisabled(day) | ||||||
|               ) |             ? " bg-blue-50" | ||||||
|  |             : "") | ||||||
|         }> |         }> | ||||||
|         {day} |         {day} | ||||||
|       </button> |       </button> | ||||||
|   )]; |     )), | ||||||
|  |   ]; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div |     <div className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}> | ||||||
|       className={ |  | ||||||
|         "mt-8 sm:mt-0 " + |  | ||||||
|         (hasPickedDate |  | ||||||
|           ? "sm:w-1/3 border-r sm:px-4" |  | ||||||
|           : "sm:w-1/2 sm:pl-4") |  | ||||||
|       } |  | ||||||
|     > |  | ||||||
|       <div className="flex text-gray-600 font-light text-xl mb-4 ml-2"> |       <div className="flex text-gray-600 font-light text-xl mb-4 ml-2"> | ||||||
|                   <span className="w-1/2"> |         <span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span> | ||||||
|                     {dayjs().month(selectedMonth).format("MMMM YYYY")} |  | ||||||
|                   </span> |  | ||||||
|         <div className="w-1/2 text-right"> |         <div className="w-1/2 text-right"> | ||||||
|           <button |           <button | ||||||
|             onClick={decrementMonth} |             onClick={decrementMonth} | ||||||
|             className={ |             className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")} | ||||||
|               "mr-4 " + |             disabled={selectedMonth < parseInt(dayjs().format("MM"))}> | ||||||
|               (selectedMonth < parseInt(dayjs().format("MM")) && |  | ||||||
|                 "text-gray-400") |  | ||||||
|             } |  | ||||||
|             disabled={selectedMonth < parseInt(dayjs().format("MM"))} |  | ||||||
|           > |  | ||||||
|             <ChevronLeftIcon className="w-5 h-5" /> |             <ChevronLeftIcon className="w-5 h-5" /> | ||||||
|           </button> |           </button> | ||||||
|           <button onClick={incrementMonth}> |           <button onClick={incrementMonth}> | ||||||
|  | @ -93,17 +93,17 @@ const DatePicker = ({ weekStart, onDatePicked }) => { | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|       <div className="grid grid-cols-7 gap-y-4 text-center"> |       <div className="grid grid-cols-7 gap-y-4 text-center"> | ||||||
|         { |         {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] | ||||||
|           ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] |           .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0)) | ||||||
|             .sort( (a, b) => weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0 ) |           .map((weekDay) => ( | ||||||
|             .map( (weekDay) => |             <div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest"> | ||||||
|               <div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">{weekDay}</div> |               {weekDay} | ||||||
|             ) |             </div> | ||||||
|         } |           ))} | ||||||
|         {calendar} |         {calendar} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | }; | ||||||
| 
 | 
 | ||||||
| export default DatePicker; | export default DatePicker; | ||||||
							
								
								
									
										66
									
								
								components/booking/Slots.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								components/booking/Slots.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | ||||||
|  | import { useState, useEffect } from "react"; | ||||||
|  | import { useRouter } from "next/router"; | ||||||
|  | import getSlots from "../../lib/slots"; | ||||||
|  | 
 | ||||||
|  | const Slots = (props) => { | ||||||
|  |   const router = useRouter(); | ||||||
|  |   const { user } = router.query; | ||||||
|  |   const [slots, setSlots] = useState([]); | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     setSlots([]); | ||||||
|  |     fetch( | ||||||
|  |       `/api/availability/${user}?dateFrom=${props.date.startOf("day").utc().format()}&dateTo=${props.date | ||||||
|  |         .endOf("day") | ||||||
|  |         .utc() | ||||||
|  |         .format()}` | ||||||
|  |     ) | ||||||
|  |       .then((res) => res.json()) | ||||||
|  |       .then(handleAvailableSlots); | ||||||
|  |   }, [props.date]); | ||||||
|  | 
 | ||||||
|  |   const handleAvailableSlots = (busyTimes: []) => { | ||||||
|  |     const times = getSlots({ | ||||||
|  |       frequency: props.eventLength, | ||||||
|  |       inviteeDate: props.date, | ||||||
|  |       workingHours: props.workingHours, | ||||||
|  |       minimumBookingNotice: 0, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     // Check for conflicts
 | ||||||
|  |     for (let i = times.length - 1; i >= 0; i -= 1) { | ||||||
|  |       busyTimes.forEach((busyTime) => { | ||||||
|  |         const startTime = dayjs(busyTime.start); | ||||||
|  |         const endTime = dayjs(busyTime.end); | ||||||
|  | 
 | ||||||
|  |         // Check if start times are the same
 | ||||||
|  |         if (dayjs(times[i]).format("HH:mm") == startTime.format("HH:mm")) { | ||||||
|  |           times.splice(i, 1); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Check if time is between start and end times
 | ||||||
|  |         if (dayjs(times[i]).isBetween(startTime, endTime)) { | ||||||
|  |           times.splice(i, 1); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Check if slot end time is between start and end time
 | ||||||
|  |         if (dayjs(times[i]).add(props.eventType.length, "minutes").isBetween(startTime, endTime)) { | ||||||
|  |           times.splice(i, 1); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Check if startTime is between slot
 | ||||||
|  |         if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, "minutes"))) { | ||||||
|  |           times.splice(i, 1); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |     // Display available times
 | ||||||
|  |     setSlots(times); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     slots, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default Slots; | ||||||
|  | @ -1,45 +1,48 @@ | ||||||
| import React, { useEffect, useState } from "react"; | import React, { useEffect, useState } from "react"; | ||||||
| import TimezoneSelect from "react-timezone-select"; | import TimezoneSelect from "react-timezone-select"; | ||||||
| import {PencilAltIcon, TrashIcon} from "@heroicons/react/outline"; | import { TrashIcon } from "@heroicons/react/outline"; | ||||||
| import {WeekdaySelect, Weekday} from "./WeekdaySelect"; | import { WeekdaySelect } from "./WeekdaySelect"; | ||||||
| import SetTimesModal from "./modal/SetTimesModal"; | import SetTimesModal from "./modal/SetTimesModal"; | ||||||
| import Schedule from '../../lib/schedule.model'; | import Schedule from "../../lib/schedule.model"; | ||||||
| import dayjs, {Dayjs} from "dayjs"; | import dayjs from "dayjs"; | ||||||
| import utc from 'dayjs/plugin/utc'; | import utc from "dayjs/plugin/utc"; | ||||||
| import timezone from 'dayjs/plugin/timezone'; | import timezone from "dayjs/plugin/timezone"; | ||||||
| dayjs.extend(utc); | dayjs.extend(utc); | ||||||
| dayjs.extend(timezone); | dayjs.extend(timezone); | ||||||
| 
 | 
 | ||||||
| export const Scheduler = (props) => { | export const Scheduler = (props) => { | ||||||
| 
 |   const [schedules, setSchedules]: Schedule[] = useState( | ||||||
|   const [ schedules, setSchedules ]: Schedule[] = useState(props.schedules.map( schedule => { |     props.schedules.map((schedule) => { | ||||||
|     const startDate = schedule.isOverride ? dayjs(schedule.startDate) : dayjs.utc().startOf('day').add(schedule.startTime, 'minutes') |       const startDate = schedule.isOverride | ||||||
|     return ( |         ? dayjs(schedule.startDate) | ||||||
|       { |         : dayjs.utc().startOf("day").add(schedule.startTime, "minutes").tz(props.timeZone); | ||||||
|  |       return { | ||||||
|         days: schedule.days, |         days: schedule.days, | ||||||
|         startDate, |         startDate, | ||||||
|         endDate: startDate.add(schedule.length, 'minutes') |         endDate: startDate.add(schedule.length, "minutes"), | ||||||
|       } |       }; | ||||||
|     ) |     }) | ||||||
|   })); |   ); | ||||||
| 
 | 
 | ||||||
|   const [timeZone, setTimeZone] = useState(props.timeZone); |   const [timeZone, setTimeZone] = useState(props.timeZone); | ||||||
|   const [editSchedule, setEditSchedule] = useState(-1); |   const [editSchedule, setEditSchedule] = useState(-1); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     props.onChange(schedules); |     props.onChange(schedules); | ||||||
|   }, [schedules]) |   }, [schedules]); | ||||||
| 
 | 
 | ||||||
|   const addNewSchedule = () => setEditSchedule(schedules.length); |   const addNewSchedule = () => setEditSchedule(schedules.length); | ||||||
| 
 | 
 | ||||||
|   const applyEditSchedule = (changed: Schedule) => { |   const applyEditSchedule = (changed: Schedule) => { | ||||||
|     const replaceWith = { |     const replaceWith = { | ||||||
|       ...schedules[editSchedule], |       ...schedules[editSchedule], | ||||||
|       ...changed |       ...changed, | ||||||
|     }; |     }; | ||||||
|  | 
 | ||||||
|     schedules.splice(editSchedule, 1, replaceWith); |     schedules.splice(editSchedule, 1, replaceWith); | ||||||
|  | 
 | ||||||
|     setSchedules([].concat(schedules)); |     setSchedules([].concat(schedules)); | ||||||
|   } |   }; | ||||||
| 
 | 
 | ||||||
|   const removeScheduleAt = (toRemove: number) => { |   const removeScheduleAt = (toRemove: number) => { | ||||||
|     schedules.splice(toRemove, 1); |     schedules.splice(toRemove, 1); | ||||||
|  | @ -49,7 +52,7 @@ export const Scheduler = (props) => { | ||||||
|   const setWeekdays = (idx: number, days: number[]) => { |   const setWeekdays = (idx: number, days: number[]) => { | ||||||
|     schedules[idx].days = days; |     schedules[idx].days = days; | ||||||
|     setSchedules([].concat(schedules)); |     setSchedules([].concat(schedules)); | ||||||
|   } |   }; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div> |     <div> | ||||||
|  | @ -60,26 +63,40 @@ export const Scheduler = (props) => { | ||||||
|               Timezone |               Timezone | ||||||
|             </label> |             </label> | ||||||
|             <div className="mt-1"> |             <div className="mt-1"> | ||||||
|               <TimezoneSelect id="timeZone" value={timeZone} onChange={setTimeZone} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" /> |               <TimezoneSelect | ||||||
|  |                 id="timeZone" | ||||||
|  |                 value={timeZone} | ||||||
|  |                 onChange={setTimeZone} | ||||||
|  |                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|  |               /> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <ul> |           <ul> | ||||||
|             {schedules.map( (schedule, idx) => |             {schedules.map((schedule, idx) => ( | ||||||
|               <li key={idx} className="py-2 flex justify-between border-t"> |               <li key={idx} className="py-2 flex justify-between border-t"> | ||||||
|                 <div className="inline-flex ml-2"> |                 <div className="inline-flex ml-2"> | ||||||
|                 <WeekdaySelect defaultValue={schedules[idx].days} onSelect={(days: number[]) => setWeekdays(idx, days)} /> |                   <WeekdaySelect | ||||||
|  |                     defaultValue={schedules[idx].days} | ||||||
|  |                     onSelect={(days: number[]) => setWeekdays(idx, days)} | ||||||
|  |                   /> | ||||||
|                   <button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}> |                   <button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}> | ||||||
|                   {schedule.startDate.format(schedule.startDate.minute() === 0 ? 'ha' : 'h:mma')} until {schedule.endDate.format(schedule.endDate.minute() === 0 ? 'ha' : 'h:mma')} |                     {dayjs(schedule.startDate).format(schedule.startDate.minute() === 0 ? "ha" : "h:mma")}{" "} | ||||||
|  |                     until {dayjs(schedule.endDate).format(schedule.endDate.minute() === 0 ? "ha" : "h:mma")} | ||||||
|                   </button> |                   </button> | ||||||
|                 </div> |                 </div> | ||||||
|               <button type="button" onClick={() => removeScheduleAt(idx)} |                 <button | ||||||
|  |                   type="button" | ||||||
|  |                   onClick={() => removeScheduleAt(idx)} | ||||||
|                   className="btn-sm bg-transparent px-2 py-1 ml-1"> |                   className="btn-sm bg-transparent px-2 py-1 ml-1"> | ||||||
|                   <TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" /> |                   <TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" /> | ||||||
|                 </button> |                 </button> | ||||||
|             </li>)} |               </li> | ||||||
|  |             ))} | ||||||
|           </ul> |           </ul> | ||||||
|           <hr /> |           <hr /> | ||||||
|           <button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">Add another</button> |           <button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2"> | ||||||
|  |             Add another | ||||||
|  |           </button> | ||||||
|         </div> |         </div> | ||||||
|         <div className="border-l p-2 w-2/5 text-sm bg-gray-50"> |         <div className="border-l p-2 w-2/5 text-sm bg-gray-50"> | ||||||
|           {/*<p className="font-bold mb-2">Add date overrides</p> |           {/*<p className="font-bold mb-2">Add date overrides</p> | ||||||
|  | @ -89,14 +106,16 @@ export const Scheduler = (props) => { | ||||||
|           <button className="btn-sm btn-white">Add a date override</button>*/} |           <button className="btn-sm btn-white">Add a date override</button>*/} | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|       {editSchedule >= 0 && |       {editSchedule >= 0 && ( | ||||||
|         <SetTimesModal schedule={schedules[editSchedule]} |         <SetTimesModal | ||||||
|  |           schedule={schedules[editSchedule]} | ||||||
|           onChange={applyEditSchedule} |           onChange={applyEditSchedule} | ||||||
|                        onExit={() => setEditSchedule(-1)} /> |           onExit={() => setEditSchedule(-1)} | ||||||
|       } |         /> | ||||||
|  |       )} | ||||||
|       {/*{showDateOverrideModal && |       {/*{showDateOverrideModal && | ||||||
|         <DateOverrideModal /> |         <DateOverrideModal /> | ||||||
|       }*/} |       }*/} | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | }; | ||||||
|  |  | ||||||
|  | @ -1,42 +1,53 @@ | ||||||
| import React, { useEffect, useState } from "react"; | import React, { useEffect, useState } from "react"; | ||||||
| 
 | 
 | ||||||
| export const WeekdaySelect = (props) => { | export const WeekdaySelect = (props) => { | ||||||
|  |   const [activeDays, setActiveDays] = useState( | ||||||
|  |     [...Array(7).keys()].map((v, i) => (props.defaultValue || []).includes(i)) | ||||||
|  |   ); | ||||||
| 
 | 
 | ||||||
|   const [ activeDays, setActiveDays ] = useState([1,2,3,4,5,6,7].map( (v) => (props.defaultValue || []).indexOf(v) !== -1)); |   const days = ["S", "M", "T", "W", "T", "F", "S"]; | ||||||
|   const days = [ 'S', 'M', 'T', 'W', 'T', 'F', 'S' ]; |  | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     props.onSelect(activeDays.map( (isActive, idx) => isActive ? idx + 1 : 0).filter( (v) => 0 !== v )); |     props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1)); | ||||||
|   }, [activeDays]); |   }, [activeDays]); | ||||||
| 
 | 
 | ||||||
|   const toggleDay = (e, idx: number) => { |   const toggleDay = (e, idx: number) => { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     activeDays[idx] = !activeDays[idx]; |     activeDays[idx] = !activeDays[idx]; | ||||||
|     setActiveDays([].concat(activeDays)); |     setActiveDays([].concat(activeDays)); | ||||||
|   } |   }; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className="weekdaySelect"> |     <div className="weekdaySelect"> | ||||||
|       <div className="inline-flex"> |       <div className="inline-flex"> | ||||||
|         {days.map( (day, idx) => activeDays[idx] ? |         {days.map((day, idx) => | ||||||
|           <button key={idx} onClick={(e) => toggleDay(e, idx)} |           activeDays[idx] ? ( | ||||||
|                   style={ {"marginLeft": "-2px"} } |             <button | ||||||
|  |               key={idx} | ||||||
|  |               onClick={(e) => toggleDay(e, idx)} | ||||||
|  |               style={{ marginLeft: "-2px" }} | ||||||
|               className={` |               className={` | ||||||
|                     active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded  |                     active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded  | ||||||
|                     ${activeDays[idx+1] ? 'rounded-r-none': ''}  |                     ${activeDays[idx + 1] ? "rounded-r-none" : ""}  | ||||||
|                     ${activeDays[idx-1] ? 'rounded-l-none': ''}  |                     ${activeDays[idx - 1] ? "rounded-l-none" : ""}  | ||||||
|                     ${idx === 0 ? 'rounded-l' : ''}  |                     ${idx === 0 ? "rounded-l" : ""}  | ||||||
|                     ${idx === days.length-1 ? 'rounded-r' : ''} |                     ${idx === days.length - 1 ? "rounded-r" : ""} | ||||||
|                   `}>
 |                   `}>
 | ||||||
|               {day} |               {day} | ||||||
|             </button> |             </button> | ||||||
|           : |           ) : ( | ||||||
|           <button key={idx} onClick={(e) => toggleDay(e, idx)} |             <button | ||||||
|                   style={ {"marginTop": "1px", "marginBottom": "1px"} } |               key={idx} | ||||||
|                   className={`border focus:outline-none px-2 py-1 rounded-none ${idx === 0 ? 'rounded-l' : 'border-l-0'} ${idx === days.length-1 ? 'rounded-r' : ''}`}> |               onClick={(e) => toggleDay(e, idx)} | ||||||
|  |               style={{ marginTop: "1px", marginBottom: "1px" }} | ||||||
|  |               className={`border focus:outline-none px-2 py-1 rounded-none ${ | ||||||
|  |                 idx === 0 ? "rounded-l" : "border-l-0" | ||||||
|  |               } ${idx === days.length - 1 ? "rounded-r" : ""}`}>
 | ||||||
|               {day} |               {day} | ||||||
|             </button> |             </button> | ||||||
|  |           ) | ||||||
|         )} |         )} | ||||||
|       </div> |       </div> | ||||||
|     </div>); |     </div> | ||||||
| } |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -1,14 +1,20 @@ | ||||||
| import { ClockIcon } from "@heroicons/react/outline"; | import { ClockIcon } from "@heroicons/react/outline"; | ||||||
| import { useRef } from "react"; | import { useRef } from "react"; | ||||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||||
|  | import utc from "dayjs/plugin/utc"; | ||||||
|  | import timezone from "dayjs/plugin/utc"; | ||||||
|  | dayjs.extend(utc); | ||||||
|  | dayjs.extend(timezone); | ||||||
| 
 | 
 | ||||||
| export default function SetTimesModal(props) { | export default function SetTimesModal(props) { | ||||||
| 
 |  | ||||||
|   const { startDate, endDate } = props.schedule || { |   const { startDate, endDate } = props.schedule || { | ||||||
|     startDate: dayjs().startOf('day').add(540, 'minutes'), |     startDate: dayjs.utc().startOf("day").add(540, "minutes"), | ||||||
|     endDate: dayjs().startOf('day').add(1020, 'minutes'), |     endDate: dayjs.utc().startOf("day").add(1020, "minutes"), | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   startDate.tz(props.timeZone); | ||||||
|  |   endDate.tz(props.timeZone); | ||||||
|  | 
 | ||||||
|   const startHoursRef = useRef<HTMLInputElement>(); |   const startHoursRef = useRef<HTMLInputElement>(); | ||||||
|   const startMinsRef = useRef<HTMLInputElement>(); |   const startMinsRef = useRef<HTMLInputElement>(); | ||||||
|   const endHoursRef = useRef<HTMLInputElement>(); |   const endHoursRef = useRef<HTMLInputElement>(); | ||||||
|  | @ -31,17 +37,21 @@ export default function SetTimesModal(props) { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> |     <div | ||||||
|  |       className="fixed z-10 inset-0 overflow-y-auto" | ||||||
|  |       aria-labelledby="modal-title" | ||||||
|  |       role="dialog" | ||||||
|  |       aria-modal="true"> | ||||||
|       <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> |       <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> | ||||||
|         <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> |         <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> | ||||||
| 
 | 
 | ||||||
|         <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span> |         <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> | ||||||
|  |           ​ | ||||||
|  |         </span> | ||||||
| 
 | 
 | ||||||
|         <div |         <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> | ||||||
|           className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> |  | ||||||
|           <div className="sm:flex sm:items-start mb-4"> |           <div className="sm:flex sm:items-start mb-4"> | ||||||
|             <div |             <div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"> | ||||||
|               className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"> |  | ||||||
|               <ClockIcon className="h-6 w-6 text-blue-600" /> |               <ClockIcon className="h-6 w-6 text-blue-600" /> | ||||||
|             </div> |             </div> | ||||||
|             <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> |             <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | ||||||
|  | @ -49,42 +59,86 @@ export default function SetTimesModal(props) { | ||||||
|                 Change when you are available for bookings |                 Change when you are available for bookings | ||||||
|               </h3> |               </h3> | ||||||
|               <div> |               <div> | ||||||
|                 <p className="text-sm text-gray-500"> |                 <p className="text-sm text-gray-500">Set your work schedule</p> | ||||||
|                   Set your work schedule |  | ||||||
|                 </p> |  | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div className="flex mb-4"> |           <div className="flex mb-4"> | ||||||
|             <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label> |             <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label> | ||||||
|             <div> |             <div> | ||||||
|               <label htmlFor="startHours" className="sr-only">Hours</label> |               <label htmlFor="startHours" className="sr-only"> | ||||||
|               <input ref={startHoursRef} type="number" min="0" max="23" maxLength="2"  name="hours" id="startHours" |                 Hours | ||||||
|  |               </label> | ||||||
|  |               <input | ||||||
|  |                 ref={startHoursRef} | ||||||
|  |                 type="number" | ||||||
|  |                 min="0" | ||||||
|  |                 max="23" | ||||||
|  |                 maxLength="2" | ||||||
|  |                 name="hours" | ||||||
|  |                 id="startHours" | ||||||
|                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" |                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|                      placeholder="9" defaultValue={startDate.format('H')} /> |                 placeholder="9" | ||||||
|  |                 defaultValue={startDate.format("H")} | ||||||
|  |               /> | ||||||
|             </div> |             </div> | ||||||
|             <span className="mx-2 pt-1">:</span> |             <span className="mx-2 pt-1">:</span> | ||||||
|             <div> |             <div> | ||||||
|               <label htmlFor="startMinutes" className="sr-only">Minutes</label> |               <label htmlFor="startMinutes" className="sr-only"> | ||||||
|               <input ref={startMinsRef} type="number" min="0" max="59" step="15" maxLength="2" name="minutes" id="startMinutes" |                 Minutes | ||||||
|  |               </label> | ||||||
|  |               <input | ||||||
|  |                 ref={startMinsRef} | ||||||
|  |                 type="number" | ||||||
|  |                 min="0" | ||||||
|  |                 max="59" | ||||||
|  |                 step="15" | ||||||
|  |                 maxLength="2" | ||||||
|  |                 name="minutes" | ||||||
|  |                 id="startMinutes" | ||||||
|                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" |                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|                      placeholder="30" defaultValue={startDate.format('m')} /> |                 placeholder="30" | ||||||
|  |                 defaultValue={startDate.format("m")} | ||||||
|  |               /> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div className="flex"> |           <div className="flex"> | ||||||
|             <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label> |             <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label> | ||||||
|             <div> |             <div> | ||||||
|               <label htmlFor="endHours" className="sr-only">Hours</label> |               <label htmlFor="endHours" className="sr-only"> | ||||||
|               <input ref={endHoursRef} type="number" min="0" max="23" maxLength="2" name="hours" id="endHours" |                 Hours | ||||||
|  |               </label> | ||||||
|  |               <input | ||||||
|  |                 ref={endHoursRef} | ||||||
|  |                 type="number" | ||||||
|  |                 min="0" | ||||||
|  |                 max="24" | ||||||
|  |                 maxLength="2" | ||||||
|  |                 name="hours" | ||||||
|  |                 id="endHours" | ||||||
|                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" |                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|                      placeholder="17" defaultValue={endDate.format('H')} /> |                 placeholder="17" | ||||||
|  |                 defaultValue={endDate.format("H")} | ||||||
|  |               /> | ||||||
|             </div> |             </div> | ||||||
|             <span className="mx-2 pt-1">:</span> |             <span className="mx-2 pt-1">:</span> | ||||||
|             <div> |             <div> | ||||||
|               <label htmlFor="endMinutes" className="sr-only">Minutes</label> |               <label htmlFor="endMinutes" className="sr-only"> | ||||||
|               <input ref={endMinsRef} type="number" min="0" max="59" maxLength="2" step="15" name="minutes" id="endMinutes" |                 Minutes | ||||||
|  |               </label> | ||||||
|  |               <input | ||||||
|  |                 ref={endMinsRef} | ||||||
|  |                 type="number" | ||||||
|  |                 min="0" | ||||||
|  |                 max="59" | ||||||
|  |                 maxLength="2" | ||||||
|  |                 step="15" | ||||||
|  |                 name="minutes" | ||||||
|  |                 id="endMinutes" | ||||||
|                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" |                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|                      placeholder="30" defaultValue={endDate.format('m')} /> |                 placeholder="30" | ||||||
|  |                 defaultValue={endDate.format("m")} | ||||||
|  |               /> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"> |           <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"> | ||||||
|  | @ -97,5 +151,6 @@ export default function SetTimesModal(props) { | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </div>); |     </div> | ||||||
|  |   ); | ||||||
| } | } | ||||||
							
								
								
									
										172
									
								
								lib/slots.ts
									
									
									
									
									
								
							
							
						
						
									
										172
									
								
								lib/slots.ts
									
									
									
									
									
								
							|  | @ -1,94 +1,108 @@ | ||||||
| const dayjs = require("dayjs"); | import dayjs, { Dayjs } from "dayjs"; | ||||||
| 
 | import utc from "dayjs/plugin/utc"; | ||||||
| const isToday = require("dayjs/plugin/isToday"); |  | ||||||
| const utc = require("dayjs/plugin/utc"); |  | ||||||
| const timezone = require("dayjs/plugin/timezone"); |  | ||||||
| 
 |  | ||||||
| dayjs.extend(isToday); |  | ||||||
| dayjs.extend(utc); | dayjs.extend(utc); | ||||||
| dayjs.extend(timezone); |  | ||||||
| 
 | 
 | ||||||
| const getMinutesFromMidnight = (date) => { | interface GetSlotsType { | ||||||
|   return date.hour() * 60 + date.minute(); |   inviteeDate: Dayjs; | ||||||
|  |   frequency: number; | ||||||
|  |   workingHours: { [WeekDay]: Boundary[] }; | ||||||
|  |   minimumBookingNotice?: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface Boundary { | ||||||
|  |   lowerBound: number; | ||||||
|  |   upperBound: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const freqApply: number = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency; | ||||||
|  | 
 | ||||||
|  | const intersectBoundary = (a: Boundary, b: Boundary) => { | ||||||
|  |   if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   return { | ||||||
|  |     lowerBound: Math.max(b.lowerBound, a.lowerBound), | ||||||
|  |     upperBound: Math.min(b.upperBound, a.upperBound), | ||||||
|  |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const getSlots = ({ | // say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
 | ||||||
|   calendarTimeZone, | const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) => | ||||||
|   eventLength, |   boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean); | ||||||
|   selectedTimeZone, |  | ||||||
|   selectedDate, |  | ||||||
|   dayStartTime, |  | ||||||
|   dayEndTime |  | ||||||
| }) => { |  | ||||||
| 
 | 
 | ||||||
|   if(!selectedDate) return [] | const organizerBoundaries = (workingHours: [], inviteeDate: Dayjs, inviteeBounds: Boundary): Boundary[] => { | ||||||
|  |   const boundaries: Boundary[] = []; | ||||||
| 
 | 
 | ||||||
|   const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day"); |   const startDay: number = +inviteeDate | ||||||
| 
 |     .utc() | ||||||
|   // Simple case, same timezone
 |  | ||||||
|   if (calendarTimeZone === selectedTimeZone) { |  | ||||||
|     const slots = []; |  | ||||||
|     const now = dayjs(); |  | ||||||
|     for ( |  | ||||||
|       let minutes = dayStartTime; |  | ||||||
|       minutes <= dayEndTime - eventLength; |  | ||||||
|       minutes += parseInt(eventLength, 10) |  | ||||||
|     ) { |  | ||||||
|       const slot = lowerBound.add(minutes, "minutes"); |  | ||||||
|       if (slot > now) { |  | ||||||
|         slots.push(slot); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     return slots; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const upperBound = selectedDate.tz(selectedTimeZone).endOf("day"); |  | ||||||
| 
 |  | ||||||
|   // We need to start generating slots from the start of the calendarTimeZone day
 |  | ||||||
|   const startDateTime = lowerBound |  | ||||||
|     .tz(calendarTimeZone) |  | ||||||
|     .startOf("day") |     .startOf("day") | ||||||
|     .add(dayStartTime, "minutes"); |     .add(inviteeBounds.lowerBound, "minutes") | ||||||
|  |     .format("d"); | ||||||
|  |   const endDay: number = +inviteeDate | ||||||
|  |     .utc() | ||||||
|  |     .startOf("day") | ||||||
|  |     .add(inviteeBounds.upperBound, "minutes") | ||||||
|  |     .format("d"); | ||||||
| 
 | 
 | ||||||
|   let phase = 0; |   workingHours.forEach((item) => { | ||||||
|   if (startDateTime < lowerBound) { |     const lowerBound: number = item.startTime; | ||||||
|     // Getting minutes of the first event in the day of the chooser
 |     const upperBound: number = lowerBound + item.length; | ||||||
|     const diff = lowerBound.diff(startDateTime, "minutes"); |     if (startDay !== endDay) { | ||||||
| 
 |       if (inviteeBounds.lowerBound < 0) { | ||||||
|     // finding first event
 |         // lowerBound edges into the previous day
 | ||||||
|     phase = diff + eventLength - (diff % eventLength); |         if (item.days.includes(startDay)) { | ||||||
|  |           boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 }); | ||||||
|         } |         } | ||||||
| 
 |         if (item.days.includes(endDay)) { | ||||||
|   // We can stop as soon as the selectedTimeZone day ends
 |           boundaries.push({ lowerBound, upperBound }); | ||||||
|   const endDateTime = upperBound |  | ||||||
|     .tz(calendarTimeZone) |  | ||||||
|     .subtract(eventLength, "minutes"); |  | ||||||
| 
 |  | ||||||
|   const maxMinutes = endDateTime.diff(startDateTime, "minutes"); |  | ||||||
| 
 |  | ||||||
|   const slots = []; |  | ||||||
|   const now = dayjs(); |  | ||||||
|   for ( |  | ||||||
|     let minutes = phase; |  | ||||||
|     minutes <= maxMinutes; |  | ||||||
|     minutes += parseInt(eventLength, 10) |  | ||||||
|   ) { |  | ||||||
|     const slot = startDateTime.add(minutes, "minutes"); |  | ||||||
| 
 |  | ||||||
|     const minutesFromMidnight = getMinutesFromMidnight(slot); |  | ||||||
| 
 |  | ||||||
|     if ( |  | ||||||
|       minutesFromMidnight < dayStartTime || |  | ||||||
|       minutesFromMidnight > dayEndTime - eventLength || |  | ||||||
|       slot < now |  | ||||||
|     ) { |  | ||||||
|       continue; |  | ||||||
|         } |         } | ||||||
| 
 |       } else { | ||||||
|     slots.push(slot.tz(selectedTimeZone)); |         // upperBound edges into the next day
 | ||||||
|  |         if (item.days.includes(endDay)) { | ||||||
|  |           boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 }); | ||||||
|         } |         } | ||||||
|  |         if (item.days.includes(startDay)) { | ||||||
|  |           boundaries.push({ lowerBound, upperBound }); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       boundaries.push({ lowerBound, upperBound }); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |   return boundaries; | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
|  | const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => { | ||||||
|  |   const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency); | ||||||
|  |   const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency); | ||||||
|  |   return { | ||||||
|  |     lowerBound, | ||||||
|  |     upperBound, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => { | ||||||
|  |   const slots: Dayjs[] = []; | ||||||
|  |   for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) { | ||||||
|  |     slots.push( | ||||||
|  |       <Dayjs>dayjs | ||||||
|  |         .utc() | ||||||
|  |         .startOf("day") | ||||||
|  |         .add(lowerBound + minutes, "minutes") | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|   return slots; |   return slots; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default getSlots | const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlotsType): Dayjs[] => { | ||||||
|  |   const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day") | ||||||
|  |     ? inviteeDate.hour() * 60 + inviteeDate.minute() + minimumBookingNotice | ||||||
|  |     : 0; | ||||||
|  | 
 | ||||||
|  |   const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency); | ||||||
|  |   return getOverlaps(inviteeBounds, organizerBoundaries(workingHours, inviteeDate, inviteeBounds)) | ||||||
|  |     .reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], []) | ||||||
|  |     .map((slot) => slot.utcOffset(dayjs(inviteeDate).utcOffset())); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default getSlots; | ||||||
|  |  | ||||||
							
								
								
									
										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", | ||||||
|  | @ -36,6 +37,7 @@ | ||||||
|     "uuid": "^8.3.2" |     "uuid": "^8.3.2" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|  |     "@types/jest": "^26.0.23", | ||||||
|     "@types/node": "^14.14.33", |     "@types/node": "^14.14.33", | ||||||
|     "@types/react": "^17.0.3", |     "@types/react": "^17.0.3", | ||||||
|     "@typescript-eslint/eslint-plugin": "^4.27.0", |     "@typescript-eslint/eslint-plugin": "^4.27.0", | ||||||
|  | @ -47,7 +49,9 @@ | ||||||
|     "eslint-plugin-react": "^7.24.0", |     "eslint-plugin-react": "^7.24.0", | ||||||
|     "eslint-plugin-react-hooks": "^4.2.0", |     "eslint-plugin-react-hooks": "^4.2.0", | ||||||
|     "husky": "^6.0.0", |     "husky": "^6.0.0", | ||||||
|  |     "jest": "^27.0.5", | ||||||
|     "lint-staged": "^11.0.0", |     "lint-staged": "^11.0.0", | ||||||
|  |     "mockdate": "^3.0.5", | ||||||
|     "postcss": "^8.2.8", |     "postcss": "^8.2.8", | ||||||
|     "prettier": "^2.3.1", |     "prettier": "^2.3.1", | ||||||
|     "prisma": "^2.23.0", |     "prisma": "^2.23.0", | ||||||
|  | @ -59,5 +63,19 @@ | ||||||
|       "prettier --write", |       "prettier --write", | ||||||
|       "eslint" |       "eslint" | ||||||
|     ] |     ] | ||||||
|  |   }, | ||||||
|  |   "jest": { | ||||||
|  |     "verbose": true, | ||||||
|  |     "extensionsToTreatAsEsm": [ | ||||||
|  |       ".ts" | ||||||
|  |     ], | ||||||
|  |     "moduleFileExtensions": [ | ||||||
|  |       "js", | ||||||
|  |       "ts" | ||||||
|  |     ], | ||||||
|  |     "moduleNameMapper": { | ||||||
|  |       "^@components(.*)$": "<rootDir>/components$1", | ||||||
|  |       "^@lib(.*)$": "<rootDir>/lib$1" | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,58 +1,64 @@ | ||||||
| import {useEffect, useState, useMemo} from 'react'; | import { useEffect, useState, useMemo } from "react"; | ||||||
| import Head from 'next/head'; | import Head from "next/head"; | ||||||
| import Link from 'next/link'; | import prisma from "../../lib/prisma"; | ||||||
| import prisma from '../../lib/prisma'; | import dayjs, { Dayjs } from "dayjs"; | ||||||
| import dayjs, { Dayjs } from 'dayjs'; | import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid"; | ||||||
| import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'; | import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; | ||||||
| import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; | import utc from "dayjs/plugin/utc"; | ||||||
| import utc from 'dayjs/plugin/utc'; | import timezone from "dayjs/plugin/timezone"; | ||||||
| import timezone from 'dayjs/plugin/timezone'; |  | ||||||
| dayjs.extend(isSameOrBefore); | dayjs.extend(isSameOrBefore); | ||||||
| dayjs.extend(utc); | dayjs.extend(utc); | ||||||
| dayjs.extend(timezone); | dayjs.extend(timezone); | ||||||
| 
 | 
 | ||||||
| import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; | import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; | ||||||
| import AvailableTimes from "../../components/booking/AvailableTimes"; | import AvailableTimes from "../../components/booking/AvailableTimes"; | ||||||
| import TimeOptions from "../../components/booking/TimeOptions" | import TimeOptions from "../../components/booking/TimeOptions"; | ||||||
| import Avatar from '../../components/Avatar'; | import Avatar from "../../components/Avatar"; | ||||||
| import { timeZone } from "../../lib/clock"; | import { timeZone } from "../../lib/clock"; | ||||||
| import DatePicker from "../../components/booking/DatePicker"; | import DatePicker from "../../components/booking/DatePicker"; | ||||||
| import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; | import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; | ||||||
| import { useRouter } from "next/router"; | import { useRouter } from "next/router"; | ||||||
|  | import getSlots from "@lib/slots"; | ||||||
| 
 | 
 | ||||||
| export default function Type(props) { | export default function Type(props) { | ||||||
| 
 |  | ||||||
|   // Get router variables
 |  | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const { rescheduleUid } = router.query; |   const { rescheduleUid } = router.query; | ||||||
| 
 | 
 | ||||||
|   // Initialise state
 |  | ||||||
|   const [selectedDate, setSelectedDate] = useState<Dayjs>(); |   const [selectedDate, setSelectedDate] = useState<Dayjs>(); | ||||||
|   const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); |  | ||||||
|   const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); |   const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); | ||||||
|   const [timeFormat, setTimeFormat] = useState('h:mma'); |   const [timeFormat, setTimeFormat] = useState("h:mma"); | ||||||
|   const telemetry = useTelemetry(); |   const telemetry = useTelemetry(); | ||||||
| 
 | 
 | ||||||
|  |   const today: string = dayjs().utc().format("YYYY-MM-DDTHH:mm"); | ||||||
|  |   const noSlotsToday = useMemo( | ||||||
|  |     () => | ||||||
|  |       getSlots({ | ||||||
|  |         frequency: props.eventType.length, | ||||||
|  |         inviteeDate: dayjs.utc(today) as Dayjs, | ||||||
|  |         workingHours: props.workingHours, | ||||||
|  |         minimumBookingNotice: 0, | ||||||
|  |       }).length === 0, | ||||||
|  |     [today, props.eventType.length, props.workingHours] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())) |     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())); | ||||||
|   }, []); |   }, [telemetry]); | ||||||
| 
 | 
 | ||||||
|   const changeDate = (date: Dayjs) => { |   const changeDate = (date: Dayjs) => { | ||||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())) |     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())); | ||||||
|     setSelectedDate(date); |     setSelectedDate(date); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const handleSelectTimeZone = (selectedTimeZone: string) => { |   const handleSelectTimeZone = (selectedTimeZone: string) => { | ||||||
|     if (selectedDate) { |     if (selectedDate) { | ||||||
|       setSelectedDate(selectedDate.tz(selectedTimeZone)) |       setSelectedDate(selectedDate.tz(selectedTimeZone)); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const handleToggle24hClock = (is24hClock: boolean) => { |   const handleToggle24hClock = (is24hClock: boolean) => { | ||||||
|     if (selectedDate) { |     setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); | ||||||
|       setTimeFormat(is24hClock ? 'HH:mm' : 'h:mma'); |   }; | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div> |     <div> | ||||||
|  | @ -67,40 +73,47 @@ export default function Type(props) { | ||||||
|         className={ |         className={ | ||||||
|           "mx-auto my-24 transition-max-width ease-in-out duration-500 " + |           "mx-auto my-24 transition-max-width ease-in-out duration-500 " + | ||||||
|           (selectedDate ? "max-w-6xl" : "max-w-3xl") |           (selectedDate ? "max-w-6xl" : "max-w-3xl") | ||||||
|         } |         }> | ||||||
|       > |  | ||||||
|         <div className="bg-white shadow rounded-lg"> |         <div className="bg-white shadow rounded-lg"> | ||||||
|           <div className="sm:flex px-4 py-5 sm:p-4"> |           <div className="sm:flex px-4 py-5 sm:p-4"> | ||||||
|             <div |             <div className={"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")}> | ||||||
|               className={ |  | ||||||
|                 "pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2") |  | ||||||
|               } |  | ||||||
|             > |  | ||||||
|               <Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" /> |               <Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" /> | ||||||
|               <h2 className="font-medium text-gray-500">{props.user.name}</h2> |               <h2 className="font-medium text-gray-500">{props.user.name}</h2> | ||||||
|               <h1 className="text-3xl font-semibold text-gray-800 mb-4"> |               <h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1> | ||||||
|                 {props.eventType.title} |  | ||||||
|               </h1> |  | ||||||
|               <p className="text-gray-500 mb-1 px-2 py-1 -ml-2"> |               <p className="text-gray-500 mb-1 px-2 py-1 -ml-2"> | ||||||
|                 <ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> |                 <ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> | ||||||
|                 {props.eventType.length} minutes |                 {props.eventType.length} minutes | ||||||
|               </p> |               </p> | ||||||
|               <button |               <button | ||||||
|                 onClick={() => setIsTimeOptionsOpen(true)} |                 onClick={() => setIsTimeOptionsOpen(true)} | ||||||
|                 className="text-gray-500 mb-1 px-2 py-1 -ml-2" |                 className="text-gray-500 mb-1 px-2 py-1 -ml-2"> | ||||||
|               > |  | ||||||
|                 <GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> |                 <GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> | ||||||
|                 {timeZone()} |                 {timeZone()} | ||||||
|                 <ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" /> |                 <ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" /> | ||||||
|               </button> |               </button> | ||||||
|             { isTimeOptionsOpen && <TimeOptions onSelectTimeZone={handleSelectTimeZone} |               {isTimeOptionsOpen && ( | ||||||
|                                                 onToggle24hClock={handleToggle24hClock} />} |                 <TimeOptions | ||||||
|             <p className="text-gray-600 mt-3 mb-8"> |                   onSelectTimeZone={handleSelectTimeZone} | ||||||
|               {props.eventType.description} |                   onToggle24hClock={handleToggle24hClock} | ||||||
|             </p> |                 /> | ||||||
|  |               )} | ||||||
|  |               <p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p> | ||||||
|             </div> |             </div> | ||||||
|             <DatePicker weekStart={props.user.weekStart} onDatePicked={changeDate} /> |             <DatePicker | ||||||
|             {selectedDate && <AvailableTimes timeFormat={timeFormat} user={props.user} eventType={props.eventType} date={selectedDate} />} |               disableToday={noSlotsToday} | ||||||
|  |               weekStart={props.user.weekStart} | ||||||
|  |               onDatePicked={changeDate} | ||||||
|  |               workingHours={props.workingHours} | ||||||
|  |             /> | ||||||
|  |             {selectedDate && ( | ||||||
|  |               <AvailableTimes | ||||||
|  |                 workingHours={props.workingHours} | ||||||
|  |                 timeFormat={timeFormat} | ||||||
|  |                 eventLength={props.eventType.length} | ||||||
|  |                 eventTypeId={props.eventType.id} | ||||||
|  |                 date={selectedDate} | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         {/* note(peer): |         {/* note(peer): | ||||||
|  | @ -112,6 +125,14 @@ export default function Type(props) { | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | interface WorkingHours { | ||||||
|  |   days: number[]; | ||||||
|  |   startTime: number; | ||||||
|  |   length: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type Availability = WorkingHours; | ||||||
|  | 
 | ||||||
| export async function getServerSideProps(context) { | export async function getServerSideProps(context) { | ||||||
|   const user = await prisma.user.findFirst({ |   const user = await prisma.user.findFirst({ | ||||||
|     where: { |     where: { | ||||||
|  | @ -129,13 +150,14 @@ export async function getServerSideProps(context) { | ||||||
|       timeZone: true, |       timeZone: true, | ||||||
|       endTime: true, |       endTime: true, | ||||||
|       weekStart: true, |       weekStart: true, | ||||||
|     } |       availability: true, | ||||||
|  |     }, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   if (!user) { |   if (!user) { | ||||||
|     return { |     return { | ||||||
|       notFound: true, |       notFound: true, | ||||||
|     } |     }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const eventType = await prisma.eventType.findFirst({ |   const eventType = await prisma.eventType.findFirst({ | ||||||
|  | @ -149,20 +171,38 @@ export async function getServerSideProps(context) { | ||||||
|       id: true, |       id: true, | ||||||
|       title: true, |       title: true, | ||||||
|       description: true, |       description: true, | ||||||
|       length: true |       length: true, | ||||||
|     } |       availability: true, | ||||||
|  |     }, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   if (!eventType) { |   if (!eventType) { | ||||||
|     return { |     return { | ||||||
|       notFound: true, |       notFound: true, | ||||||
|  |     }; | ||||||
|   } |   } | ||||||
|   } | 
 | ||||||
|  |   const getWorkingHours = (providesAvailability) => | ||||||
|  |     providesAvailability.availability && providesAvailability.availability.length | ||||||
|  |       ? providesAvailability.availability | ||||||
|  |       : null; | ||||||
|  | 
 | ||||||
|  |   const workingHours: WorkingHours[] = | ||||||
|  |     getWorkingHours(eventType) || | ||||||
|  |     getWorkingHours(user) || | ||||||
|  |     [ | ||||||
|  |       { | ||||||
|  |         days: [1, 2, 3, 4, 5, 6, 7], | ||||||
|  |         startTime: user.startTime, | ||||||
|  |         length: user.endTime, | ||||||
|  |       }, | ||||||
|  |     ].filter((availability: Availability): boolean => typeof availability["days"] !== "undefined"); | ||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|     props: { |     props: { | ||||||
|       user, |       user, | ||||||
|       eventType, |       eventType, | ||||||
|  |       workingHours, | ||||||
|     }, |     }, | ||||||
|   } |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,48 +1,44 @@ | ||||||
| import Head from 'next/head'; | import Head from "next/head"; | ||||||
| import Link from 'next/link'; | import Link from "next/link"; | ||||||
| import { useRouter } from 'next/router'; | import { useRouter } from "next/router"; | ||||||
| import { useRef, useState, useEffect } from 'react'; | import { useRef, useState } from "react"; | ||||||
| import Select, { OptionBase } from 'react-select'; | import Select, { OptionBase } from "react-select"; | ||||||
| import prisma from '../../../lib/prisma'; | import prisma from "../../../lib/prisma"; | ||||||
| import {LocationType} from '../../../lib/location'; | import { LocationType } from "../../../lib/location"; | ||||||
| import Shell from '../../../components/Shell'; | import Shell from "../../../components/Shell"; | ||||||
| import { useSession, getSession } from 'next-auth/client'; | import { getSession } from "next-auth/client"; | ||||||
| import { Scheduler } from "../../../components/ui/Scheduler"; | import { Scheduler } from "../../../components/ui/Scheduler"; | ||||||
| 
 | 
 | ||||||
| import { | import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline"; | ||||||
|   LocationMarkerIcon, |  | ||||||
|   PlusCircleIcon, |  | ||||||
|   XIcon, |  | ||||||
|   PhoneIcon, |  | ||||||
| } from '@heroicons/react/outline'; |  | ||||||
| import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput"; | import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput"; | ||||||
| import { PlusIcon } from "@heroicons/react/solid"; | import { PlusIcon } from "@heroicons/react/solid"; | ||||||
| 
 | 
 | ||||||
| import dayjs, {Dayjs} from "dayjs"; | import dayjs from "dayjs"; | ||||||
| import utc from 'dayjs/plugin/utc'; | import utc from "dayjs/plugin/utc"; | ||||||
| dayjs.extend(utc); | dayjs.extend(utc); | ||||||
| import timezone from 'dayjs/plugin/timezone'; | import timezone from "dayjs/plugin/timezone"; | ||||||
| dayjs.extend(timezone); | dayjs.extend(timezone); | ||||||
| 
 | 
 | ||||||
| export default function EventType(props) { | export default function EventType(props) { | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
| 
 | 
 | ||||||
|   const inputOptions: OptionBase[] = [ |   const inputOptions: OptionBase[] = [ | ||||||
|       { value: EventTypeCustomInputType.Text, label: 'Text' }, |     { value: EventTypeCustomInputType.Text, label: "Text" }, | ||||||
|       { value: EventTypeCustomInputType.TextLong, label: 'Multiline Text' }, |     { value: EventTypeCustomInputType.TextLong, label: "Multiline Text" }, | ||||||
|       { value: EventTypeCustomInputType.Number, label: 'Number', }, |     { value: EventTypeCustomInputType.Number, label: "Number" }, | ||||||
|       { value: EventTypeCustomInputType.Bool, label: 'Checkbox', }, |     { value: EventTypeCustomInputType.Bool, label: "Checkbox" }, | ||||||
|     ] |   ]; | ||||||
| 
 | 
 | ||||||
|     const [ session, loading ] = useSession(); |  | ||||||
|   const [showLocationModal, setShowLocationModal] = useState(false); |   const [showLocationModal, setShowLocationModal] = useState(false); | ||||||
|   const [showAddCustomModal, setShowAddCustomModal] = useState(false); |   const [showAddCustomModal, setShowAddCustomModal] = useState(false); | ||||||
|   const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined); |   const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined); | ||||||
|   const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]); |   const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]); | ||||||
|   const [locations, setLocations] = useState(props.eventType.locations || []); |   const [locations, setLocations] = useState(props.eventType.locations || []); | ||||||
|   const [schedule, setSchedule] = useState(undefined); |   const [schedule, setSchedule] = useState(undefined); | ||||||
|     const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []); |   const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>( | ||||||
|     const locationOptions = props.locationOptions |     props.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>(); | ||||||
|  | @ -51,10 +47,6 @@ export default function EventType(props) { | ||||||
|   const isHiddenRef = useRef<HTMLInputElement>(); |   const isHiddenRef = useRef<HTMLInputElement>(); | ||||||
|   const eventNameRef = useRef<HTMLInputElement>(); |   const eventNameRef = useRef<HTMLInputElement>(); | ||||||
| 
 | 
 | ||||||
|     if (loading) { |  | ||||||
|         return <p className="text-gray-400">Loading...</p>; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|   async function updateEventTypeHandler(event) { |   async function updateEventTypeHandler(event) { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
| 
 | 
 | ||||||
|  | @ -66,60 +58,70 @@ export default function EventType(props) { | ||||||
|     const enteredEventName = eventNameRef.current.value; |     const enteredEventName = eventNameRef.current.value; | ||||||
|     // TODO: Add validation
 |     // TODO: Add validation
 | ||||||
| 
 | 
 | ||||||
|         const response = await fetch('/api/availability/eventtype', { |     await fetch("/api/availability/eventtype", { | ||||||
|             method: 'PATCH', |       method: "PATCH", | ||||||
|             body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations, eventName: enteredEventName, customInputs }), |       body: JSON.stringify({ | ||||||
|  |         id: props.eventType.id, | ||||||
|  |         title: enteredTitle, | ||||||
|  |         slug: enteredSlug, | ||||||
|  |         description: enteredDescription, | ||||||
|  |         length: enteredLength, | ||||||
|  |         hidden: enteredIsHidden, | ||||||
|  |         locations, | ||||||
|  |         eventName: enteredEventName, | ||||||
|  |         customInputs, | ||||||
|  |       }), | ||||||
|       headers: { |       headers: { | ||||||
|                 'Content-Type': 'application/json' |         "Content-Type": "application/json", | ||||||
|             } |       }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     if (schedule) { |     if (schedule) { | ||||||
| 
 |       const schedulePayload = { overrides: [], timeZone: props.user.timeZone, openingHours: [] }; | ||||||
|           let schedulePayload = { "overrides": [], "timeZone": props.user.timeZone, "openingHours": [] }; |  | ||||||
|       schedule.forEach((item) => { |       schedule.forEach((item) => { | ||||||
|         if (item.isOverride) { |         if (item.isOverride) { | ||||||
|           delete item.isOverride; |           delete item.isOverride; | ||||||
|           schedulePayload.overrides.push(item); |           schedulePayload.overrides.push(item); | ||||||
|         } else { |         } else { | ||||||
|  |           const endTime = item.endDate.hour() * 60 + item.endDate.minute() || 1440; // also handles 00:00
 | ||||||
|           schedulePayload.openingHours.push({ |           schedulePayload.openingHours.push({ | ||||||
|             days: item.days, |             days: item.days, | ||||||
|                 startTime: item.startDate.hour() * 60 + item.startDate.minute(), |             startTime: item.startDate.hour() * 60 + item.startDate.minute() - item.startDate.utcOffset(), | ||||||
|                 endTime: item.endDate.hour() * 60 + item.endDate.minute() |             endTime: endTime - item.endDate.utcOffset(), | ||||||
|           }); |           }); | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|           const response = await fetch('/api/availability/schedule/' + props.eventType.id, { |       await fetch("/api/availability/schedule/" + props.eventType.id, { | ||||||
|             method: 'PUT', |         method: "PUT", | ||||||
|         body: JSON.stringify(schedulePayload), |         body: JSON.stringify(schedulePayload), | ||||||
|         headers: { |         headers: { | ||||||
|               'Content-Type': 'application/json' |           "Content-Type": "application/json", | ||||||
|             } |         }, | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|         router.push('/availability'); |     router.push("/availability"); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async function deleteEventTypeHandler(event) { |   async function deleteEventTypeHandler(event) { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
| 
 | 
 | ||||||
|         const response = await fetch('/api/availability/eventtype', { |     await fetch("/api/availability/eventtype", { | ||||||
|             method: 'DELETE', |       method: "DELETE", | ||||||
|       body: JSON.stringify({ id: props.eventType.id }), |       body: JSON.stringify({ id: props.eventType.id }), | ||||||
|       headers: { |       headers: { | ||||||
|                 'Content-Type': 'application/json' |         "Content-Type": "application/json", | ||||||
|             } |       }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|         router.push('/availability'); |     router.push("/availability"); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const openLocationModal = (type: LocationType) => { |   const openLocationModal = (type: LocationType) => { | ||||||
|     setSelectedLocation(locationOptions.find((option) => option.value === type)); |     setSelectedLocation(locationOptions.find((option) => option.value === type)); | ||||||
|     setShowLocationModal(true); |     setShowLocationModal(true); | ||||||
|     } |   }; | ||||||
| 
 | 
 | ||||||
|   const closeLocationModal = () => { |   const closeLocationModal = () => { | ||||||
|     setSelectedLocation(undefined); |     setSelectedLocation(undefined); | ||||||
|  | @ -136,27 +138,32 @@ export default function EventType(props) { | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
|     switch (selectedLocation.value) { |     switch (selectedLocation.value) { | ||||||
|             case LocationType.InPerson: |       case LocationType.InPerson: { | ||||||
|                 const address = locations.find( |         const address = locations.find((location) => location.type === LocationType.InPerson)?.address; | ||||||
|                     (location) => location.type === LocationType.InPerson |  | ||||||
|                 )?.address; |  | ||||||
|         return ( |         return ( | ||||||
|           <div> |           <div> | ||||||
|                         <label htmlFor="address" className="block text-sm font-medium text-gray-700">Set an address or place</label> |             <label htmlFor="address" className="block text-sm font-medium text-gray-700"> | ||||||
|  |               Set an address or place | ||||||
|  |             </label> | ||||||
|             <div className="mt-1"> |             <div className="mt-1"> | ||||||
|                             <input type="text" name="address" id="address" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" defaultValue={address} /> |               <input | ||||||
|  |                 type="text" | ||||||
|  |                 name="address" | ||||||
|  |                 id="address" | ||||||
|  |                 required | ||||||
|  |                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|  |                 defaultValue={address} | ||||||
|  |               /> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|                 ) |         ); | ||||||
|  |       } | ||||||
|       case LocationType.Phone: |       case LocationType.Phone: | ||||||
| 
 |  | ||||||
|         return ( |         return ( | ||||||
|           <p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p> |           <p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p> | ||||||
|                 ) |         ); | ||||||
|       case LocationType.GoogleMeet: |       case LocationType.GoogleMeet: | ||||||
|                  return ( |         return <p className="text-sm">Calendso will provide a Google Meet location.</p>; | ||||||
|                     <p className="text-sm">Calendso will provide a Google Meet location.</p> |  | ||||||
|                 ) |  | ||||||
|     } |     } | ||||||
|     return null; |     return null; | ||||||
|   }; |   }; | ||||||
|  | @ -171,7 +178,7 @@ export default function EventType(props) { | ||||||
| 
 | 
 | ||||||
|     const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type); |     const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type); | ||||||
|     if (existingIdx !== -1) { |     if (existingIdx !== -1) { | ||||||
|             let copy = locations; |       const copy = locations; | ||||||
|       copy[existingIdx] = { ...locations[existingIdx], ...details }; |       copy[existingIdx] = { ...locations[existingIdx], ...details }; | ||||||
|       setLocations(copy); |       setLocations(copy); | ||||||
|     } else { |     } else { | ||||||
|  | @ -191,7 +198,7 @@ export default function EventType(props) { | ||||||
|     const customInput: EventTypeCustomInput = { |     const customInput: EventTypeCustomInput = { | ||||||
|       label: e.target.label.value, |       label: e.target.label.value, | ||||||
|       required: e.target.required.checked, |       required: e.target.required.checked, | ||||||
|         type: e.target.type.value |       type: e.target.type.value, | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     setCustomInputs(customInputs.concat(customInput)); |     setCustomInputs(customInputs.concat(customInput)); | ||||||
|  | @ -205,20 +212,33 @@ export default function EventType(props) { | ||||||
|         <title>{props.eventType.title} | Event Type | Calendso</title> |         <title>{props.eventType.title} | Event Type | Calendso</title> | ||||||
|         <link rel="icon" href="/favicon.ico" /> |         <link rel="icon" href="/favicon.ico" /> | ||||||
|       </Head> |       </Head> | ||||||
|         <Shell heading={'Event Type - ' + props.eventType.title}> |       <Shell heading={"Event Type - " + props.eventType.title}> | ||||||
|         <div className="grid grid-cols-3 gap-4"> |         <div className="grid grid-cols-3 gap-4"> | ||||||
|           <div className="col-span-3 sm:col-span-2"> |           <div className="col-span-3 sm:col-span-2"> | ||||||
|             <div className="bg-white overflow-hidden shadow rounded-lg mb-4"> |             <div className="bg-white overflow-hidden shadow rounded-lg mb-4"> | ||||||
|               <div className="px-4 py-5 sm:p-6"> |               <div className="px-4 py-5 sm:p-6"> | ||||||
|                 <form onSubmit={updateEventTypeHandler}> |                 <form onSubmit={updateEventTypeHandler}> | ||||||
|                   <div className="mb-4"> |                   <div className="mb-4"> | ||||||
|                       <label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label> |                     <label htmlFor="title" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       Title | ||||||
|  |                     </label> | ||||||
|                     <div className="mt-1"> |                     <div className="mt-1"> | ||||||
|                         <input ref={titleRef} type="text" name="title" id="title" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Quick Chat" defaultValue={props.eventType.title} /> |                       <input | ||||||
|  |                         ref={titleRef} | ||||||
|  |                         type="text" | ||||||
|  |                         name="title" | ||||||
|  |                         id="title" | ||||||
|  |                         required | ||||||
|  |                         className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|  |                         placeholder="Quick Chat" | ||||||
|  |                         defaultValue={props.eventType.title} | ||||||
|  |                       /> | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mb-4"> |                   <div className="mb-4"> | ||||||
|                       <label htmlFor="slug" className="block text-sm font-medium text-gray-700">URL</label> |                     <label htmlFor="slug" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       URL | ||||||
|  |                     </label> | ||||||
|                     <div className="mt-1"> |                     <div className="mt-1"> | ||||||
|                       <div className="flex rounded-md shadow-sm"> |                       <div className="flex rounded-md shadow-sm"> | ||||||
|                         <span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm"> |                         <span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm"> | ||||||
|  | @ -237,8 +257,11 @@ export default function EventType(props) { | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mb-4"> |                   <div className="mb-4"> | ||||||
|                       <label htmlFor="location" className="block text-sm font-medium text-gray-700">Location</label> |                     <label htmlFor="location" className="block text-sm font-medium text-gray-700"> | ||||||
|                       {locations.length === 0 && <div className="mt-1 mb-2"> |                       Location | ||||||
|  |                     </label> | ||||||
|  |                     {locations.length === 0 && ( | ||||||
|  |                       <div className="mt-1 mb-2"> | ||||||
|                         <div className="flex rounded-md shadow-sm"> |                         <div className="flex rounded-md shadow-sm"> | ||||||
|                           <Select |                           <Select | ||||||
|                             name="location" |                             name="location" | ||||||
|  | @ -249,8 +272,10 @@ export default function EventType(props) { | ||||||
|                             onChange={(e) => openLocationModal(e.value)} |                             onChange={(e) => openLocationModal(e.value)} | ||||||
|                           /> |                           /> | ||||||
|                         </div> |                         </div> | ||||||
|                       </div>} |                       </div> | ||||||
|                       {locations.length > 0 && <ul className="w-96 mt-1"> |                     )} | ||||||
|  |                     {locations.length > 0 && ( | ||||||
|  |                       <ul className="w-96 mt-1"> | ||||||
|                         {locations.map((location) => ( |                         {locations.map((location) => ( | ||||||
|                           <li key={location.type} className="bg-blue-50 mb-2 p-2 border"> |                           <li key={location.type} className="bg-blue-50 mb-2 p-2 border"> | ||||||
|                             <div className="flex justify-between"> |                             <div className="flex justify-between"> | ||||||
|  | @ -268,12 +293,29 @@ export default function EventType(props) { | ||||||
|                               )} |                               )} | ||||||
|                               {location.type === LocationType.GoogleMeet && ( |                               {location.type === LocationType.GoogleMeet && ( | ||||||
|                                 <div className="flex-grow flex"> |                                 <div className="flex-grow flex"> | ||||||
|                                   <svg className="h-6 w-6" stroke="currentColor" fill="currentColor" stroke-width="0" role="img" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><title></title><path d="M12 0C6.28 0 1.636 4.641 1.636 10.364c0 5.421 4.945 9.817 10.364 9.817V24c6.295-3.194 10.364-8.333 10.364-13.636C22.364 4.64 17.72 0 12 0zM7.5 6.272h6.817a1.363 1.363 0 0 1 1.365 1.365v1.704l2.728-2.727v7.501l-2.726-2.726v1.703a1.362 1.362 0 0 1-1.365 1.365H7.5c-.35 0-.698-.133-.965-.4a1.358 1.358 0 0 1-.4-.965V7.637A1.362 1.362 0 0 1 7.5 6.272Z"></path></svg> |                                   <svg | ||||||
|  |                                     className="h-6 w-6" | ||||||
|  |                                     stroke="currentColor" | ||||||
|  |                                     fill="currentColor" | ||||||
|  |                                     strokeWidth="0" | ||||||
|  |                                     role="img" | ||||||
|  |                                     viewBox="0 0 24 24" | ||||||
|  |                                     height="1em" | ||||||
|  |                                     width="1em" | ||||||
|  |                                     xmlns="http://www.w3.org/2000/svg"> | ||||||
|  |                                     <title></title> | ||||||
|  |                                     <path d="M12 0C6.28 0 1.636 4.641 1.636 10.364c0 5.421 4.945 9.817 10.364 9.817V24c6.295-3.194 10.364-8.333 10.364-13.636C22.364 4.64 17.72 0 12 0zM7.5 6.272h6.817a1.363 1.363 0 0 1 1.365 1.365v1.704l2.728-2.727v7.501l-2.726-2.726v1.703a1.362 1.362 0 0 1-1.365 1.365H7.5c-.35 0-.698-.133-.965-.4a1.358 1.358 0 0 1-.4-.965V7.637A1.362 1.362 0 0 1 7.5 6.272Z"></path> | ||||||
|  |                                   </svg> | ||||||
|                                   <span className="ml-2 text-sm">Google Meet</span> |                                   <span className="ml-2 text-sm">Google Meet</span> | ||||||
|                                 </div> |                                 </div> | ||||||
|                               )} |                               )} | ||||||
|                               <div className="flex"> |                               <div className="flex"> | ||||||
|                                 <button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button> |                                 <button | ||||||
|  |                                   type="button" | ||||||
|  |                                   onClick={() => openLocationModal(location.type)} | ||||||
|  |                                   className="mr-2 text-sm text-blue-600"> | ||||||
|  |                                   Edit | ||||||
|  |                                 </button> | ||||||
|                                 <button onClick={() => removeLocation(location)}> |                                 <button onClick={() => removeLocation(location)}> | ||||||
|                                   <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " /> |                                   <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " /> | ||||||
|                                 </button> |                                 </button> | ||||||
|  | @ -281,37 +323,74 @@ export default function EventType(props) { | ||||||
|                             </div> |                             </div> | ||||||
|                           </li> |                           </li> | ||||||
|                         ))} |                         ))} | ||||||
|                         {locations.length > 0 && locations.length !== locationOptions.length && <li> |                         {locations.length > 0 && locations.length !== locationOptions.length && ( | ||||||
|                           <button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowLocationModal(true)}> |                           <li> | ||||||
|  |                             <button | ||||||
|  |                               type="button" | ||||||
|  |                               className="sm:flex sm:items-start text-sm text-blue-600" | ||||||
|  |                               onClick={() => setShowLocationModal(true)}> | ||||||
|                               <PlusCircleIcon className="h-6 w-6" /> |                               <PlusCircleIcon className="h-6 w-6" /> | ||||||
|                               <span className="ml-1">Add another location option</span> |                               <span className="ml-1">Add another location option</span> | ||||||
|                             </button> |                             </button> | ||||||
|                         </li>} |                           </li> | ||||||
|                       </ul>} |                         )} | ||||||
|  |                       </ul> | ||||||
|  |                     )} | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mb-4"> |                   <div className="mb-4"> | ||||||
|                       <label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label> |                     <label htmlFor="description" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       Description | ||||||
|  |                     </label> | ||||||
|                     <div className="mt-1"> |                     <div className="mt-1"> | ||||||
|                         <textarea ref={descriptionRef} name="description" id="description" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="A quick video meeting." defaultValue={props.eventType.description}></textarea> |                       <textarea | ||||||
|  |                         ref={descriptionRef} | ||||||
|  |                         name="description" | ||||||
|  |                         id="description" | ||||||
|  |                         className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|  |                         placeholder="A quick video meeting." | ||||||
|  |                         defaultValue={props.eventType.description}></textarea> | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mb-4"> |                   <div className="mb-4"> | ||||||
|                       <label htmlFor="length" className="block text-sm font-medium text-gray-700">Length</label> |                     <label htmlFor="length" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       Length | ||||||
|  |                     </label> | ||||||
|                     <div className="mt-1 relative rounded-md shadow-sm"> |                     <div className="mt-1 relative rounded-md shadow-sm"> | ||||||
|                         <input ref={lengthRef} type="number" name="length" id="length" required className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md" placeholder="15" defaultValue={props.eventType.length} /> |                       <input | ||||||
|  |                         ref={lengthRef} | ||||||
|  |                         type="number" | ||||||
|  |                         name="length" | ||||||
|  |                         id="length" | ||||||
|  |                         required | ||||||
|  |                         className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md" | ||||||
|  |                         placeholder="15" | ||||||
|  |                         defaultValue={props.eventType.length} | ||||||
|  |                       /> | ||||||
|                       <div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm"> |                       <div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm"> | ||||||
|                         minutes |                         minutes | ||||||
|                       </div> |                       </div> | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mb-4"> |                   <div className="mb-4"> | ||||||
|                       <label htmlFor="eventName" className="block text-sm font-medium text-gray-700">Calendar entry name</label> |                     <label htmlFor="eventName" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       Calendar entry name | ||||||
|  |                     </label> | ||||||
|                     <div className="mt-1 relative rounded-md shadow-sm"> |                     <div className="mt-1 relative rounded-md shadow-sm"> | ||||||
|                         <input ref={eventNameRef} type="text" name="title" id="title" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Meeting with {USER}" defaultValue={props.eventType.eventName} /> |                       <input | ||||||
|  |                         ref={eventNameRef} | ||||||
|  |                         type="text" | ||||||
|  |                         name="title" | ||||||
|  |                         id="title" | ||||||
|  |                         className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|  |                         placeholder="Meeting with {USER}" | ||||||
|  |                         defaultValue={props.eventType.eventName} | ||||||
|  |                       /> | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mb-4"> |                   <div className="mb-4"> | ||||||
|                       <label htmlFor="additionalFields" className="block text-sm font-medium text-gray-700">Additional Inputs</label> |                     <label htmlFor="additionalFields" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       Additional Inputs | ||||||
|  |                     </label> | ||||||
|                     <ul className="w-96 mt-1"> |                     <ul className="w-96 mt-1"> | ||||||
|                       {customInputs.map((customInput) => ( |                       {customInputs.map((customInput) => ( | ||||||
|                         <li key={customInput.type} className="bg-blue-50 mb-2 p-2 border"> |                         <li key={customInput.type} className="bg-blue-50 mb-2 p-2 border"> | ||||||
|  | @ -324,16 +403,16 @@ export default function EventType(props) { | ||||||
|                                 <span className="ml-2 text-sm">Type: {customInput.type}</span> |                                 <span className="ml-2 text-sm">Type: {customInput.type}</span> | ||||||
|                               </div> |                               </div> | ||||||
|                               <div> |                               <div> | ||||||
|                                   <span |                                 <span className="ml-2 text-sm"> | ||||||
|                                     className="ml-2 text-sm">{customInput.required ? "Required" : "Optional"}</span> |                                   {customInput.required ? "Required" : "Optional"} | ||||||
|  |                                 </span> | ||||||
|                               </div> |                               </div> | ||||||
|                             </div> |                             </div> | ||||||
|                             <div className="flex"> |                             <div className="flex"> | ||||||
|                                 <button type="button" onClick={() => { |                               <button type="button" className="mr-2 text-sm text-blue-600"> | ||||||
|                                 }} className="mr-2 text-sm text-blue-600">Edit |                                 Edit | ||||||
|                               </button> |                               </button> | ||||||
|                                 <button onClick={() => { |                               <button> | ||||||
|                                 }}> |  | ||||||
|                                 <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " /> |                                 <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " /> | ||||||
|                               </button> |                               </button> | ||||||
|                             </div> |                             </div> | ||||||
|  | @ -341,7 +420,10 @@ export default function EventType(props) { | ||||||
|                         </li> |                         </li> | ||||||
|                       ))} |                       ))} | ||||||
|                       <li> |                       <li> | ||||||
|                             <button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowAddCustomModal(true)}> |                         <button | ||||||
|  |                           type="button" | ||||||
|  |                           className="sm:flex sm:items-start text-sm text-blue-600" | ||||||
|  |                           onClick={() => setShowAddCustomModal(true)}> | ||||||
|                           <PlusCircleIcon className="h-6 w-6" /> |                           <PlusCircleIcon className="h-6 w-6" /> | ||||||
|                           <span className="ml-1">Add another input</span> |                           <span className="ml-1">Add another input</span> | ||||||
|                         </button> |                         </button> | ||||||
|  | @ -364,17 +446,27 @@ export default function EventType(props) { | ||||||
|                         <label htmlFor="ishidden" className="font-medium text-gray-700"> |                         <label htmlFor="ishidden" className="font-medium text-gray-700"> | ||||||
|                           Hide this event type |                           Hide this event type | ||||||
|                         </label> |                         </label> | ||||||
|                           <p className="text-gray-500">Hide the event type from your page, so it can only be booked through it's URL.</p> |                         <p className="text-gray-500"> | ||||||
|  |                           Hide the event type from your page, so it can only be booked through it's URL. | ||||||
|  |                         </p> | ||||||
|                       </div> |                       </div> | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                   <hr className="my-4" /> |                   <hr className="my-4" /> | ||||||
|                   <div> |                   <div> | ||||||
|                     <h3 className="mb-2">How do you want to offer your availability for this event type?</h3> |                     <h3 className="mb-2">How do you want to offer your availability for this event type?</h3> | ||||||
|                       <Scheduler onChange={setSchedule} timeZone={props.user.timeZone} schedules={props.schedules} /> |                     <Scheduler | ||||||
|  |                       onChange={setSchedule} | ||||||
|  |                       timeZone={props.user.timeZone} | ||||||
|  |                       schedules={props.schedules} | ||||||
|  |                     /> | ||||||
|                     <div className="py-4 flex justify-end"> |                     <div className="py-4 flex justify-end"> | ||||||
|                         <Link href="/availability"><a className="mr-2 btn btn-white">Cancel</a></Link> |                       <Link href="/availability"> | ||||||
|                         <button type="submit" className="btn btn-primary">Update</button> |                         <a className="mr-2 btn btn-white">Cancel</a> | ||||||
|  |                       </Link> | ||||||
|  |                       <button type="submit" className="btn btn-primary"> | ||||||
|  |                         Update | ||||||
|  |                       </button> | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                 </form> |                 </form> | ||||||
|  | @ -384,16 +476,15 @@ export default function EventType(props) { | ||||||
|           <div> |           <div> | ||||||
|             <div className="bg-white shadow sm:rounded-lg"> |             <div className="bg-white shadow sm:rounded-lg"> | ||||||
|               <div className="px-4 py-5 sm:p-6"> |               <div className="px-4 py-5 sm:p-6"> | ||||||
|                   <h3 className="text-lg mb-2 leading-6 font-medium text-gray-900"> |                 <h3 className="text-lg mb-2 leading-6 font-medium text-gray-900">Delete this event type</h3> | ||||||
|                     Delete this event type |  | ||||||
|                   </h3> |  | ||||||
|                 <div className="mb-4 max-w-xl text-sm text-gray-500"> |                 <div className="mb-4 max-w-xl text-sm text-gray-500"> | ||||||
|                     <p> |                   <p>Once you delete this event type, it will be permanently removed.</p> | ||||||
|                       Once you delete this event type, it will be permanently removed. |  | ||||||
|                     </p> |  | ||||||
|                 </div> |                 </div> | ||||||
|                 <div> |                 <div> | ||||||
|                     <button onClick={deleteEventTypeHandler} type="button" className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"> |                   <button | ||||||
|  |                     onClick={deleteEventTypeHandler} | ||||||
|  |                     type="button" | ||||||
|  |                     className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"> | ||||||
|                     Delete event type |                     Delete event type | ||||||
|                   </button> |                   </button> | ||||||
|                 </div> |                 </div> | ||||||
|  | @ -401,12 +492,20 @@ export default function EventType(props) { | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|           {showLocationModal && |         {showLocationModal && ( | ||||||
|           <div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> |           <div | ||||||
|  |             className="fixed z-10 inset-0 overflow-y-auto" | ||||||
|  |             aria-labelledby="modal-title" | ||||||
|  |             role="dialog" | ||||||
|  |             aria-modal="true"> | ||||||
|             <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> |             <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> | ||||||
|               <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> |               <div | ||||||
|  |                 className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" | ||||||
|  |                 aria-hidden="true"></div> | ||||||
| 
 | 
 | ||||||
|               <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span> |               <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 shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> |               <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> | ||||||
|                 <div className="sm:flex sm:items-start mb-4"> |                 <div className="sm:flex sm:items-start mb-4"> | ||||||
|  | @ -414,7 +513,9 @@ export default function EventType(props) { | ||||||
|                     <LocationMarkerIcon className="h-6 w-6 text-blue-600" /> |                     <LocationMarkerIcon className="h-6 w-6 text-blue-600" /> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> |                   <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | ||||||
|                     <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit location</h3> |                     <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> | ||||||
|  |                       Edit location | ||||||
|  |                     </h3> | ||||||
|                   </div> |                   </div> | ||||||
|                 </div> |                 </div> | ||||||
|                 <form onSubmit={updateLocations}> |                 <form onSubmit={updateLocations}> | ||||||
|  | @ -439,13 +540,22 @@ export default function EventType(props) { | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           } |         )} | ||||||
|           {showAddCustomModal && |         {showAddCustomModal && ( | ||||||
|           <div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> |           <div | ||||||
|  |             className="fixed z-10 inset-0 overflow-y-auto" | ||||||
|  |             aria-labelledby="modal-title" | ||||||
|  |             role="dialog" | ||||||
|  |             aria-modal="true"> | ||||||
|             <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> |             <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> | ||||||
|                   <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"/> |               <div | ||||||
|  |                 className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" | ||||||
|  |                 aria-hidden="true" | ||||||
|  |               /> | ||||||
| 
 | 
 | ||||||
|                   <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span> |               <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 shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> |               <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> | ||||||
|                 <div className="sm:flex sm:items-start mb-4"> |                 <div className="sm:flex sm:items-start mb-4"> | ||||||
|  | @ -453,7 +563,9 @@ export default function EventType(props) { | ||||||
|                     <PlusIcon className="h-6 w-6 text-blue-600" /> |                     <PlusIcon className="h-6 w-6 text-blue-600" /> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> |                   <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | ||||||
|                               <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Add new custom input field</h3> |                     <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> | ||||||
|  |                       Add new custom input field | ||||||
|  |                     </h3> | ||||||
|                     <div> |                     <div> | ||||||
|                       <p className="text-sm text-gray-400"> |                       <p className="text-sm text-gray-400"> | ||||||
|                         This input will be shown when booking this event |                         This input will be shown when booking this event | ||||||
|  | @ -463,7 +575,9 @@ export default function EventType(props) { | ||||||
|                 </div> |                 </div> | ||||||
|                 <form onSubmit={updateCustom}> |                 <form onSubmit={updateCustom}> | ||||||
|                   <div className="mb-2"> |                   <div className="mb-2"> | ||||||
|                             <label htmlFor="type" className="block text-sm font-medium text-gray-700">Input type</label> |                     <label htmlFor="type" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       Input type | ||||||
|  |                     </label> | ||||||
|                     <Select |                     <Select | ||||||
|                       name="type" |                       name="type" | ||||||
|                       defaultValue={selectedInputOption} |                       defaultValue={selectedInputOption} | ||||||
|  | @ -475,13 +589,27 @@ export default function EventType(props) { | ||||||
|                     /> |                     /> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="mb-2"> |                   <div className="mb-2"> | ||||||
|                               <label htmlFor="label" className="block text-sm font-medium text-gray-700">Label</label> |                     <label htmlFor="label" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       Label | ||||||
|  |                     </label> | ||||||
|                     <div className="mt-1"> |                     <div className="mt-1"> | ||||||
|                                   <input type="text" name="label" id="label" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" /> |                       <input | ||||||
|  |                         type="text" | ||||||
|  |                         name="label" | ||||||
|  |                         id="label" | ||||||
|  |                         required | ||||||
|  |                         className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||||
|  |                       /> | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                   <div className="flex items-center h-5"> |                   <div className="flex items-center h-5"> | ||||||
|                               <input id="required" name="required" type="checkbox" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2" defaultChecked={true}/> |                     <input | ||||||
|  |                       id="required" | ||||||
|  |                       name="required" | ||||||
|  |                       type="checkbox" | ||||||
|  |                       className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2" | ||||||
|  |                       defaultChecked={true} | ||||||
|  |                     /> | ||||||
|                     <label htmlFor="required" className="block text-sm font-medium text-gray-700"> |                     <label htmlFor="required" className="block text-sm font-medium text-gray-700"> | ||||||
|                       Is required |                       Is required | ||||||
|                     </label> |                     </label> | ||||||
|  | @ -499,7 +627,7 @@ export default function EventType(props) { | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           } |         )} | ||||||
|       </Shell> |       </Shell> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
|  | @ -511,15 +639,16 @@ const validJson = (jsonString: string) => { | ||||||
|     if (o && typeof o === "object") { |     if (o && typeof o === "object") { | ||||||
|       return o; |       return o; | ||||||
|     } |     } | ||||||
|  |   } catch (e) { | ||||||
|  |     // no longer empty
 | ||||||
|   } |   } | ||||||
|   catch (e) {} |  | ||||||
|   return false; |   return false; | ||||||
| } | }; | ||||||
| 
 | 
 | ||||||
| export async function getServerSideProps(context) { | export async function getServerSideProps(context) { | ||||||
|   const session = await getSession(context); |   const session = await getSession(context); | ||||||
|   if (!session) { |   if (!session) { | ||||||
|       return { redirect: { permanent: false, destination: '/auth/login' } }; |     return { redirect: { permanent: false, destination: "/auth/login" } }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const user = await prisma.user.findFirst({ |   const user = await prisma.user.findFirst({ | ||||||
|  | @ -532,7 +661,7 @@ export async function getServerSideProps(context) { | ||||||
|       startTime: true, |       startTime: true, | ||||||
|       endTime: true, |       endTime: true, | ||||||
|       availability: true, |       availability: true, | ||||||
|     } |     }, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const eventType = await prisma.eventType.findUnique({ |   const eventType = await prisma.eventType.findUnique({ | ||||||
|  | @ -549,8 +678,8 @@ export async function getServerSideProps(context) { | ||||||
|       locations: true, |       locations: true, | ||||||
|       eventName: true, |       eventName: true, | ||||||
|       availability: true, |       availability: true, | ||||||
|       customInputs: true |       customInputs: true, | ||||||
|     } |     }, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const credentials = await prisma.credential.findMany({ |   const credentials = await prisma.credential.findMany({ | ||||||
|  | @ -560,75 +689,68 @@ export async function getServerSideProps(context) { | ||||||
|     select: { |     select: { | ||||||
|       id: true, |       id: true, | ||||||
|       type: true, |       type: true, | ||||||
|             key: true |       key: true, | ||||||
|         } |     }, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|     const integrations = [ { |   const integrations = [ | ||||||
|  |     { | ||||||
|       installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), |       installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), | ||||||
|       enabled: credentials.find((integration) => integration.type === "google_calendar") != null, |       enabled: credentials.find((integration) => integration.type === "google_calendar") != null, | ||||||
|       type: "google_calendar", |       type: "google_calendar", | ||||||
|       title: "Google Calendar", |       title: "Google Calendar", | ||||||
|       imageSrc: "integrations/google-calendar.png", |       imageSrc: "integrations/google-calendar.png", | ||||||
|       description: "For personal and business accounts", |       description: "For personal and business accounts", | ||||||
|     }, { |     }, | ||||||
|  |     { | ||||||
|       installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), |       installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), | ||||||
|       type: "office365_calendar", |       type: "office365_calendar", | ||||||
|       enabled: credentials.find((integration) => integration.type === "office365_calendar") != null, |       enabled: credentials.find((integration) => integration.type === "office365_calendar") != null, | ||||||
|       title: "Office 365 / Outlook.com Calendar", |       title: "Office 365 / Outlook.com Calendar", | ||||||
|       imageSrc: "integrations/office-365.png", |       imageSrc: "integrations/office-365.png", | ||||||
|       description: "For personal and business accounts", |       description: "For personal and business accounts", | ||||||
|     } ]; |     }, | ||||||
| 
 |  | ||||||
|     let locationOptions: OptionBase[] = [ |  | ||||||
|         { value: LocationType.InPerson, label: 'In-person meeting' }, |  | ||||||
|         { value: LocationType.Phone, label: 'Phone call', }, |  | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|       const hasGoogleCalendarIntegration = integrations.find((i) => i.type === "google_calendar" && i.installed === true && i.enabled) |   const locationOptions: OptionBase[] = [ | ||||||
|  |     { value: LocationType.InPerson, label: "In-person meeting" }, | ||||||
|  |     { value: LocationType.Phone, label: "Phone call" }, | ||||||
|  |   ]; | ||||||
|  | 
 | ||||||
|  |   const hasGoogleCalendarIntegration = integrations.find( | ||||||
|  |     (i) => i.type === "google_calendar" && i.installed === true && i.enabled | ||||||
|  |   ); | ||||||
|   if (hasGoogleCalendarIntegration) { |   if (hasGoogleCalendarIntegration) { | ||||||
|         locationOptions.push( { value: LocationType.GoogleMeet, label: 'Google Meet' }) |     locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|       const hasOfficeIntegration = integrations.find((i) => i.type === "office365_calendar" && i.installed === true && i.enabled) |   const hasOfficeIntegration = integrations.find( | ||||||
|  |     (i) => i.type === "office365_calendar" && i.installed === true && i.enabled | ||||||
|  |   ); | ||||||
|   if (hasOfficeIntegration) { |   if (hasOfficeIntegration) { | ||||||
|     // TODO: Add default meeting option of the office integration.
 |     // TODO: Add default meeting option of the office integration.
 | ||||||
|     // Assuming it's Microsoft Teams.
 |     // Assuming it's Microsoft Teams.
 | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|     const eventType = await prisma.eventType.findUnique({ |  | ||||||
|         where: { |  | ||||||
|           id: parseInt(context.query.type), |  | ||||||
|         }, |  | ||||||
|         select: { |  | ||||||
|             id: true, |  | ||||||
|             title: true, |  | ||||||
|             slug: true, |  | ||||||
|             description: true, |  | ||||||
|             length: true, |  | ||||||
|             hidden: true, |  | ||||||
|             locations: true, |  | ||||||
|             eventName: true, |  | ||||||
|             customInputs: true, |  | ||||||
|             availability: true, |  | ||||||
|         } |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|   if (!eventType) { |   if (!eventType) { | ||||||
|     return { |     return { | ||||||
|       notFound: true, |       notFound: true, | ||||||
|     } |     }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const getAvailability = (providesAvailability) => ( |   const getAvailability = (providesAvailability) => | ||||||
|     providesAvailability.availability && providesAvailability.availability.length |     providesAvailability.availability && providesAvailability.availability.length | ||||||
|   ) ? providesAvailability.availability : null; |       ? providesAvailability.availability | ||||||
|  |       : null; | ||||||
| 
 | 
 | ||||||
|   const schedules = getAvailability(eventType) || getAvailability(user) || [ { |   const schedules = getAvailability(eventType) || | ||||||
|  |     getAvailability(user) || [ | ||||||
|  |       { | ||||||
|         days: [1, 2, 3, 4, 5, 6, 7], |         days: [1, 2, 3, 4, 5, 6, 7], | ||||||
|         startTime: user.startTime, |         startTime: user.startTime, | ||||||
|         length: user.endTime >= 1440 ? 1439 : user.endTime, |         length: user.endTime >= 1440 ? 1439 : user.endTime, | ||||||
|   } ]; |       }, | ||||||
|  |     ]; | ||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|     props: { |     props: { | ||||||
|  | @ -637,5 +759,5 @@ export async function getServerSideProps(context) { | ||||||
|       schedules, |       schedules, | ||||||
|       locationOptions, |       locationOptions, | ||||||
|     }, |     }, | ||||||
|   } |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										39
									
								
								test/lib/slots.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								test/lib/slots.test.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,39 @@ | ||||||
|  | import getSlots from '@lib/slots'; | ||||||
|  | import {it, expect} from '@jest/globals'; | ||||||
|  | import MockDate from 'mockdate'; | ||||||
|  | import dayjs, {Dayjs} from 'dayjs'; | ||||||
|  | import utc from 'dayjs/plugin/utc'; | ||||||
|  | import timezone from 'dayjs/plugin/timezone'; | ||||||
|  | dayjs.extend(utc); | ||||||
|  | dayjs.extend(timezone); | ||||||
|  | 
 | ||||||
|  | MockDate.set('2021-06-20T12:00:00Z'); | ||||||
|  | 
 | ||||||
|  | it('can fit 24 hourly slots for an empty day', async () => { | ||||||
|  |   // 24h in a day.
 | ||||||
|  |   expect(getSlots({ | ||||||
|  |     inviteeDate: dayjs().add(1, 'day'), | ||||||
|  |     length: 60, | ||||||
|  |   })).toHaveLength(24); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | it('has slots that be in the same timezone as the invitee', async() => { | ||||||
|  |   expect(getSlots({ | ||||||
|  |     inviteeDate: dayjs().add(1, 'day'), | ||||||
|  |     length: 60 | ||||||
|  |   })[0].utcOffset()).toBe(-0); | ||||||
|  | 
 | ||||||
|  |   expect(getSlots({ | ||||||
|  |     inviteeDate: dayjs().tz('Europe/London').add(1, 'day'), | ||||||
|  |     length: 60 | ||||||
|  |   })[0].utcOffset()).toBe(dayjs().tz('Europe/London').utcOffset()); | ||||||
|  | }) | ||||||
|  | 
 | ||||||
|  | it('excludes slots that have already passed when invitee day equals today', async () => { | ||||||
|  |   expect(getSlots({ inviteeDate: dayjs(), length: 60 })).toHaveLength(12); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | it('supports having slots in different utc offset than the invitee', async () => { | ||||||
|  |   expect(getSlots({ inviteeDate: dayjs(), length: 60 })).toHaveLength(12); | ||||||
|  |   expect(getSlots({ inviteeDate: dayjs().tz('Europe/Brussels'), length: 60 })).toHaveLength(14); | ||||||
|  | }); | ||||||
|  | @ -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
	
	 Alex van Andel
						Alex van Andel