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