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": { | ||||
|     "prettier/prettier": ["error"], | ||||
|     "@typescript-eslint/no-unused-vars": "error", | ||||
|     "react/react-in-jsx-scope": "off" | ||||
|     "react/react-in-jsx-scope": "off", | ||||
|     "react/prop-types": "off" | ||||
|   }, | ||||
|   "env": { | ||||
|     "browser": true, | ||||
|     "node": true, | ||||
|     "es6": true | ||||
|     "es6": true, | ||||
|     "jest": true | ||||
|   }, | ||||
|   "settings": { | ||||
|     "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 {timeZone} from "../../lib/clock"; | ||||
| import {useRouter} from "next/router"; | ||||
| 
 | ||||
| const AvailableTimes = (props) => { | ||||
| import { useRouter } from "next/router"; | ||||
| import Slots from "./Slots"; | ||||
| 
 | ||||
| const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat }) => { | ||||
|   const router = useRouter(); | ||||
|   const { user, rescheduleUid } = router.query; | ||||
|   const [loaded, setLoaded] = useState(false); | ||||
| 
 | ||||
|   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]); | ||||
| 
 | ||||
|   const { slots } = Slots({ date, eventLength, workingHours }); | ||||
|   return ( | ||||
|     <div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3  md:max-h-97 overflow-y-auto"> | ||||
|       <div className="text-gray-600 font-light text-xl mb-4 text-left"> | ||||
|         <span className="w-1/2"> | ||||
|           {props.date.format("dddd DD MMMM YYYY")} | ||||
|         </span> | ||||
|         <span className="w-1/2">{date.format("dddd DD MMMM YYYY")}</span> | ||||
|       </div> | ||||
|       { | ||||
|         loaded ? times.map((time) => | ||||
|           <div key={dayjs(time).utc().format()}> | ||||
|       {slots.length > 0 ? ( | ||||
|         slots.map((slot) => ( | ||||
|           <div key={slot.format()}> | ||||
|             <Link | ||||
|               href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")}> | ||||
|               <a key={dayjs(time).format("hh:mma")} | ||||
|                  className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">{dayjs(time).tz(timeZone()).format(props.timeFormat)}</a> | ||||
|               href={ | ||||
|                 `/${user}/book?date=${slot.utc().format()}&type=${eventTypeId}` + | ||||
|                 (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> | ||||
|           </div> | ||||
|         ) : <div className="loader"></div> | ||||
|       } | ||||
|         )) | ||||
|       ) : ( | ||||
|         <div className="loader" /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| }; | ||||
| 
 | ||||
| export default AvailableTimes; | ||||
|  |  | |||
|  | @ -1,90 +1,90 @@ | |||
| import dayjs from "dayjs"; | ||||
| import {ChevronLeftIcon, ChevronRightIcon} from "@heroicons/react/solid"; | ||||
| import {useEffect, useState} from "react"; | ||||
| 
 | ||||
| const DatePicker = ({ weekStart, onDatePicked }) => { | ||||
| import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import dayjs, { Dayjs } from "dayjs"; | ||||
| import isToday from "dayjs/plugin/isToday"; | ||||
| dayjs.extend(isToday); | ||||
| 
 | ||||
| const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) => { | ||||
|   const workingDays = workingHours.reduce((workingDays: number[], wh) => [...workingDays, ...wh.days], []); | ||||
|   const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); | ||||
|   const [selectedDay, setSelectedDay] = useState(dayjs().date()); | ||||
|   const [hasPickedDate, setHasPickedDate] = useState(false); | ||||
|   const [selectedDate, setSelectedDate] = useState(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (hasPickedDate) { | ||||
|       onDatePicked(dayjs().month(selectedMonth).date(selectedDay)); | ||||
|     } | ||||
|   }, [hasPickedDate, selectedDay]); | ||||
|     if (selectedDate) onDatePicked(selectedDate); | ||||
|   }, [selectedDate, onDatePicked]); | ||||
| 
 | ||||
|   // Handle month changes
 | ||||
|   const incrementMonth = () => { | ||||
|     setSelectedMonth(selectedMonth + 1); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   const decrementMonth = () => { | ||||
|     setSelectedMonth(selectedMonth - 1); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   // Set up calendar
 | ||||
|   var daysInMonth = dayjs().month(selectedMonth).daysInMonth(); | ||||
|   var days = []; | ||||
|   const daysInMonth = dayjs().month(selectedMonth).daysInMonth(); | ||||
|   const days = []; | ||||
|   for (let i = 1; i <= daysInMonth; i++) { | ||||
|     days.push(i); | ||||
|   } | ||||
| 
 | ||||
|   // Create placeholder elements for empty days in first week
 | ||||
|   let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); | ||||
|   if (weekStart === 'Monday') { | ||||
|   if (weekStart === "Monday") { | ||||
|     weekdayOfFirst -= 1; | ||||
|     if (weekdayOfFirst < 0) | ||||
|       weekdayOfFirst = 6; | ||||
|     if (weekdayOfFirst < 0) 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"}> | ||||
|         {null} | ||||
|       </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
 | ||||
|   const calendar = [...emptyDays, ...days.map((day) => | ||||
|     <button key={day} | ||||
|             onClick={() => { setHasPickedDate(true); setSelectedDay(day) }} | ||||
|   const calendar = [ | ||||
|     ...emptyDays, | ||||
|     ...days.map((day) => ( | ||||
|       <button | ||||
|         key={day} | ||||
|         onClick={() => setSelectedDate(dayjs().month(selectedMonth).date(day))} | ||||
|         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={ | ||||
|               "text-center w-10 h-10 rounded-full mx-auto " + ( | ||||
|                 dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth) | ||||
|                 ) ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-400 font-light' | ||||
|               ) + ( | ||||
|                 dayjs().date(selectedDay).month(selectedMonth).format("D") == day ? ' bg-blue-600 text-white-important' : '' | ||||
|               ) | ||||
|           "text-center w-10 h-10 rounded-full mx-auto" + | ||||
|           (isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") + | ||||
|           (selectedDate && selectedDate.isSame(dayjs().month(selectedMonth).date(day), "day") | ||||
|             ? " bg-blue-600 text-white-important" | ||||
|             : !isDisabled(day) | ||||
|             ? " bg-blue-50" | ||||
|             : "") | ||||
|         }> | ||||
|         {day} | ||||
|       </button> | ||||
|   )]; | ||||
|     )), | ||||
|   ]; | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       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={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}> | ||||
|       <div className="flex text-gray-600 font-light text-xl mb-4 ml-2"> | ||||
|                   <span className="w-1/2"> | ||||
|                     {dayjs().month(selectedMonth).format("MMMM YYYY")} | ||||
|                   </span> | ||||
|         <span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span> | ||||
|         <div className="w-1/2 text-right"> | ||||
|           <button | ||||
|             onClick={decrementMonth} | ||||
|             className={ | ||||
|               "mr-4 " + | ||||
|               (selectedMonth < parseInt(dayjs().format("MM")) && | ||||
|                 "text-gray-400") | ||||
|             } | ||||
|             disabled={selectedMonth < parseInt(dayjs().format("MM"))} | ||||
|           > | ||||
|             className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")} | ||||
|             disabled={selectedMonth < parseInt(dayjs().format("MM"))}> | ||||
|             <ChevronLeftIcon className="w-5 h-5" /> | ||||
|           </button> | ||||
|           <button onClick={incrementMonth}> | ||||
|  | @ -93,17 +93,17 @@ const DatePicker = ({ weekStart, onDatePicked }) => { | |||
|         </div> | ||||
|       </div> | ||||
|       <div className="grid grid-cols-7 gap-y-4 text-center"> | ||||
|         { | ||||
|           ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] | ||||
|             .sort( (a, b) => weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0 ) | ||||
|             .map( (weekDay) => | ||||
|               <div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">{weekDay}</div> | ||||
|             ) | ||||
|         } | ||||
|         {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] | ||||
|           .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0)) | ||||
|           .map((weekDay) => ( | ||||
|             <div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest"> | ||||
|               {weekDay} | ||||
|             </div> | ||||
|           ))} | ||||
|         {calendar} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| }; | ||||
| 
 | ||||
