290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
import {useEffect, useState, useMemo} from 'react';
|
|
import Head from 'next/head';
|
|
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';
|
|
dayjs.extend(isSameOrBefore);
|
|
dayjs.extend(isBetween);
|
|
dayjs.extend(utc);
|
|
dayjs.extend(timezone);
|
|
|
|
import getSlots from '../../lib/slots';
|
|
import {useTelemetry} from "../../lib/telemetry";
|
|
|
|
function classNames(...classes) {
|
|
return classes.filter(Boolean).join(' ')
|
|
}
|
|
|
|
export default function Type(props) {
|
|
// Initialise state
|
|
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
|
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('');
|
|
|
|
function toggleTimeOptions() {
|
|
setIsTimeOptionsOpen(!isTimeOptionsOpen);
|
|
}
|
|
|
|
useEffect(() => {
|
|
// Setting timezone only client-side
|
|
setSelectedTimeZone(dayjs.tz.guess())
|
|
}, [])
|
|
|
|
|
|
// Get router variables
|
|
const router = useRouter();
|
|
const { user } = 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
|
|
const weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
|
|
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
|
|
const calendar = [...emptyDays, ...days.map((day) =>
|
|
<button key={day} onClick={(e) => {
|
|
telemetry.withJitsu((jitsu) => jitsu.track('date_selected', {page_title: "", source_ip: ""}))
|
|
setSelectedDate(dayjs().tz(selectedTimeZone).month(selectedMonth).date(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>
|
|
)];
|
|
|
|
// Handle date change and timezone change
|
|
useEffect(() => {
|
|
const changeDate = async () => {
|
|
if (!selectedDate) {
|
|
return
|
|
}
|
|
|
|
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]);
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Display available times
|
|
const availableTimes = times.map((time) =>
|
|
<div key={dayjs(time).utc().format()}>
|
|
<Link href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}`}>
|
|
<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(selectedTimeZone).format(is24h ? "HH:mm" : "hh:mma")}</a>
|
|
</Link>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<Head>
|
|
<title>{props.eventType.title} | {props.user.name || props.user.username} | Calendso</title>
|
|
<link rel="icon" href="/favicon.ico" />
|
|
</Head>
|
|
|
|
<main className={"mx-auto my-24 transition-max-width ease-in-out duration-500 " + (selectedDate ? 'max-w-6xl' : 'max-w-3xl')}>
|
|
<div className="bg-white shadow rounded-lg">
|
|
<div className="sm:flex px-4 py-5 sm:p-4">
|
|
<div className={"pr-8 sm:border-r " + (selectedDate ? 'sm:w-1/3' : 'sm:w-1/2')}>
|
|
{props.user.avatar && <img src={props.user.avatar} alt="Avatar" className="w-16 h-16 rounded-full mb-4"/>}
|
|
<h2 className="font-medium text-gray-500">{props.user.name}</h2>
|
|
<h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1>
|
|
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
|
{props.eventType.length} minutes
|
|
</p>
|
|
<button onClick={toggleTimeOptions} className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
|
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
|
{selectedTimeZone}
|
|
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
|
</button>
|
|
{isTimeOptionsOpen &&
|
|
<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={is24h}
|
|
onChange={setIs24h}
|
|
className={classNames(
|
|
is24h ? '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(
|
|
is24h ? '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={({ value }) =>setSelectedTimeZone(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>
|
|
}
|
|
<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">
|
|
<div className="uppercase text-gray-400 text-xs tracking-widest">Sun</div>
|
|
<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>
|
|
{calendar}
|
|
</div>
|
|
</div>
|
|
{selectedDate && <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">{dayjs(selectedDate).format("dddd DD MMMM YYYY")}</span>
|
|
</div>
|
|
{!loading ? availableTimes : <div className="loader"></div>}
|
|
</div>}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export async function getServerSideProps(context) {
|
|
const user = await prisma.user.findFirst({
|
|
where: {
|
|
username: context.query.user,
|
|
},
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
name: true,
|
|
bio: true,
|
|
avatar: true,
|
|
eventTypes: true,
|
|
startTime: true,
|
|
timeZone: true,
|
|
endTime: true
|
|
}
|
|
});
|
|
|
|
const eventType = await prisma.eventType.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
slug: {
|
|
equals: context.query.type,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
description: true,
|
|
length: true
|
|
}
|
|
});
|
|
|
|
return {
|
|
props: {
|
|
user,
|
|
eventType
|
|
},
|
|
}
|
|
}
|