Final thing to check is timezones, currently if I am in Kuala Lumpur the time is correct, but it jumps 8 hours due to being out of bound on Seoul.

This commit is contained in:
Alex van Andel 2021-06-29 01:45:58 +00:00
parent b4272ad7aa
commit 575747bcd3
12 changed files with 413 additions and 361 deletions

View file

@ -1,6 +0,0 @@
import {Dayjs} from "dayjs";
interface Schedule {
startDate: Dayjs;
endDate: Dayjs;
}

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
import getSlots from "../../lib/slots";
import dayjs, {Dayjs} from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
dayjs.extend(isBetween);
@ -11,44 +11,43 @@ type Props = {
eventLength: number;
minimumBookingNotice?: number;
date: Dayjs;
}
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours }: Props) => {
};
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);
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
`/api/availability/${user}?dateFrom=${date.startOf("day").utc().startOf("day").format()}&dateTo=${date
.endOf("day")
.utc()
.endOf('day')
.endOf("day")
.format()}`
)
.then((res) => res.json())
.then(handleAvailableSlots)
.catch( e => {
.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;
@ -56,7 +55,6 @@ const Slots = ({ eventLength, minimumBookingNotice, date, workingHours }: Props)
// Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) {
busyTimes.forEach((busyTime) => {
const startTime = dayjs(busyTime.start).utc();
const endTime = dayjs(busyTime.end).utc();

View file

@ -3,56 +3,87 @@ import TimezoneSelect from "react-timezone-select";
import { TrashIcon } from "@heroicons/react/outline";
import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal";
import Schedule from "../../lib/schedule.model";
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);
export const Scheduler = (props) => {
const [schedules, setSchedules]: Schedule[] = useState(
props.schedules.map((schedule) => {
const startDate = schedule.isOverride
? dayjs(schedule.startDate)
: dayjs.utc().startOf("day").add(schedule.startTime, "minutes").tz(props.timeZone);
return {
days: schedule.days,
startDate,
endDate: startDate.add(schedule.length, "minutes"),
};
})
);
type Props = {
timeZone: string;
availability: Availability[];
setTimeZone: unknown;
};
const [timeZone, setTimeZone] = useState(props.timeZone);
export const Scheduler = ({
availability,
setAvailability,
timeZone: selectedTimeZone,
setTimeZone,
}: Props) => {
const [editSchedule, setEditSchedule] = useState(-1);
const [dateOverrides, setDateOverrides] = useState([]);
const [openingHours, setOpeningHours] = useState([]);
useEffect(() => {
props.onChange(schedules);
}, [schedules]);
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));
}, []);
const addNewSchedule = () => setEditSchedule(schedules.length);
// updates availability to how it should be formatted outside this component.
useEffect(() => {
setAvailability({
dateOverrides: dateOverrides,
openingHours: openingHours,
});
}, [dateOverrides, openingHours]);
const applyEditSchedule = (changed: Schedule) => {
const replaceWith = {
...schedules[editSchedule],
...changed,
};
const addNewSchedule = () => setEditSchedule(openingHours.length);
schedules.splice(editSchedule, 1, replaceWith);
const applyEditSchedule = (changed) => {
if (!changed.days) {
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
}
setSchedules([].concat(schedules));
const replaceWith = { ...openingHours[editSchedule], ...changed };
openingHours.splice(editSchedule, 1, replaceWith);
setOpeningHours([].concat(openingHours));
};
const removeScheduleAt = (toRemove: number) => {
schedules.splice(toRemove, 1);
setSchedules([].concat(schedules));
openingHours.splice(toRemove, 1);
setOpeningHours([].concat(openingHours));
};
const setWeekdays = (idx: number, days: number[]) => {
schedules[idx].days = days;
setSchedules([].concat(schedules));
};
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(item.startDate).format(item.startDate.minute() === 0 ? "ha" : "h:mma")}
&nbsp;until&nbsp;
{dayjs(item.endDate).format(item.endDate.minute() === 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>
);
console.log(selectedTimeZone);
return (
<div>
@ -65,32 +96,15 @@ export const Scheduler = (props) => {
<div className="mt-1">
<TimezoneSelect
id="timeZone"
value={timeZone}
onChange={setTimeZone}
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>
{schedules.map((schedule, idx) => (
<li key={idx} className="py-2 flex justify-between border-t">
<div className="inline-flex ml-2">
<WeekdaySelect
defaultValue={schedules[idx].days}
onSelect={(days: number[]) => setWeekdays(idx, days)}
/>
<button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}>
{dayjs(schedule.startDate).format(schedule.startDate.minute() === 0 ? "ha" : "h:mma")}{" "}
until {dayjs(schedule.endDate).format(schedule.endDate.minute() === 0 ? "ha" : "h:mma")}
</button>
</div>
<button
type="button"
onClick={() => removeScheduleAt(idx)}
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>
{openingHours.map((item, idx) => (
<OpeningHours key={idx} idx={idx} item={item} />
))}
</ul>
<hr />
@ -108,7 +122,7 @@ export const Scheduler = (props) => {
</div>
{editSchedule >= 0 && (
<SetTimesModal
schedule={schedules[editSchedule]}
schedule={{ ...openingHours[editSchedule], timeZone: selectedTimeZone }}
onChange={applyEditSchedule}
onExit={() => setEditSchedule(-1)}
/>

View file

@ -7,9 +7,15 @@ dayjs.extend(utc);
dayjs.extend(timezone);
export default function SetTimesModal(props) {
const { startDate, endDate } = props.schedule || {
startDate: dayjs.utc().startOf("day").add(540, "minutes"),
endDate: dayjs.utc().startOf("day").add(1020, "minutes"),
const { startDate, endDate } = {
startDate: dayjs
.utc()
.startOf("day")
.add(props.schedule.startTime || 540, "minutes"),
endDate: dayjs
.utc()
.startOf("day")
.add(props.schedule.endTime || 1020, "minutes"),
};
startDate.tz(props.timeZone);

11
lib/jsonUtils.ts Normal file
View 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;
};

View file

@ -1,7 +0,0 @@
import {Dayjs} from "dayjs";
export default interface Schedule {
id: number | null;
startDate: Dayjs;
endDate: Dayjs;
}

View file

@ -1,12 +1,15 @@
import dayjs, {Dayjs} from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);
interface GetSlotsType {
inviteeDate: Dayjs;
frequency: number;
workingHours: [];
minimumBookingNotice?: number;
organizerUtcOffset: number;
}
interface Boundary {
@ -17,32 +20,41 @@ interface Boundary {
const freqApply: number = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency;
const intersectBoundary = (a: Boundary, b: Boundary) => {
if (
a.upperBound < b.lowerBound || a.lowerBound > b.upperBound
) {
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)
upperBound: Math.min(b.upperBound, a.upperBound),
};
}
};
// 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);
const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
const organizerBoundaries = (workingHours: [], inviteeDate: Dayjs, inviteeBounds: Boundary): Boundary[] => {
const organizerBoundaries = (
workingHours: [],
inviteeDate: Dayjs,
inviteeBounds: Boundary,
organizerTimeZone
): Boundary[] => {
const boundaries: Boundary[] = [];
const startDay: number = +inviteeDate.utc().startOf('day').add(inviteeBounds.lowerBound, 'minutes').format('d');
const endDay: number = +inviteeDate.utc().startOf('day').add(inviteeBounds.upperBound, 'minutes').format('d');
const startDay: number = +inviteeDate
.utc()
.startOf("day")
.add(inviteeBounds.lowerBound, "minutes")
.format("d");
const endDay: number = +inviteeDate
.utc()
.startOf("day")
.add(inviteeBounds.upperBound, "minutes")
.format("d");
workingHours.forEach( (item) => {
const lowerBound: number = item.startTime;
const upperBound: number = lowerBound + item.length;
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
@ -62,7 +74,7 @@ const organizerBoundaries = (workingHours: [], inviteeDate: Dayjs, inviteeBounds
}
}
} else {
boundaries.push({lowerBound, upperBound});
boundaries.push({ lowerBound, upperBound });
}
});
return boundaries;
@ -72,38 +84,42 @@ const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number
const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency);
const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency);
return {
lowerBound, upperBound,
lowerBound,
upperBound,
};
};
const getSlotsBetweenBoundary = (frequency: number, {lowerBound,upperBound}: Boundary) => {
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'));
for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
slots.push(
<Dayjs>dayjs
.utc()
.startOf("day")
.add(lowerBound + minutes, "minutes")
);
}
return slots;
};
const getSlots = (
{ inviteeDate, frequency, minimumBookingNotice, workingHours, }: GetSlotsType
): Dayjs[] => {
const startTime = (
dayjs.utc().isSame(dayjs(inviteeDate), 'day') ? inviteeDate.hour() * 60 + inviteeDate.minute() + minimumBookingNotice : 0
);
const getSlots = ({
inviteeDate,
frequency,
minimumBookingNotice,
workingHours,
organizerTimeZone,
}: GetSlotsType): Dayjs[] => {
const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
? inviteeDate.hour() * 60 + inviteeDate.minute() + minimumBookingNotice
: 0;
const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
return getOverlaps(
inviteeBounds, organizerBoundaries(workingHours, inviteeDate, inviteeBounds)
).reduce(
(slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary) ], []
).map(
(slot) => slot.utcOffset(dayjs(inviteeDate).utcOffset())
inviteeBounds,
organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone)
)
}
.reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
.map((slot) => slot.utcOffset(dayjs(inviteeDate).utcOffset()));
};
export default getSlots;

View file

@ -32,6 +32,7 @@ export default function Type(props): Type {
frequency: props.eventType.length,
inviteeDate: dayjs.utc(today) as Dayjs,
workingHours: props.workingHours,
organizerTimeZone: props.eventType.timeZone,
minimumBookingNotice: 0,
}).length === 0,
[today, props.eventType.length, props.workingHours]
@ -63,21 +64,46 @@ export default function Type(props): Type {
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
Calendso
</title>
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
<meta name="description" content={props.eventType.description} />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://calendso/" />
<meta property="og:title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}/>
<meta property="og:description" content={props.eventType.description}/>
<meta property="og:image" content={"https://og-image-one-pi.vercel.app/" + encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} />
<meta
property="og:title"
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
/>
<meta property="og:description" content={props.eventType.description} />
<meta
property="og:image"
content={
"https://og-image-one-pi.vercel.app/" +
encodeURIComponent(
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
).replace(/'/g, "%27") +
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
encodeURIComponent(props.user.avatar)
}
/>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://calendso/" />
<meta property="twitter:title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
<meta
property="twitter:title"
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
/>
<meta property="twitter:description" content={props.eventType.description} />
<meta property="twitter:image" content={"https://og-image-one-pi.vercel.app/" + encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} />
<meta
property="twitter:image"
content={
"https://og-image-one-pi.vercel.app/" +
encodeURIComponent(
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
).replace(/'/g, "%27") +
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
encodeURIComponent(props.user.avatar)
}
/>
</Head>
<main
className={
@ -184,6 +210,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
description: true,
length: true,
availability: true,
timeZone: true,
},
});

View file

@ -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" });
}
}

View file

@ -1,69 +0,0 @@
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});
if (!session) {
res.status(401).json({message: "Not authenticated"});
return;
}
if (req.method == "PUT") {
const openingHours = req.body.openingHours || [];
const overrides = req.body.overrides || [];
const removeSchedule = await prisma.schedule.deleteMany({
where: {
eventTypeId: +req.query.eventtype,
}
})
const updateSchedule = Promise.all(openingHours.map( (schedule) => prisma.schedule.create({
data: {
eventTypeId: +req.query.eventtype,
days: schedule.days,
startTime: schedule.startTime,
length: schedule.endTime - schedule.startTime,
},
})))
.catch( (error) => {
console.log(error);
})
}
res.status(200).json({message: 'Created schedule'});
/*if (req.method == "PATCH") {
const openingHours = req.body.openingHours || [];
const overrides = req.body.overrides || [];
openingHours.forEach( (schedule) => {
const updateSchedule = await prisma.schedule.update({
where: {
id: req.body.id,
},
data: {
eventTypeId: req.query.eventtype,
days: req.body.days,
startTime: 333,
endTime: 540 - req.body.startTime,
},
});
});
overrides.forEach( (schedule) => {
const updateSchedule = await prisma.schedule.update({
where: {
id: req.body.id,
},
data: {
eventTypeId: req.query.eventtype,
startDate: req.body.startDate,
length: 540,
},
});
});*/
}