| 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 {PencilAltIcon, TrashIcon} from "@heroicons/react/outline"; | ||||
| import {WeekdaySelect, Weekday} from "./WeekdaySelect"; | ||||
| import { TrashIcon } from "@heroicons/react/outline"; | ||||
| import { WeekdaySelect } from "./WeekdaySelect"; | ||||
| import SetTimesModal from "./modal/SetTimesModal"; | ||||
| import Schedule from '../../lib/schedule.model'; | ||||
| import dayjs, {Dayjs} from "dayjs"; | ||||
| import utc from 'dayjs/plugin/utc'; | ||||
| import timezone from 'dayjs/plugin/timezone'; | ||||
| import Schedule from "../../lib/schedule.model"; | ||||
| import dayjs from "dayjs"; | ||||
| import utc from "dayjs/plugin/utc"; | ||||
| import timezone from "dayjs/plugin/timezone"; | ||||
| dayjs.extend(utc); | ||||
| dayjs.extend(timezone); | ||||
| 
 | ||||
| export const Scheduler = (props) => { | ||||
| 
 | ||||
|   const [ schedules, setSchedules ]: Schedule[] = useState(props.schedules.map( schedule => { | ||||
|     const startDate = schedule.isOverride ? dayjs(schedule.startDate) : dayjs.utc().startOf('day').add(schedule.startTime, 'minutes') | ||||
|     return ( | ||||
|       { | ||||
|   const [schedules, setSchedules]: Schedule[] = useState( | ||||
|     props.schedules.map((schedule) => { | ||||
|       const startDate = schedule.isOverride | ||||
|         ? dayjs(schedule.startDate) | ||||
|         : dayjs.utc().startOf("day").add(schedule.startTime, "minutes").tz(props.timeZone); | ||||
|       return { | ||||
|         days: schedule.days, | ||||
|         startDate, | ||||
|         endDate: startDate.add(schedule.length, 'minutes') | ||||
|       } | ||||
|     ) | ||||
|   })); | ||||
|         endDate: startDate.add(schedule.length, "minutes"), | ||||
|       }; | ||||
|     }) | ||||
|   ); | ||||
| 
 | ||||
|   const [ timeZone, setTimeZone ] = useState(props.timeZone); | ||||
|   const [ editSchedule, setEditSchedule ] = useState(-1); | ||||
|   const [timeZone, setTimeZone] = useState(props.timeZone); | ||||
|   const [editSchedule, setEditSchedule] = useState(-1); | ||||
| 
 | ||||
|   useEffect( () => { | ||||
|   useEffect(() => { | ||||
|     props.onChange(schedules); | ||||
|   }, [schedules]) | ||||
|   }, [schedules]); | ||||
| 
 | ||||
|   const addNewSchedule = () => setEditSchedule(schedules.length); | ||||
| 
 | ||||
|   const applyEditSchedule = (changed: Schedule) => { | ||||
|     const replaceWith = { | ||||
|       ...schedules[editSchedule], | ||||
|       ...changed | ||||
|       ...changed, | ||||
|     }; | ||||
| 
 | ||||
|     schedules.splice(editSchedule, 1, replaceWith); | ||||
| 
 | ||||
|     setSchedules([].concat(schedules)); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   const removeScheduleAt = (toRemove: number) => { | ||||
|     schedules.splice(toRemove, 1); | ||||
|  | @ -49,7 +52,7 @@ export const Scheduler = (props) => { | |||
|   const setWeekdays = (idx: number, days: number[]) => { | ||||
|     schedules[idx].days = days; | ||||
|     setSchedules([].concat(schedules)); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|  | @ -60,26 +63,40 @@ export const Scheduler = (props) => { | |||
|               Timezone | ||||
|             </label> | ||||
|             <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> | ||||
|           <ul> | ||||
|             {schedules.map( (schedule, idx) => | ||||
|             {schedules.map((schedule, idx) => ( | ||||
|               <li key={idx} className="py-2 flex justify-between border-t"> | ||||
|                 <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)}> | ||||
|                   {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> | ||||
|                 </div> | ||||
|               <button type="button" onClick={() => removeScheduleAt(idx)} | ||||
|                 <button | ||||
|                   type="button" | ||||
|                   onClick={() => removeScheduleAt(idx)} | ||||
|                   className="btn-sm bg-transparent px-2 py-1 ml-1"> | ||||
|                   <TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" /> | ||||
|                 </button> | ||||
|             </li>)} | ||||
|               </li> | ||||
|             ))} | ||||
|           </ul> | ||||
|           <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 className="border-l p-2 w-2/5 text-sm bg-gray-50"> | ||||
|           {/*<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>*/} | ||||
|         </div> | ||||
|       </div> | ||||
|       {editSchedule >= 0 && | ||||
|         <SetTimesModal schedule={schedules[editSchedule]} | ||||
|       {editSchedule >= 0 && ( | ||||
|         <SetTimesModal | ||||
|           schedule={schedules[editSchedule]} | ||||
|           onChange={applyEditSchedule} | ||||
|                        onExit={() => setEditSchedule(-1)} /> | ||||
|       } | ||||
|           onExit={() => setEditSchedule(-1)} | ||||
|         /> | ||||
|       )} | ||||
|       {/*{showDateOverrideModal && | ||||
|         <DateOverrideModal /> | ||||
|       }*/} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| }; | ||||
|  |  | |||
|  | @ -1,42 +1,53 @@ | |||
| import React, {useEffect, useState} from "react"; | ||||
| import React, { useEffect, useState } from "react"; | ||||
| 
 | ||||
| export const WeekdaySelect = (props) => { | ||||
|   const [activeDays, setActiveDays] = useState( | ||||
|     [...Array(7).keys()].map((v, i) => (props.defaultValue || []).includes(i)) | ||||
|   ); | ||||
| 
 | ||||
|   const [ 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( () => { | ||||
|     props.onSelect(activeDays.map( (isActive, idx) => isActive ? idx + 1 : 0).filter( (v) => 0 !== v )); | ||||
|   useEffect(() => { | ||||
|     props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1)); | ||||
|   }, [activeDays]); | ||||
| 
 | ||||
|   const toggleDay = (e, idx: number) => { | ||||
|     e.preventDefault(); | ||||
|     activeDays[idx] = !activeDays[idx]; | ||||
|     setActiveDays([].concat(activeDays)); | ||||
|   } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="weekdaySelect"> | ||||
|       <div className="inline-flex"> | ||||
|         {days.map( (day, idx) => activeDays[idx] ? | ||||
|           <button key={idx} onClick={(e) => toggleDay(e, idx)} | ||||
|                   style={ {"marginLeft": "-2px"} } | ||||
|         {days.map((day, idx) => | ||||
|           activeDays[idx] ? ( | ||||
|             <button | ||||
|               key={idx} | ||||
|               onClick={(e) => toggleDay(e, idx)} | ||||
|               style={{ marginLeft: "-2px" }} | ||||
|               className={` | ||||
|                     active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded  | ||||
|                     ${activeDays[idx+1] ? 'rounded-r-none': ''}  | ||||
|                     ${activeDays[idx-1] ? 'rounded-l-none': ''}  | ||||
|                     ${idx === 0 ? 'rounded-l' : ''}  | ||||
|                     ${idx === days.length-1 ? 'rounded-r' : ''} | ||||
|                     ${activeDays[idx + 1] ? "rounded-r-none" : ""}  | ||||
|                     ${activeDays[idx - 1] ? "rounded-l-none" : ""}  | ||||
|                     ${idx === 0 ? "rounded-l" : ""}  | ||||
|                     ${idx === days.length - 1 ? "rounded-r" : ""} | ||||
|                   `}>
 | ||||
|               {day} | ||||
|             </button> | ||||
|           : | ||||
|           <button key={idx} onClick={(e) => toggleDay(e, idx)} | ||||
|                   style={ {"marginTop": "1px", "marginBottom": "1px"} } | ||||
|                   className={`border focus:outline-none px-2 py-1 rounded-none ${idx === 0 ? 'rounded-l' : 'border-l-0'} ${idx === days.length-1 ? 'rounded-r' : ''}`}> | ||||
|           ) : ( | ||||
|             <button | ||||
|               key={idx} | ||||
|               onClick={(e) => toggleDay(e, idx)} | ||||
|               style={{ marginTop: "1px", marginBottom: "1px" }} | ||||
|               className={`border focus:outline-none px-2 py-1 rounded-none ${ | ||||
|                 idx === 0 ? "rounded-l" : "border-l-0" | ||||
|               } ${idx === days.length - 1 ? "rounded-r" : ""}`}>
 | ||||
|               {day} | ||||
|             </button> | ||||
|           ) | ||||
|         )} | ||||
|       </div> | ||||
|     </div>); | ||||
| } | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -1,14 +1,20 @@ | |||
| import {ClockIcon} from "@heroicons/react/outline"; | ||||
| import {useRef} from "react"; | ||||
| import { ClockIcon } from "@heroicons/react/outline"; | ||||
| import { useRef } from "react"; | ||||
| 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) { | ||||
| 
 | ||||
|   const {startDate, endDate} = props.schedule || { | ||||
|     startDate: dayjs().startOf('day').add(540, 'minutes'), | ||||
|     endDate: dayjs().startOf('day').add(1020, 'minutes'), | ||||
|   const { startDate, endDate } = props.schedule || { | ||||
|     startDate: dayjs.utc().startOf("day").add(540, "minutes"), | ||||
|     endDate: dayjs.utc().startOf("day").add(1020, "minutes"), | ||||
|   }; | ||||
| 
 | ||||
|   startDate.tz(props.timeZone); | ||||
|   endDate.tz(props.timeZone); | ||||
| 
 | ||||
|   const startHoursRef = useRef<HTMLInputElement>(); | ||||
|   const startMinsRef = useRef<HTMLInputElement>(); | ||||
|   const endHoursRef = useRef<HTMLInputElement>(); | ||||
|  | @ -31,60 +37,108 @@ export default function SetTimesModal(props) { | |||
|   } | ||||
| 
 | ||||
|   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="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 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="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> | ||||
|           <div className="sm:flex sm:items-start mb-4"> | ||||
|             <div | ||||
|               className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"> | ||||
|               <ClockIcon className="h-6 w-6 text-blue-600"/> | ||||
|             <div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"> | ||||
|               <ClockIcon className="h-6 w-6 text-blue-600" /> | ||||
|             </div> | ||||
|             <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | ||||
|               <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> | ||||
|                 Change when you are available for bookings | ||||
|               </h3> | ||||
|               <div> | ||||
|                 <p className="text-sm text-gray-500"> | ||||
|                   Set your work schedule | ||||
|                 </p> | ||||
|                 <p className="text-sm text-gray-500">Set your work schedule</p> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div className="flex mb-4"> | ||||
|             <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label> | ||||
|             <div> | ||||
|               <label htmlFor="startHours" className="sr-only">Hours</label> | ||||
|               <input ref={startHoursRef} type="number" min="0" max="23" maxLength="2"  name="hours" id="startHours" | ||||
|               <label htmlFor="startHours" className="sr-only"> | ||||
|                 Hours | ||||
|               </label> | ||||
|               <input | ||||
|                 ref={startHoursRef} | ||||
|                 type="number" | ||||
|                 min="0" | ||||
|                 max="23" | ||||
|                 maxLength="2" | ||||
|                 name="hours" | ||||
|                 id="startHours" | ||||
|                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||
|                      placeholder="9" defaultValue={startDate.format('H')} /> | ||||
|                 placeholder="9" | ||||
|                 defaultValue={startDate.format("H")} | ||||
|               /> | ||||
|             </div> | ||||
|             <span className="mx-2 pt-1">:</span> | ||||
|             <div> | ||||
|               <label htmlFor="startMinutes" className="sr-only">Minutes</label> | ||||
|               <input ref={startMinsRef} type="number" min="0" max="59" step="15" maxLength="2" name="minutes" id="startMinutes" | ||||
|               <label htmlFor="startMinutes" className="sr-only"> | ||||
|                 Minutes | ||||
|               </label> | ||||
|               <input | ||||
|                 ref={startMinsRef} | ||||
|                 type="number" | ||||
|                 min="0" | ||||
|                 max="59" | ||||
|                 step="15" | ||||
|                 maxLength="2" | ||||
|                 name="minutes" | ||||
|                 id="startMinutes" | ||||
|                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||
|                      placeholder="30" defaultValue={startDate.format('m')} /> | ||||
|                 placeholder="30" | ||||
|                 defaultValue={startDate.format("m")} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div className="flex"> | ||||
|             <label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label> | ||||
|             <div> | ||||
|               <label htmlFor="endHours" className="sr-only">Hours</label> | ||||
|               <input ref={endHoursRef} type="number" min="0" max="23" maxLength="2" name="hours" id="endHours" | ||||
|               <label htmlFor="endHours" className="sr-only"> | ||||
|                 Hours | ||||
|               </label> | ||||
|               <input | ||||
|                 ref={endHoursRef} | ||||
|                 type="number" | ||||
|                 min="0" | ||||
|                 max="24" | ||||
|                 maxLength="2" | ||||
|                 name="hours" | ||||
|                 id="endHours" | ||||
|                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||
|                      placeholder="17" defaultValue={endDate.format('H')} /> | ||||
|                 placeholder="17" | ||||
|                 defaultValue={endDate.format("H")} | ||||
|               /> | ||||
|             </div> | ||||
|             <span className="mx-2 pt-1">:</span> | ||||
|             <div> | ||||
|               <label htmlFor="endMinutes" className="sr-only">Minutes</label> | ||||
|               <input ref={endMinsRef} type="number" min="0" max="59" maxLength="2" step="15" name="minutes" id="endMinutes" | ||||
|               <label htmlFor="endMinutes" className="sr-only"> | ||||
|                 Minutes | ||||
|               </label> | ||||
|               <input | ||||
|                 ref={endMinsRef} | ||||
|                 type="number" | ||||
|                 min="0" | ||||
|                 max="59" | ||||
|                 maxLength="2" | ||||
|                 step="15" | ||||
|                 name="minutes" | ||||
|                 id="endMinutes" | ||||
|                 className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" | ||||
|                      placeholder="30" defaultValue={endDate.format('m')} /> | ||||
|                 placeholder="30" | ||||
|                 defaultValue={endDate.format("m")} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										172
									
								
								lib/slots.ts
									
									
									
									
									
								
							
							
						
						
									
										172
									
								
								lib/slots.ts
									
									
									
									
									
								
							|  | @ -1,94 +1,108 @@ | |||
| const dayjs = require("dayjs"); | ||||
| 
 | ||||
| const isToday = require("dayjs/plugin/isToday"); | ||||
| const utc = require("dayjs/plugin/utc"); | ||||
| const timezone = require("dayjs/plugin/timezone"); | ||||
| 
 | ||||
| dayjs.extend(isToday); | ||||
| import dayjs, { Dayjs } from "dayjs"; | ||||
| import utc from "dayjs/plugin/utc"; | ||||
| dayjs.extend(utc); | ||||
| dayjs.extend(timezone); | ||||
| 
 | ||||
| const getMinutesFromMidnight = (date) => { | ||||
|   return date.hour() * 60 + date.minute(); | ||||
| interface GetSlotsType { | ||||
|   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 = ({ | ||||
|   calendarTimeZone, | ||||
|   eventLength, | ||||
|   selectedTimeZone, | ||||
|   selectedDate, | ||||
|   dayStartTime, | ||||
|   dayEndTime | ||||
| }) => { | ||||
| // say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
 | ||||
| const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) => | ||||
|   boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean); | ||||
| 
 | ||||
|   if(!selectedDate) return [] | ||||
| const organizerBoundaries = (workingHours: [], inviteeDate: Dayjs, inviteeBounds: Boundary): Boundary[] => { | ||||
|   const boundaries: Boundary[] = []; | ||||
| 
 | ||||
|   const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day"); | ||||
| 
 | ||||
|   // Simple case, same timezone
 | ||||
|   if (calendarTimeZone === selectedTimeZone) { | ||||
|     const slots = []; | ||||
|     const now = dayjs(); | ||||
|     for ( | ||||
|       let minutes = dayStartTime; | ||||
|       minutes <= dayEndTime - eventLength; | ||||
|       minutes += parseInt(eventLength, 10) | ||||
|     ) { | ||||
|       const slot = lowerBound.add(minutes, "minutes"); | ||||
|       if (slot > now) { | ||||
|         slots.push(slot); | ||||
|       } | ||||
|     } | ||||
|     return slots; | ||||
|   } | ||||
| 
 | ||||
|   const upperBound = selectedDate.tz(selectedTimeZone).endOf("day"); | ||||
| 
 | ||||
|   // We need to start generating slots from the start of the calendarTimeZone day
 | ||||
|   const startDateTime = lowerBound | ||||
|     .tz(calendarTimeZone) | ||||
|   const startDay: number = +inviteeDate | ||||
|     .utc() | ||||
|     .startOf("day") | ||||
|     .add(dayStartTime, "minutes"); | ||||
|     .add(inviteeBounds.lowerBound, "minutes") | ||||
|     .format("d"); | ||||
|   const endDay: number = +inviteeDate | ||||
|     .utc() | ||||
|     .startOf("day") | ||||
|     .add(inviteeBounds.upperBound, "minutes") | ||||
|     .format("d"); | ||||
| 
 | ||||
|   let phase = 0; | ||||
|   if (startDateTime < lowerBound) { | ||||
|     // Getting minutes of the first event in the day of the chooser
 | ||||
|     const diff = lowerBound.diff(startDateTime, "minutes"); | ||||
| 
 | ||||
|     // finding first event
 | ||||
|     phase = diff + eventLength - (diff % eventLength); | ||||
|   workingHours.forEach((item) => { | ||||
|     const lowerBound: number = item.startTime; | ||||
|     const upperBound: number = lowerBound + item.length; | ||||
|     if (startDay !== endDay) { | ||||
|       if (inviteeBounds.lowerBound < 0) { | ||||
|         // lowerBound edges into the previous day
 | ||||
|         if (item.days.includes(startDay)) { | ||||
|           boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 }); | ||||
|         } | ||||
| 
 | ||||
|   // We can stop as soon as the selectedTimeZone day ends
 | ||||
|   const endDateTime = upperBound | ||||
|     .tz(calendarTimeZone) | ||||
|     .subtract(eventLength, "minutes"); | ||||
| 
 | ||||
|   const maxMinutes = endDateTime.diff(startDateTime, "minutes"); | ||||
| 
 | ||||
|   const slots = []; | ||||
|   const now = dayjs(); | ||||
|   for ( | ||||
|     let minutes = phase; | ||||
|     minutes <= maxMinutes; | ||||
|     minutes += parseInt(eventLength, 10) | ||||
|   ) { | ||||
|     const slot = startDateTime.add(minutes, "minutes"); | ||||
| 
 | ||||
|     const minutesFromMidnight = getMinutesFromMidnight(slot); | ||||
| 
 | ||||
|     if ( | ||||
|       minutesFromMidnight < dayStartTime || | ||||
|       minutesFromMidnight > dayEndTime - eventLength || | ||||
|       slot < now | ||||
|     ) { | ||||
|       continue; | ||||
|         if (item.days.includes(endDay)) { | ||||
|           boundaries.push({ lowerBound, upperBound }); | ||||
|         } | ||||
| 
 | ||||
|     slots.push(slot.tz(selectedTimeZone)); | ||||
|       } else { | ||||
|         // upperBound edges into the next day
 | ||||
|         if (item.days.includes(endDay)) { | ||||
|           boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 }); | ||||
|         } | ||||
|         if (item.days.includes(startDay)) { | ||||
|           boundaries.push({ lowerBound, upperBound }); | ||||
|         } | ||||
|       } | ||||
|     } else { | ||||
|       boundaries.push({ lowerBound, upperBound }); | ||||
|     } | ||||
|   }); | ||||
|   return boundaries; | ||||
| }; | ||||
| 
 | ||||
| const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => { | ||||
|   const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency); | ||||
|   const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency); | ||||
|   return { | ||||
|     lowerBound, | ||||
|     upperBound, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => { | ||||
|   const slots: Dayjs[] = []; | ||||
|   for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) { | ||||
|     slots.push( | ||||
|       <Dayjs>dayjs | ||||
|         .utc() | ||||
|         .startOf("day") | ||||
|         .add(lowerBound + minutes, "minutes") | ||||
|     ); | ||||
|   } | ||||
|   return slots; | ||||
| }; | ||||
| 
 | ||||
| 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, | ||||
|   "scripts": { | ||||
|     "dev": "next dev", | ||||
|     "test": "node --experimental-vm-modules node_modules/.bin/jest", | ||||
|     "build": "next build", | ||||
|     "start": "next start", | ||||
|     "postinstall": "prisma generate", | ||||
|  | @ -36,6 +37,7 @@ | |||
|     "uuid": "^8.3.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/jest": "^26.0.23", | ||||
|     "@types/node": "^14.14.33", | ||||
|     "@types/react": "^17.0.3", | ||||
|     "@typescript-eslint/eslint-plugin": "^4.27.0", | ||||
|  | @ -47,7 +49,9 @@ | |||
|     "eslint-plugin-react": "^7.24.0", | ||||
|     "eslint-plugin-react-hooks": "^4.2.0", | ||||
|     "husky": "^6.0.0", | ||||
|     "jest": "^27.0.5", | ||||
|     "lint-staged": "^11.0.0", | ||||
|     "mockdate": "^3.0.5", | ||||
|     "postcss": "^8.2.8", | ||||
|     "prettier": "^2.3.1", | ||||
|     "prisma": "^2.23.0", | ||||
|  | @ -59,5 +63,19 @@ | |||
|       "prettier --write", | ||||
|       "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 Head from 'next/head'; | ||||
| import Link from 'next/link'; | ||||
| import prisma from '../../lib/prisma'; | ||||
| import dayjs, { Dayjs } from 'dayjs'; | ||||
| import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'; | ||||
| import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; | ||||
| import utc from 'dayjs/plugin/utc'; | ||||
| import timezone from 'dayjs/plugin/timezone'; | ||||
| import { useEffect, useState, useMemo } from "react"; | ||||
| import Head from "next/head"; | ||||
| import prisma from "../../lib/prisma"; | ||||
| import dayjs, { Dayjs } from "dayjs"; | ||||
| import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid"; | ||||
| import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; | ||||
| import utc from "dayjs/plugin/utc"; | ||||
| import timezone from "dayjs/plugin/timezone"; | ||||
| dayjs.extend(isSameOrBefore); | ||||
| dayjs.extend(utc); | ||||
| dayjs.extend(timezone); | ||||
| 
 | ||||
| import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; | ||||
| import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; | ||||
| import AvailableTimes from "../../components/booking/AvailableTimes"; | ||||
| import TimeOptions from "../../components/booking/TimeOptions" | ||||
| import Avatar from '../../components/Avatar'; | ||||
| import {timeZone} from "../../lib/clock"; | ||||
| import TimeOptions from "../../components/booking/TimeOptions"; | ||||
| import Avatar from "../../components/Avatar"; | ||||
| import { timeZone } from "../../lib/clock"; | ||||
| import DatePicker from "../../components/booking/DatePicker"; | ||||
| import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; | ||||
| import {useRouter} from "next/router"; | ||||
| import { useRouter } from "next/router"; | ||||
| import getSlots from "@lib/slots"; | ||||
| 
 | ||||
| export default function Type(props) { | ||||
| 
 | ||||
|   // Get router variables
 | ||||
|   const router = useRouter(); | ||||
|   const { rescheduleUid } = router.query; | ||||
| 
 | ||||
|   // Initialise state
 | ||||
|   const [selectedDate, setSelectedDate] = useState<Dayjs>(); | ||||
|   const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); | ||||
|   const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); | ||||
|   const [timeFormat, setTimeFormat] = useState('h:mma'); | ||||
|   const [timeFormat, setTimeFormat] = useState("h:mma"); | ||||
|   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(() => { | ||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())) | ||||
|   }, []); | ||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())); | ||||
|   }, [telemetry]); | ||||
| 
 | ||||
