diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx new file mode 100644 index 00000000..3acc07a2 --- /dev/null +++ b/components/booking/AvailableTimes.tsx @@ -0,0 +1,89 @@ +import dayjs, {Dayjs} from "dayjs"; +import isBetween from 'dayjs/plugin/isBetween'; +dayjs.extend(isBetween); +import {useEffect, useMemo, useState} from "react"; +import getSlots from "../../lib/slots"; +import Link from "next/link"; +import {timeZone} from "../../lib/clock"; +import {useRouter} from "next/router"; + +const AvailableTimes = (props) => { + + const router = useRouter(); + const { user, rescheduleUid } = router.query; + const [loaded, setLoaded] = useState(false); + + const times = useMemo(() => + getSlots({ + calendarTimeZone: props.user.timeZone, + selectedTimeZone: timeZone(), + eventLength: props.eventType.length, + selectedDate: props.date, + dayStartTime: props.user.startTime, + dayEndTime: props.user.endTime, + }) + , []) + + const handleAvailableSlots = (busyTimes: []) => { + // Check for conflicts + for (let i = times.length - 1; i >= 0; i -= 1) { + busyTimes.forEach(busyTime => { + let startTime = dayjs(busyTime.start); + let endTime = dayjs(busyTime.end); + + // Check if start times are the same + if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) { + times.splice(i, 1); + } + + // Check if time is between start and end times + if (dayjs(times[i]).isBetween(startTime, endTime)) { + times.splice(i, 1); + } + + // Check if slot end time is between start and end time + if (dayjs(times[i]).add(props.eventType.length, 'minutes').isBetween(startTime, endTime)) { + times.splice(i, 1); + } + + // Check if startTime is between slot + if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, 'minutes'))) { + times.splice(i, 1); + } + }); + } + // Display available times + setLoaded(true); + }; + + // Re-render only when invitee changes date + useEffect(() => { + setLoaded(false); + fetch(`/api/availability/${user}?dateFrom=${props.date.startOf('day').utc().format()}&dateTo=${props.date.endOf('day').utc().format()}`) + .then( res => res.json()) + .then(handleAvailableSlots); + }, [props.date]); + + return ( +
+
+ + {props.date.format("dddd DD MMMM YYYY")} + +
+ { + loaded ? times.map((time) => +
+ + {dayjs(time).tz(timeZone()).format(props.timeFormat)} + +
+ ) :
+ } +
+ ); +} + +export default AvailableTimes; \ No newline at end of file diff --git a/components/booking/TimeOptions.tsx b/components/booking/TimeOptions.tsx new file mode 100644 index 00000000..38aafd8b --- /dev/null +++ b/components/booking/TimeOptions.tsx @@ -0,0 +1,73 @@ +import {Switch} from "@headlessui/react"; +import TimezoneSelect from "react-timezone-select"; +import {useEffect, useState} from "react"; +import {timeZone, is24h} from '../../lib/clock'; + +function classNames(...classes) { + return classes.filter(Boolean).join(' ') +} + +const TimeOptions = (props) => { + + const [selectedTimeZone, setSelectedTimeZone] = useState(''); + const [is24hClock, setIs24hClock] = useState(false); + + useEffect( () => { + setIs24hClock(is24h()); + setSelectedTimeZone(timeZone()); + }, []); + + useEffect( () => { + props.onSelectTimeZone(timeZone(selectedTimeZone)); + }, [selectedTimeZone]); + + useEffect( () => { + props.onToggle24hClock(is24h(is24hClock)); + }, [is24hClock]); + + return selectedTimeZone !== "" && ( +
+
+
Time Options
+
+ + + am/pm + + + Use setting + + + 24h + + +
+
+ setSelectedTimeZone(tz.value)} + className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + /> +
+ ); +} + +export default TimeOptions; \ No newline at end of file diff --git a/lib/clock.ts b/lib/clock.ts new file mode 100644 index 00000000..2b065cf4 --- /dev/null +++ b/lib/clock.ts @@ -0,0 +1,43 @@ +// handles logic related to user clock display using 24h display / timeZone options. +import dayjs, {Dayjs} from 'dayjs'; + +interface TimeOptions { is24hClock: boolean, inviteeTimeZone: string }; + +const timeOptions: TimeOptions = { + is24hClock: false, + inviteeTimeZone: '', +} + +const isInitialized: boolean = false; + +const initClock = () => { + if (typeof localStorage === "undefined" || isInitialized) { + return; + } + timeOptions.is24hClock = localStorage.getItem('timeOption.is24hClock') === "true"; + timeOptions.inviteeTimeZone = localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess(); +} + +const is24h = (is24hClock?: boolean) => { + initClock(); + if(typeof is24hClock !== "undefined") set24hClock(is24hClock); + return timeOptions.is24hClock; +} + +const set24hClock = (is24hClock: boolean) => { + localStorage.setItem('timeOption.is24hClock', is24hClock.toString()); + timeOptions.is24hClock = is24hClock; +} + +function setTimeZone(selectedTimeZone: string) { + localStorage.setItem('timeOption.preferredTimeZone', selectedTimeZone); + timeOptions.inviteeTimeZone = selectedTimeZone; +} + +const timeZone = (selectedTimeZone?: string) => { + initClock(); + if (selectedTimeZone) setTimeZone(selectedTimeZone) + return timeOptions.inviteeTimeZone; +} + +export {is24h, timeZone}; \ No newline at end of file diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index aa57bd48..1a9d7c47 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -4,369 +4,221 @@ import Link from 'next/link'; import prisma from '../../lib/prisma'; import { useRouter } from 'next/router'; import dayjs, { Dayjs } from 'dayjs'; -import { Switch } from '@headlessui/react'; -import TimezoneSelect from 'react-timezone-select'; import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; -import isBetween from 'dayjs/plugin/isBetween'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; -import Avatar from '../../components/Avatar'; dayjs.extend(isSameOrBefore); -dayjs.extend(isBetween); dayjs.extend(utc); dayjs.extend(timezone); -import getSlots from '../../lib/slots'; import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; - -function classNames(...classes) { - return classes.filter(Boolean).join(' ') -} +import AvailableTimes from "../../components/booking/AvailableTimes"; +import TimeOptions from "../../components/booking/TimeOptions" +import Avatar from '../../components/Avatar'; +import {timeZone} from "../../lib/clock"; export default function Type(props) { - // Initialise state - const [selectedDate, setSelectedDate] = useState(); - const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); - const [loading, setLoading] = useState(false); - const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); - const [is24h, setIs24h] = useState(false); - const [busy, setBusy] = useState([]); - const telemetry = useTelemetry(); - const [selectedTimeZone, setSelectedTimeZone] = useState(''); + // Get router variables + const router = useRouter(); + const { rescheduleUid } = router.query; - function toggleTimeOptions() { - setIsTimeOptionsOpen(!isTimeOptionsOpen); - } - - function toggleClockSticky() { - localStorage.setItem('timeOption.is24hClock', (!is24h).toString()); - setIs24h(!is24h); - } - - function setPreferredTimeZoneSticky({ value }: string) { - localStorage.setItem('timeOption.preferredTimeZone', value); - setSelectedTimeZone(value); - } - - function initializeTimeOptions() { - setSelectedTimeZone(localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess()); - setIs24h(!!localStorage.getItem('timeOption.is24hClock')); - } + // Initialise state + const [selectedDate, setSelectedDate] = useState(); + const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); + const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); + const [timeFormat, setTimeFormat] = useState('hh:mm'); + const telemetry = useTelemetry(); useEffect(() => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())) }, []); - // Handle date change and timezone change - useEffect(() => { + // Handle month changes + const incrementMonth = () => { + setSelectedMonth(selectedMonth + 1); + } - if ( ! selectedTimeZone ) { - initializeTimeOptions(); - } + const decrementMonth = () => { + setSelectedMonth(selectedMonth - 1); + } - const changeDate = async () => { - if (!selectedDate) { - return - } + // Set up calendar + var daysInMonth = dayjs().month(selectedMonth).daysInMonth(); + var days = []; + for (let i = 1; i <= daysInMonth; i++) { + days.push(i); + } - setLoading(true); - const res = await fetch(`/api/availability/${user}?dateFrom=${lowerBound.utc().format()}&dateTo=${upperBound.utc().format()}`); - const busyTimes = await res.json(); - if (busyTimes.length > 0) setBusy(busyTimes); - setLoading(false); - } - changeDate(); - }, [selectedDate, selectedTimeZone]); - - // Get router variables - const router = useRouter(); - const { user, rescheduleUid } = router.query; - - // Handle month changes - const incrementMonth = () => { - setSelectedMonth(selectedMonth + 1); - } - - const decrementMonth = () => { - setSelectedMonth(selectedMonth - 1); - } - - // Need to define the bounds of the 24-hour window - const lowerBound = useMemo(() => { - if(!selectedDate) { - return - } - - return selectedDate.startOf('day') - }, [selectedDate]) - - const upperBound = useMemo(() => { - if(!selectedDate) return - - return selectedDate.endOf('day') - }, [selectedDate]) - - // Set up calendar - var daysInMonth = dayjs().month(selectedMonth).daysInMonth(); - var days = []; - for (let i = 1; i <= daysInMonth; i++) { - days.push(i); - } - - // Create placeholder elements for empty days in first week - let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); - if (props.user.weekStart === 'Monday') { - weekdayOfFirst -= 1; - if (weekdayOfFirst < 0) - weekdayOfFirst = 6; - } - const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) => -
- {null} -
- ); - - // Combine placeholder days with actual days - const calendar = [...emptyDays, ...days.map((day) => - - )]; - - const times = useMemo(() => - getSlots({ - calendarTimeZone: props.user.timeZone, - selectedTimeZone: selectedTimeZone, - eventLength: props.eventType.length, - selectedDate: selectedDate, - dayStartTime: props.user.startTime, - dayEndTime: props.user.endTime, - }) - , [selectedDate, selectedTimeZone]) - - // Check for conflicts - for(let i = times.length - 1; i >= 0; i -= 1) { - busy.forEach(busyTime => { - let startTime = dayjs(busyTime.start); - let endTime = dayjs(busyTime.end); - - // Check if start times are the same - if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) { - times.splice(i, 1); - } - - // Check if time is between start and end times - if (dayjs(times[i]).isBetween(startTime, endTime)) { - times.splice(i, 1); - } - - // Check if slot end time is between start and end time - if (dayjs(times[i]).add(props.eventType.length, 'minutes').isBetween(startTime, endTime)) { - times.splice(i, 1); - } - - // Check if startTime is between slot - if(startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, 'minutes'))) { - times.splice(i, 1); - } - }); - } - - // Display available times - const availableTimes = times.map((time) => -
- - {dayjs(time).tz(selectedTimeZone).format(is24h ? "HH:mm" : "hh:mma")} - -
- ); - - return ( -
- - - {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} | - Calendso - - - -
-
-
-
- -

{props.user.name}

-

- {props.eventType.title} -

-

- - {props.eventType.length} minutes -

- - {isTimeOptionsOpen && ( -
-
-
Time Options
-
- - - am/pm - - - Use setting - - - 24h - - -
-
- -
- )} -

- {props.eventType.description} -

-
-
-
- - {dayjs().month(selectedMonth).format("MMMM YYYY")} - -
- - -
-
-
- {props.user.weekStart !== 'Monday' ? ( -
- Sun -
- ) : null} -
- Mon -
-
- Tue -
-
- Wed -
-
- Thu -
-
- Fri -
-
- Sat -
- {props.user.weekStart === 'Monday' ? ( -
- Sun -
- ) : null} - {calendar} -
-
- {selectedDate && ( -
-
- - {dayjs(selectedDate).format("dddd DD MMMM YYYY")} - -
- {!loading ? availableTimes :
} -
- )} -
-
- {/* note(peer): - you can remove calendso branding here, but we'd also appreciate it, if you don't <3 - */} -
- - - powered by{" "} - Calendso Logo - - -
-
+ // Create placeholder elements for empty days in first week + let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); + if (props.user.weekStart === 'Monday') { + weekdayOfFirst -= 1; + if (weekdayOfFirst < 0) + weekdayOfFirst = 6; + } + const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) => +
+ {null}
- ); + ); + + const changeDate = (day) => { + telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())) + setSelectedDate(dayjs().month(selectedMonth).date(day)) + }; + + // Combine placeholder days with actual days + const calendar = [...emptyDays, ...days.map((day) => + + )]; + + const handleSelectTimeZone = (selectedTimeZone: string) => { + if (selectedDate) { + setSelectedDate(selectedDate.tz(selectedTimeZone)) + } + }; + + const handleToggle24hClock = (is24hClock: boolean) => { + if (selectedDate) { + setTimeFormat(is24hClock ? 'HH:mm' : 'h:mma'); + } + } + + return ( +
+ + + {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} | + Calendso + + + +
+
+
+
+ +

{props.user.name}

+

+ {props.eventType.title} +

+

+ + {props.eventType.length} minutes +

+ + { isTimeOptionsOpen && } +

+ {props.eventType.description} +

+
+
+
+ + {dayjs().month(selectedMonth).format("MMMM YYYY")} + +
+ + +
+
+
+ {props.user.weekStart !== 'Monday' ? ( +
+ Sun +
+ ) : null} +
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+ {props.user.weekStart === 'Monday' ? ( +
+ Sun +
+ ) : null} + {calendar} +
+
+ {selectedDate && } +
+
+ {/* note(peer): + you can remove calendso branding here, but we'd also appreciate it, if you don't <3 + */} + +
+
+ ); } export async function getServerSideProps(context) { @@ -389,7 +241,7 @@ export async function getServerSideProps(context) { } }); - if (!user ) { + if (!user) { return { notFound: true, } @@ -412,7 +264,7 @@ export async function getServerSideProps(context) { if (!eventType) { return { - notFound: true + notFound: true, } }