View file

@ -1,26 +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 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 { Scheduler } from "@components/ui/Scheduler";
import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline";
import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput";
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
import { PlusIcon } from "@heroicons/react/solid";
import 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);
export default function EventType(props: any): JSX.Element {
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[] = [
@ -30,17 +70,17 @@ export default function EventType(props: any): JSX.Element {
{ value: EventTypeCustomInputType.Bool, label: "Checkbox" },
];
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(props.eventType.locations || []);
const [schedule, setSchedule] = useState(undefined);
const [locations, setLocations] = useState(eventType.locations || []);
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
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>();
@ -49,60 +89,55 @@ export default function EventType(props: any): JSX.Element {
const isHiddenRef = useRef<HTMLInputElement>();
const eventNameRef = useRef<HTMLInputElement>();
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 = {
dateOverrides: [],
openingHours: enteredAvailability.openingHours.map((item): OpeningHours => {
item.startTime = item.startDate.hour() * 60 + item.startDate.minute();
delete item.startDate;
item.endTime = item.endDate.hour() * 60 + item.endDate.minute();
delete item.endDate;
return item;
}),
};
}
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",
},
});
if (schedule) {
const schedulePayload = { overrides: [], timeZone: props.user.timeZone, openingHours: [] };
schedule.forEach((item) => {
if (item.isOverride) {
delete item.isOverride;
schedulePayload.overrides.push(item);
} else {
const endTime = item.endDate.hour() * 60 + item.endDate.minute() || 1440; // also handles 00:00
schedulePayload.openingHours.push({
days: item.days,
startTime: item.startDate.hour() * 60 + item.startDate.minute() - item.startDate.utcOffset(),
endTime: endTime - item.endDate.utcOffset(),
});
}
});
await fetch("/api/availability/schedule/" + props.eventType.id, {
method: "PUT",
body: JSON.stringify(schedulePayload),
headers: {
"Content-Type": "application/json",
},
});
}
router.push("/availability");
}
@ -111,7 +146,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",
},
@ -237,10 +272,10 @@ 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}>
<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">
@ -259,7 +294,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>
@ -270,7 +305,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">
{typeof location !== "undefined" ? location.hostname : ""}/{props.user.username}/
{typeof location !== "undefined" ? location.hostname : ""}/{user.username}/
</span>
<input
ref={slugRef}
@ -279,7 +314,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>
@ -420,7 +455,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">
@ -436,7 +471,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
@ -455,7 +490,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>
@ -514,7 +549,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">
@ -531,9 +566,10 @@ export default function EventType(props: any): JSX.Element {
<div>
<h3 className="mb-2">How do you want to offer your availability for this event type?</h3>
<Scheduler
onChange={setSchedule}
timeZone={props.user.timeZone}
schedules={props.schedules}
setAvailability={setEnteredAvailability}
setTimeZone={setSelectedTimeZone}
timeZone={selectedTimeZone}
availability={availability}
/>
<div className="py-4 flex justify-end">
<Link href="/availability">
@ -709,24 +745,18 @@ 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,
},
@ -739,9 +769,9 @@ export async function getServerSideProps(context) {
},
});
const eventType = await prisma.eventType.findUnique({
const eventType: EventType | null = await prisma.eventType.findUnique({
where: {
id: parseInt(context.query.type),
id: parseInt(query.type as string),
},
select: {
id: true,
@ -754,9 +784,16 @@ export async function getServerSideProps(context) {
eventName: true,
availability: true,
customInputs: true,
timeZone: true,
},
});
if (!eventType) {
return {
notFound: true,
};
}
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
@ -808,18 +845,12 @@ export async function getServerSideProps(context) {
// Assuming it's Microsoft Teams.
}
if (!eventType) {
return {
notFound: true,
};
}
const getAvailability = (providesAvailability) =>
providesAvailability.availability && providesAvailability.availability.length
? providesAvailability.availability
: null;
const schedules = getAvailability(eventType) ||
const availability: Availability[] = getAvailability(eventType) ||
getAvailability(user) || [
{
days: [0, 1, 2, 3, 4, 5, 6],
@ -832,8 +863,8 @@ export async function getServerSideProps(context) {
props: {
user,
eventType,
schedules,
locationOptions,
availability,
},
};
}
};

View file

@ -24,6 +24,7 @@ model EventType {
availability Availability[]
eventName String?
customInputs EventTypeCustomInput[]
timeZone String?
}
model Credential {