|   const changeDate = (date: Dayjs) => { | ||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())) | ||||
|     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())); | ||||
|     setSelectedDate(date); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectTimeZone = (selectedTimeZone: string) => { | ||||
|     if (selectedDate) { | ||||
|       setSelectedDate(selectedDate.tz(selectedTimeZone)) | ||||
|       setSelectedDate(selectedDate.tz(selectedTimeZone)); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleToggle24hClock = (is24hClock: boolean) => { | ||||
|     if (selectedDate) { | ||||
|       setTimeFormat(is24hClock ? 'HH:mm' : 'h:mma'); | ||||
|     } | ||||
|   } | ||||
|     setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|  | @ -67,40 +73,47 @@ export default function Type(props) { | |||
|         className={ | ||||
|           "mx-auto my-24 transition-max-width ease-in-out duration-500 " + | ||||
|           (selectedDate ? "max-w-6xl" : "max-w-3xl") | ||||
|         } | ||||
|       > | ||||
|         }> | ||||
|         <div className="bg-white shadow rounded-lg"> | ||||
|           <div className="sm:flex px-4 py-5 sm:p-4"> | ||||
|             <div | ||||
|               className={ | ||||
|                 "pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2") | ||||
|               } | ||||
|             > | ||||
|             <div 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" /> | ||||
|               <h2 className="font-medium text-gray-500">{props.user.name}</h2> | ||||
|               <h1 className="text-3xl font-semibold text-gray-800 mb-4"> | ||||
|                 {props.eventType.title} | ||||
|               </h1> | ||||
|               <h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1> | ||||
|               <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" /> | ||||
|                 {props.eventType.length} minutes | ||||
|               </p> | ||||
|               <button | ||||
|                 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" /> | ||||
|                 {timeZone()} | ||||
|                 <ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" /> | ||||
|               </button> | ||||
|             { isTimeOptionsOpen && <TimeOptions onSelectTimeZone={handleSelectTimeZone} | ||||
|                                                 onToggle24hClock={handleToggle24hClock} />} | ||||
|             <p className="text-gray-600 mt-3 mb-8"> | ||||
|               {props.eventType.description} | ||||
|             </p> | ||||
|               {isTimeOptionsOpen && ( | ||||
|                 <TimeOptions | ||||
|                   onSelectTimeZone={handleSelectTimeZone} | ||||
|                   onToggle24hClock={handleToggle24hClock} | ||||
|                 /> | ||||
|               )} | ||||
|               <p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p> | ||||
|             </div> | ||||
|             <DatePicker weekStart={props.user.weekStart} onDatePicked={changeDate} /> | ||||
|             {selectedDate && <AvailableTimes timeFormat={timeFormat} user={props.user} eventType={props.eventType} date={selectedDate} />} | ||||
|             <DatePicker | ||||
|               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> | ||||
|         {/* 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) { | ||||
|   const user = await prisma.user.findFirst({ | ||||
|     where: { | ||||
|  | @ -129,13 +150,14 @@ export async function getServerSideProps(context) { | |||
|       timeZone: true, | ||||
|       endTime: true, | ||||
|       weekStart: true, | ||||
|     } | ||||
|       availability: true, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   if (!user) { | ||||
|     return { | ||||
|       notFound: true, | ||||
|     } | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   const eventType = await prisma.eventType.findFirst({ | ||||
|  | @ -149,20 +171,38 @@ export async function getServerSideProps(context) { | |||
|       id: true, | ||||
|       title: true, | ||||
|       description: true, | ||||
|       length: true | ||||
|     } | ||||
|       length: true, | ||||
|       availability: true, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   if (!eventType) { | ||||
|     return { | ||||
|       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 { | ||||
|     props: { | ||||
|       user, | ||||
|       eventType, | ||||
|       workingHours, | ||||
|     }, | ||||
|   } | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -1,48 +1,44 @@ | |||
| import Head from 'next/head'; | ||||
| import Link from 'next/link'; | ||||
| import { useRouter } from 'next/router'; | ||||
| import { useRef, useState, useEffect } from 'react'; | ||||
| import Select, { OptionBase } from 'react-select'; | ||||
| import prisma from '../../../lib/prisma'; | ||||
| import {LocationType} from '../../../lib/location'; | ||||
| import Shell from '../../../components/Shell'; | ||||
| import { useSession, getSession } from 'next-auth/client'; | ||||
| import {Scheduler} from "../../../components/ui/Scheduler"; | ||||
| import Head from "next/head"; | ||||
| import Link from "next/link"; | ||||
| import { useRouter } from "next/router"; | ||||
| import { useRef, useState } from "react"; | ||||
| import Select, { OptionBase } from "react-select"; | ||||
| import prisma from "../../../lib/prisma"; | ||||
| import { LocationType } from "../../../lib/location"; | ||||
| import Shell from "../../../components/Shell"; | ||||
| import { getSession } from "next-auth/client"; | ||||
| import { Scheduler } from "../../../components/ui/Scheduler"; | ||||
| 
 | ||||
| import { | ||||
|   LocationMarkerIcon, | ||||
|   PlusCircleIcon, | ||||
|   XIcon, | ||||
|   PhoneIcon, | ||||
| } from '@heroicons/react/outline'; | ||||
| import {EventTypeCustomInput, EventTypeCustomInputType} from "../../../lib/eventTypeInput"; | ||||
| import {PlusIcon} from "@heroicons/react/solid"; | ||||
| import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline"; | ||||
| import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput"; | ||||
| import { PlusIcon } from "@heroicons/react/solid"; | ||||
| 
 | ||||
| import dayjs, {Dayjs} from "dayjs"; | ||||
| import utc from 'dayjs/plugin/utc'; | ||||
| import dayjs from "dayjs"; | ||||
| import utc from "dayjs/plugin/utc"; | ||||
| dayjs.extend(utc); | ||||
| import timezone from 'dayjs/plugin/timezone'; | ||||
| import timezone from "dayjs/plugin/timezone"; | ||||
| dayjs.extend(timezone); | ||||
| 
 | ||||
| export default function EventType(props) { | ||||
|   const router = useRouter(); | ||||
| 
 | ||||
|   const inputOptions: OptionBase[] = [ | ||||
|       { value: EventTypeCustomInputType.Text, label: 'Text' }, | ||||
|       { value: EventTypeCustomInputType.TextLong, label: 'Multiline Text' }, | ||||
|       { value: EventTypeCustomInputType.Number, label: 'Number', }, | ||||
|       { value: EventTypeCustomInputType.Bool, label: 'Checkbox', }, | ||||
|     ] | ||||
|     { value: EventTypeCustomInputType.Text, label: "Text" }, | ||||
|     { value: EventTypeCustomInputType.TextLong, label: "Multiline Text" }, | ||||
|     { value: EventTypeCustomInputType.Number, label: "Number" }, | ||||
|     { value: EventTypeCustomInputType.Bool, label: "Checkbox" }, | ||||
|   ]; | ||||
| 
 | ||||
|     const [ session, loading ] = useSession(); | ||||
|     const [ showLocationModal, setShowLocationModal ] = useState(false); | ||||
|     const [ showAddCustomModal, setShowAddCustomModal ] = useState(false); | ||||
|     const [ selectedLocation, setSelectedLocation ] = useState<OptionBase | undefined>(undefined); | ||||
|     const [ selectedInputOption, setSelectedInputOption ] = useState<OptionBase>(inputOptions[0]); | ||||
|     const [ locations, setLocations ] = useState(props.eventType.locations || []); | ||||
|     const [ schedule, setSchedule ] = useState(undefined); | ||||
|     const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []); | ||||
|     const locationOptions = props.locationOptions | ||||
|   const [showLocationModal, setShowLocationModal] = useState(false); | ||||
|   const [showAddCustomModal, setShowAddCustomModal] = useState(false); | ||||
|   const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined); | ||||
|   const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]); | ||||
|   const [locations, setLocations] = useState(props.eventType.locations || []); | ||||
|   const [schedule, setSchedule] = useState(undefined); | ||||
|   const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>( | ||||
|     props.eventType.customInputs.sort((a, b) => a.id - b.id) || [] | ||||
|   ); | ||||
|   const locationOptions = props.locationOptions; | ||||
| 
 | ||||
|   const titleRef = useRef<HTMLInputElement>(); | ||||
|   const slugRef = useRef<HTMLInputElement>(); | ||||
|  | @ -51,10 +47,6 @@ export default function EventType(props) { | |||
|   const isHiddenRef = useRef<HTMLInputElement>(); | ||||
|   const eventNameRef = useRef<HTMLInputElement>(); | ||||
| 
 | ||||
|     if (loading) { | ||||
|         return <p className="text-gray-400">Loading...</p>; | ||||
|     } | ||||
| 
 | ||||
|   async function updateEventTypeHandler(event) { | ||||
|     event.preventDefault(); | ||||
| 
 | ||||
|  | @ -66,60 +58,70 @@ export default function EventType(props) { | |||
|     const enteredEventName = eventNameRef.current.value; | ||||
|     // TODO: Add validation
 | ||||
| 
 | ||||
|         const response = await fetch('/api/availability/eventtype', { | ||||
|             method: 'PATCH', | ||||
|             body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations, eventName: enteredEventName, customInputs }), | ||||
|     await fetch("/api/availability/eventtype", { | ||||
|       method: "PATCH", | ||||
|       body: JSON.stringify({ | ||||
|         id: props.eventType.id, | ||||
|         title: enteredTitle, | ||||
|         slug: enteredSlug, | ||||
|         description: enteredDescription, | ||||
|         length: enteredLength, | ||||
|         hidden: enteredIsHidden, | ||||
|         locations, | ||||
|         eventName: enteredEventName, | ||||
|         customInputs, | ||||
|       }), | ||||
|       headers: { | ||||
|                 'Content-Type': 'application/json' | ||||
|             } | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     if (schedule) { | ||||
| 
 | ||||
|           let schedulePayload = { "overrides": [], "timeZone": props.user.timeZone, "openingHours": [] }; | ||||
|           schedule.forEach( (item) => { | ||||
|       const schedulePayload = { overrides: [], timeZone: props.user.timeZone, openingHours: [] }; | ||||
|       schedule.forEach((item) => { | ||||
|         if (item.isOverride) { | ||||
|           delete item.isOverride; | ||||
|           schedulePayload.overrides.push(item); | ||||
|         } else { | ||||
|           const endTime = item.endDate.hour() * 60 + item.endDate.minute() || 1440; // also handles 00:00
 | ||||
|           schedulePayload.openingHours.push({ | ||||
|             days: item.days, | ||||
|                 startTime: item.startDate.hour() * 60 + item.startDate.minute(), | ||||
|                 endTime: item.endDate.hour() * 60 + item.endDate.minute() | ||||
|             startTime: item.startDate.hour() * 60 + item.startDate.minute() - item.startDate.utcOffset(), | ||||
|             endTime: endTime - item.endDate.utcOffset(), | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|           const response = await fetch('/api/availability/schedule/' + props.eventType.id, { | ||||
|             method: 'PUT', | ||||
|       await fetch("/api/availability/schedule/" + props.eventType.id, { | ||||
|         method: "PUT", | ||||
|         body: JSON.stringify(schedulePayload), | ||||
|         headers: { | ||||
|               'Content-Type': 'application/json' | ||||
|             } | ||||
|           "Content-Type": "application/json", | ||||
|         }, | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|         router.push('/availability'); | ||||
|     router.push("/availability"); | ||||
|   } | ||||
| 
 | ||||
|   async function deleteEventTypeHandler(event) { | ||||
|     event.preventDefault(); | ||||
| 
 | ||||
|         const response = await fetch('/api/availability/eventtype', { | ||||
|             method: 'DELETE', | ||||
|             body: JSON.stringify({id: props.eventType.id}), | ||||
|     await fetch("/api/availability/eventtype", { | ||||
|       method: "DELETE", | ||||
|       body: JSON.stringify({ id: props.eventType.id }), | ||||
|       headers: { | ||||
|                 'Content-Type': 'application/json' | ||||
|             } | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|         router.push('/availability'); | ||||
|     router.push("/availability"); | ||||
|   } | ||||
| 
 | ||||
|   const openLocationModal = (type: LocationType) => { | ||||
|         setSelectedLocation(locationOptions.find( (option) => option.value === type)); | ||||
|     setSelectedLocation(locationOptions.find((option) => option.value === type)); | ||||
|     setShowLocationModal(true); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const closeLocationModal = () => { | ||||
|     setSelectedLocation(undefined); | ||||
|  | @ -136,27 +138,32 @@ export default function EventType(props) { | |||
|       return null; | ||||
|     } | ||||
|     switch (selectedLocation.value) { | ||||
|             case LocationType.InPerson: | ||||
|                 const address = locations.find( | ||||
|                     (location) => location.type === LocationType.InPerson | ||||
|                 )?.address; | ||||
|       case LocationType.InPerson: { | ||||
|         const address = locations.find((location) => location.type === LocationType.InPerson)?.address; | ||||
|         return ( | ||||
|           <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"> | ||||
|                             <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> | ||||
|                 ) | ||||
|         ); | ||||
|       } | ||||
|       case LocationType.Phone: | ||||
| 
 | ||||
|         return ( | ||||
|           <p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p> | ||||
|                 ) | ||||
|         ); | ||||
|       case LocationType.GoogleMeet: | ||||
|                  return ( | ||||
|                     <p className="text-sm">Calendso will provide a Google Meet location.</p> | ||||
|                 ) | ||||
|         return <p className="text-sm">Calendso will provide a Google Meet location.</p>; | ||||
|     } | ||||
|     return null; | ||||
|   }; | ||||
|  | @ -169,10 +176,10 @@ export default function EventType(props) { | |||
|       details = { address: e.target.address.value }; | ||||
|     } | ||||
| 
 | ||||
|         const existingIdx = locations.findIndex( (loc) => e.target.location.value === loc.type ); | ||||
|     const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type); | ||||
|     if (existingIdx !== -1) { | ||||
|             let copy = locations; | ||||
|             copy[ existingIdx ] = { ...locations[ existingIdx ], ...details }; | ||||
|       const copy = locations; | ||||
|       copy[existingIdx] = { ...locations[existingIdx], ...details }; | ||||
|       setLocations(copy); | ||||
|     } else { | ||||
|       setLocations(locations.concat({ type: e.target.location.value, ...details })); | ||||
|  | @ -182,7 +189,7 @@ export default function EventType(props) { | |||
|   }; | ||||
| 
 | ||||
|   const removeLocation = (selectedLocation) => { | ||||
|         setLocations(locations.filter( (location) => location.type !== selectedLocation.type )); | ||||
|     setLocations(locations.filter((location) => location.type !== selectedLocation.type)); | ||||
|   }; | ||||
| 
 | ||||
|   const updateCustom = (e) => { | ||||
|  | @ -191,7 +198,7 @@ export default function EventType(props) { | |||
|     const customInput: EventTypeCustomInput = { | ||||
|       label: e.target.label.value, | ||||
|       required: e.target.required.checked, | ||||
|         type: e.target.type.value | ||||
|       type: e.target.type.value, | ||||
|     }; | ||||
| 
 | ||||
|     setCustomInputs(customInputs.concat(customInput)); | ||||
|  | @ -205,20 +212,33 @@ export default function EventType(props) { | |||
|         <title>{props.eventType.title} | Event Type | Calendso</title> | ||||
|         <link rel="icon" href="/favicon.ico" /> | ||||
|       </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="col-span-3 sm:col-span-2"> | ||||
|             <div className="bg-white overflow-hidden shadow rounded-lg mb-4"> | ||||
|               <div className="px-4 py-5 sm:p-6"> | ||||
|                 <form onSubmit={updateEventTypeHandler}> | ||||
|                   <div className="mb-4"> | ||||
|                       <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"> | ||||
|                         <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 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="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"> | ||||
|  | @ -237,8 +257,11 @@ export default function EventType(props) { | |||
|                     </div> | ||||
|                   </div> | ||||
|                   <div className="mb-4"> | ||||
|                       <label htmlFor="location" className="block text-sm font-medium text-gray-700">Location</label> | ||||
|                       {locations.length === 0 && <div className="mt-1 mb-2"> | ||||
|                     <label htmlFor="location" className="block text-sm font-medium text-gray-700"> | ||||
|                       Location | ||||
|                     </label> | ||||
|                     {locations.length === 0 && ( | ||||
|                       <div className="mt-1 mb-2"> | ||||
|                         <div className="flex rounded-md shadow-sm"> | ||||
|                           <Select | ||||
|                             name="location" | ||||
|  | @ -249,9 +272,11 @@ export default function EventType(props) { | |||
|                             onChange={(e) => openLocationModal(e.value)} | ||||
|                           /> | ||||
|                         </div> | ||||
|                       </div>} | ||||
|                       {locations.length > 0 && <ul className="w-96 mt-1"> | ||||
|                         {locations.map( (location) => ( | ||||
|                       </div> | ||||
|                     )} | ||||
|                     {locations.length > 0 && ( | ||||
|                       <ul className="w-96 mt-1"> | ||||
|                         {locations.map((location) => ( | ||||
|                           <li key={location.type} className="bg-blue-50 mb-2 p-2 border"> | ||||
|                             <div className="flex justify-between"> | ||||
|                               {location.type === LocationType.InPerson && ( | ||||
|  | @ -268,12 +293,29 @@ export default function EventType(props) { | |||
|                               )} | ||||
|                               {location.type === LocationType.GoogleMeet && ( | ||||
|                                 <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> | ||||
|                                 </div> | ||||
|                               )} | ||||
|                               <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)}> | ||||
|                                   <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " /> | ||||
|                                 </button> | ||||
|  | @ -281,39 +323,76 @@ export default function EventType(props) { | |||
|                             </div> | ||||
|                           </li> | ||||
|                         ))} | ||||
|                         {locations.length > 0 && locations.length !== locationOptions.length && <li> | ||||
|                           <button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowLocationModal(true)}> | ||||
|                         {locations.length > 0 && locations.length !== locationOptions.length && ( | ||||
|                           <li> | ||||
|                             <button | ||||
|                               type="button" | ||||
|                               className="sm:flex sm:items-start text-sm text-blue-600" | ||||
|                               onClick={() => setShowLocationModal(true)}> | ||||
|                               <PlusCircleIcon className="h-6 w-6" /> | ||||
|                               <span className="ml-1">Add another location option</span> | ||||
|                             </button> | ||||
|                         </li>} | ||||
|                       </ul>} | ||||
|                           </li> | ||||
|                         )} | ||||
|                       </ul> | ||||
|                     )} | ||||
|                   </div> | ||||
|                   <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"> | ||||
|                         <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 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"> | ||||
|                         <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"> | ||||
|                         minutes | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                   <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"> | ||||
|                         <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 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"> | ||||
|                         {customInputs.map( (customInput) => ( | ||||
|                       {customInputs.map((customInput) => ( | ||||
|                         <li key={customInput.type} className="bg-blue-50 mb-2 p-2 border"> | ||||
|                           <div className="flex justify-between"> | ||||
|                             <div> | ||||
|  | @ -324,24 +403,27 @@ export default function EventType(props) { | |||
|                                 <span className="ml-2 text-sm">Type: {customInput.type}</span> | ||||
|                               </div> | ||||
|                               <div> | ||||
|                                   <span | ||||
|                                     className="ml-2 text-sm">{customInput.required ? "Required" : "Optional"}</span> | ||||
|                                 <span className="ml-2 text-sm"> | ||||
|                                   {customInput.required ? "Required" : "Optional"} | ||||
|                                 </span> | ||||
|                               </div> | ||||
|                             </div> | ||||
|                             <div className="flex"> | ||||
|                                 <button type="button" onClick={() => { | ||||
|                                 }} className="mr-2 text-sm text-blue-600">Edit | ||||
|                               <button type="button" className="mr-2 text-sm text-blue-600"> | ||||
|                                 Edit | ||||
|                               </button> | ||||
|                                 <button onClick={() => { | ||||
|                                 }}> | ||||
|                                   <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 "/> | ||||
|                               <button> | ||||
|                                 <XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " /> | ||||
|                               </button> | ||||
|                             </div> | ||||
|                           </div> | ||||
|                         </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" /> | ||||
|                           <span className="ml-1">Add another input</span> | ||||
|                         </button> | ||||
|  | @ -364,17 +446,27 @@ export default function EventType(props) { | |||
|                         <label htmlFor="ishidden" className="font-medium text-gray-700"> | ||||
|                           Hide this event type | ||||
|                         </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> | ||||
|                     <hr className="my-4"/> | ||||
|                   <hr className="my-4" /> | ||||
|                   <div> | ||||
|                     <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"> | ||||
|                         <Link href="/availability"><a className="mr-2 btn btn-white">Cancel</a></Link> | ||||
|                         <button type="submit" className="btn btn-primary">Update</button> | ||||
|                       <Link href="/availability"> | ||||
|                         <a className="mr-2 btn btn-white">Cancel</a> | ||||
|                       </Link> | ||||
|                       <button type="submit" className="btn btn-primary"> | ||||
|                         Update | ||||
|                       </button> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </form> | ||||
|  | @ -384,16 +476,15 @@ export default function EventType(props) { | |||
|           <div> | ||||
|             <div className="bg-white shadow sm:rounded-lg"> | ||||
|               <div className="px-4 py-5 sm:p-6"> | ||||
|                   <h3 className="text-lg mb-2 leading-6 font-medium text-gray-900"> | ||||
|                     Delete this event type | ||||
|                   </h3> | ||||
|                 <h3 className="text-lg mb-2 leading-6 font-medium text-gray-900">Delete this event type</h3> | ||||
|                 <div className="mb-4 max-w-xl text-sm text-gray-500"> | ||||
|                     <p> | ||||
|                       Once you delete this event type, it will be permanently removed. | ||||
|                     </p> | ||||
|                   <p>Once you delete this event type, it will be permanently removed.</p> | ||||
|                 </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 | ||||
|                   </button> | ||||
|                 </div> | ||||
|  | @ -401,12 +492,20 @@ export default function EventType(props) { | |||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|           {showLocationModal && | ||||
|           <div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> | ||||
|         {showLocationModal && ( | ||||
|           <div | ||||
|             className="fixed z-10 inset-0 overflow-y-auto" | ||||
|             aria-labelledby="modal-title" | ||||
|             role="dialog" | ||||
|             aria-modal="true"> | ||||
|             <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> | ||||
|               <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> | ||||
|               <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="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" /> | ||||
|                   </div> | ||||
|                   <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | ||||
|                     <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit location</h3> | ||||
|                     <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> | ||||
|                       Edit location | ||||
|                     </h3> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <form onSubmit={updateLocations}> | ||||
|  | @ -439,13 +540,22 @@ export default function EventType(props) { | |||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|           } | ||||
|           {showAddCustomModal && | ||||
|           <div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> | ||||
|         )} | ||||
|         {showAddCustomModal && ( | ||||
|           <div | ||||
|             className="fixed z-10 inset-0 overflow-y-auto" | ||||
|             aria-labelledby="modal-title" | ||||
|             role="dialog" | ||||
|             aria-modal="true"> | ||||
|             <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> | ||||
|                   <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"/> | ||||
|               <div | ||||
|                 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="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" /> | ||||
|                   </div> | ||||
|                   <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | ||||
|                               <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">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> | ||||
|                       <p className="text-sm text-gray-400"> | ||||
|                         This input will be shown when booking this event | ||||
|  | @ -463,7 +575,9 @@ export default function EventType(props) { | |||
|                 </div> | ||||
|                 <form onSubmit={updateCustom}> | ||||
|                   <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 | ||||
|                       name="type" | ||||
|                       defaultValue={selectedInputOption} | ||||
|  | @ -475,13 +589,27 @@ export default function EventType(props) { | |||
|                     /> | ||||
|                   </div> | ||||
|                   <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"> | ||||
|                                   <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 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"> | ||||
|                       Is required | ||||
|                     </label> | ||||
|  | @ -499,7 +627,7 @@ export default function EventType(props) { | |||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|           } | ||||
|         )} | ||||
|       </Shell> | ||||
|     </div> | ||||
|   ); | ||||
|  | @ -511,15 +639,16 @@ const validJson = (jsonString: string) => { | |||
|     if (o && typeof o === "object") { | ||||
|       return o; | ||||
|     } | ||||
|   } catch (e) { | ||||
|     // no longer empty
 | ||||
|   } | ||||
|   catch (e) {} | ||||
|   return false; | ||||
| } | ||||
| }; | ||||
| 
 | ||||
| export async function getServerSideProps(context) { | ||||
|   const session = await getSession(context); | ||||
|   if (!session) { | ||||
|       return { redirect: { permanent: false, destination: '/auth/login' } }; | ||||
|     return { redirect: { permanent: false, destination: "/auth/login" } }; | ||||
|   } | ||||
| 
 | ||||
|   const user = await prisma.user.findFirst({ | ||||
|  | @ -532,7 +661,7 @@ export async function getServerSideProps(context) { | |||
|       startTime: true, | ||||
|       endTime: true, | ||||
|       availability: true, | ||||
|     } | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   const eventType = await prisma.eventType.findUnique({ | ||||
|  | @ -549,8 +678,8 @@ export async function getServerSideProps(context) { | |||
|       locations: true, | ||||
|       eventName: true, | ||||
|       availability: true, | ||||
|       customInputs: true | ||||
|     } | ||||
|       customInputs: true, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   const credentials = await prisma.credential.findMany({ | ||||
|  | @ -560,75 +689,68 @@ export async function getServerSideProps(context) { | |||
|     select: { | ||||
|       id: true, | ||||
|       type: true, | ||||
|             key: true | ||||
|         } | ||||
|       key: true, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|     const integrations = [ { | ||||
|   const integrations = [ | ||||
|     { | ||||
|       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", | ||||
|       title: "Google Calendar", | ||||
|       imageSrc: "integrations/google-calendar.png", | ||||
|       description: "For personal and business accounts", | ||||
|     }, { | ||||
|     }, | ||||
|     { | ||||
|       installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), | ||||
|       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", | ||||
|       imageSrc: "integrations/office-365.png", | ||||
|       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) { | ||||
|         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) { | ||||
|     // TODO: Add default meeting option of the office integration.
 | ||||
|     // 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) { | ||||
|     return { | ||||
|       notFound: true, | ||||
|     } | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   const getAvailability = (providesAvailability) => ( | ||||
|   const getAvailability = (providesAvailability) => | ||||
|     providesAvailability.availability && providesAvailability.availability.length | ||||
|   ) ? providesAvailability.availability : null; | ||||
|       ? providesAvailability.availability | ||||
|       : null; | ||||
| 
 | ||||
|   const schedules = getAvailability(eventType) || getAvailability(user) || [ { | ||||
|     days: [ 1, 2, 3, 4, 5, 6, 7 ], | ||||
|   const schedules = getAvailability(eventType) || | ||||
|     getAvailability(user) || [ | ||||
|       { | ||||
|         days: [1, 2, 3, 4, 5, 6, 7], | ||||
|         startTime: user.startTime, | ||||
|         length: user.endTime >= 1440 ? 1439 : user.endTime, | ||||
|   } ]; | ||||
|       }, | ||||
|     ]; | ||||
| 
 | ||||
|   return { | ||||
|     props: { | ||||
|  | @ -637,5 +759,5 @@ export async function getServerSideProps(context) { | |||
|       schedules, | ||||
|       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", | ||||
|       "esnext" | ||||
|     ], | ||||
|     "baseUrl": ".", | ||||
|     "paths": { | ||||
|       "@components/*": ["components/*"], | ||||
|       "@lib/*": ["lib/*"] | ||||
|     }, | ||||
|     "allowJs": true, | ||||
|     "skipLibCheck": true, | ||||
|     "strict": false, | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Alex van Andel
						Alex van Andel