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 { useState, useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import getSlots from "../../lib/slots"; import getSlots from "../../lib/slots";
import dayjs, {Dayjs} from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween"; import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
dayjs.extend(isBetween); dayjs.extend(isBetween);
@ -11,44 +11,43 @@ type Props = {
eventLength: number; eventLength: number;
minimumBookingNotice?: number; minimumBookingNotice?: number;
date: Dayjs; date: Dayjs;
} };
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours }: Props) => {
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerUtcOffset }: Props) => {
minimumBookingNotice = minimumBookingNotice || 0; minimumBookingNotice = minimumBookingNotice || 0;
const router = useRouter(); const router = useRouter();
const { user } = router.query; const { user } = router.query;
const [slots, setSlots] = useState([]); const [slots, setSlots] = useState([]);
const [isFullyBooked, setIsFullyBooked ] = useState(false); const [isFullyBooked, setIsFullyBooked] = useState(false);
const [hasErrors, setHasErrors ] = useState(false); const [hasErrors, setHasErrors] = useState(false);
useEffect(() => { useEffect(() => {
setSlots([]); setSlots([]);
setIsFullyBooked(false); setIsFullyBooked(false);
setHasErrors(false); setHasErrors(false);
fetch( 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") .endOf("day")
.utc() .utc()
.endOf('day') .endOf("day")
.format()}` .format()}`
) )
.then((res) => res.json()) .then((res) => res.json())
.then(handleAvailableSlots) .then(handleAvailableSlots)
.catch( e => { .catch((e) => {
console.error(e); console.error(e);
setHasErrors(true); setHasErrors(true);
}) });
}, [date]); }, [date]);
const handleAvailableSlots = (busyTimes: []) => { const handleAvailableSlots = (busyTimes: []) => {
const times = getSlots({ const times = getSlots({
frequency: eventLength, frequency: eventLength,
inviteeDate: date, inviteeDate: date,
workingHours, workingHours,
minimumBookingNotice, minimumBookingNotice,
organizerUtcOffset,
}); });
const timesLengthBeforeConflicts: number = times.length; const timesLengthBeforeConflicts: number = times.length;
@ -56,7 +55,6 @@ const Slots = ({ eventLength, minimumBookingNotice, date, workingHours }: Props)
// Check for conflicts // Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) { for (let i = times.length - 1; i >= 0; i -= 1) {
busyTimes.forEach((busyTime) => { busyTimes.forEach((busyTime) => {
const startTime = dayjs(busyTime.start).utc(); const startTime = dayjs(busyTime.start).utc();
const endTime = dayjs(busyTime.end).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 { TrashIcon } from "@heroicons/react/outline";
import { WeekdaySelect } from "./WeekdaySelect"; import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal"; import SetTimesModal from "./modal/SetTimesModal";
import Schedule from "../../lib/schedule.model";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import { Availability } from "@prisma/client";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export const Scheduler = (props) => { type Props = {
const [schedules, setSchedules]: Schedule[] = useState( timeZone: string;
props.schedules.map((schedule) => { availability: Availability[];
const startDate = schedule.isOverride setTimeZone: unknown;
? 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"),
};
})
);
const [timeZone, setTimeZone] = useState(props.timeZone); export const Scheduler = ({
availability,
setAvailability,
timeZone: selectedTimeZone,
setTimeZone,
}: Props) => {
const [editSchedule, setEditSchedule] = useState(-1); const [editSchedule, setEditSchedule] = useState(-1);
const [dateOverrides, setDateOverrides] = useState([]);
const [openingHours, setOpeningHours] = useState([]);
useEffect(() => { useEffect(() => {
props.onChange(schedules); setOpeningHours(
}, [schedules]); 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 addNewSchedule = () => setEditSchedule(openingHours.length);
const replaceWith = {
...schedules[editSchedule],
...changed,
};
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) => { const removeScheduleAt = (toRemove: number) => {
schedules.splice(toRemove, 1); openingHours.splice(toRemove, 1);
setSchedules([].concat(schedules)); setOpeningHours([].concat(openingHours));
}; };
const setWeekdays = (idx: number, days: number[]) => { const OpeningHours = ({ idx, item }) => (
schedules[idx].days = days; <li className="py-2 flex justify-between border-t">
setSchedules([].concat(schedules)); <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 ( return (
<div> <div>
@ -65,32 +96,15 @@ export const Scheduler = (props) => {
<div className="mt-1"> <div className="mt-1">
<TimezoneSelect <TimezoneSelect
id="timeZone" id="timeZone"
value={timeZone} value={selectedTimeZone}
onChange={setTimeZone} 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" 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>
</div> </div>
<ul> <ul>
{schedules.map((schedule, idx) => ( {openingHours.map((item, idx) => (
<li key={idx} className="py-2 flex justify-between border-t"> <OpeningHours key={idx} idx={idx} item={item} />
<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>
))} ))}
</ul> </ul>
<hr /> <hr />
@ -108,7 +122,7 @@ export const Scheduler = (props) => {
</div> </div>
{editSchedule >= 0 && ( {editSchedule >= 0 && (
<SetTimesModal <SetTimesModal
schedule={schedules[editSchedule]} schedule={{ ...openingHours[editSchedule], timeZone: selectedTimeZone }}
onChange={applyEditSchedule} onChange={applyEditSchedule}
onExit={() => setEditSchedule(-1)} onExit={() => setEditSchedule(-1)}
/> />

View file

@ -7,9 +7,15 @@ dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export default function SetTimesModal(props) { export default function SetTimesModal(props) {
const { startDate, endDate } = props.schedule || { const { startDate, endDate } = {
startDate: dayjs.utc().startOf("day").add(540, "minutes"), startDate: dayjs
endDate: dayjs.utc().startOf("day").add(1020, "minutes"), .utc()
.startOf("day")
.add(props.schedule.startTime || 540, "minutes"),
endDate: dayjs
.utc()
.startOf("day")
.add(props.schedule.endTime || 1020, "minutes"),
}; };
startDate.tz(props.timeZone); 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 utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone);
interface GetSlotsType { interface GetSlotsType {
inviteeDate: Dayjs; inviteeDate: Dayjs;
frequency: number; frequency: number;
workingHours: []; workingHours: [];
minimumBookingNotice?: number; minimumBookingNotice?: number;
organizerUtcOffset: number;
} }
interface Boundary { interface Boundary {
@ -17,32 +20,41 @@ interface Boundary {
const freqApply: number = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency; const freqApply: number = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency;
const intersectBoundary = (a: Boundary, b: Boundary) => { const intersectBoundary = (a: Boundary, b: Boundary) => {
if ( if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) {
a.upperBound < b.lowerBound || a.lowerBound > b.upperBound
) {
return; return;
} }
return { return {
lowerBound: Math.max(b.lowerBound, a.lowerBound), 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 // say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) => boundaries const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
.map( boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
(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 boundaries: Boundary[] = [];
const startDay: number = +inviteeDate.utc().startOf('day').add(inviteeBounds.lowerBound, 'minutes').format('d'); const startDay: number = +inviteeDate
const endDay: number = +inviteeDate.utc().startOf('day').add(inviteeBounds.upperBound, 'minutes').format('d'); .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) => { workingHours.forEach((item) => {
const lowerBound: number = item.startTime; const lowerBound: number = item.startTime - dayjs().tz(organizerTimeZone).utcOffset();
const upperBound: number = lowerBound + item.length; const upperBound: number = item.endTime - dayjs().tz(organizerTimeZone).utcOffset();
if (startDay !== endDay) { if (startDay !== endDay) {
if (inviteeBounds.lowerBound < 0) { if (inviteeBounds.lowerBound < 0) {
// lowerBound edges into the previous day // lowerBound edges into the previous day
@ -62,7 +74,7 @@ const organizerBoundaries = (workingHours: [], inviteeDate: Dayjs, inviteeBounds
} }
} }
} else { } else {
boundaries.push({lowerBound, upperBound}); boundaries.push({ lowerBound, upperBound });
} }
}); });
return boundaries; return boundaries;
@ -72,38 +84,42 @@ const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number
const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency); const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency);
const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency); const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency);
return { return {
lowerBound, upperBound, lowerBound,
upperBound,
}; };
}; };
const getSlotsBetweenBoundary = (frequency: number, {lowerBound,upperBound}: Boundary) => { const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => {
const slots: Dayjs[] = []; const slots: Dayjs[] = [];
for ( for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
let minutes = 0; slots.push(
lowerBound + minutes <= upperBound - frequency; <Dayjs>dayjs
minutes += frequency .utc()
) { .startOf("day")
slots.push(<Dayjs>dayjs.utc().startOf('day').add(lowerBound + minutes, 'minutes')); .add(lowerBound + minutes, "minutes")
);
} }
return slots; return slots;
}; };
const getSlots = ( const getSlots = ({
{ inviteeDate, frequency, minimumBookingNotice, workingHours, }: GetSlotsType inviteeDate,
): Dayjs[] => { frequency,
minimumBookingNotice,
const startTime = ( workingHours,
dayjs.utc().isSame(dayjs(inviteeDate), 'day') ? inviteeDate.hour() * 60 + inviteeDate.minute() + minimumBookingNotice : 0 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); const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
return getOverlaps( return getOverlaps(
inviteeBounds, organizerBoundaries(workingHours, inviteeDate, inviteeBounds) inviteeBounds,
).reduce( organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone)
(slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary) ], []
).map(
(slot) => slot.utcOffset(dayjs(inviteeDate).utcOffset())
) )
} .reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
.map((slot) => slot.utcOffset(dayjs(inviteeDate).utcOffset()));
};
export default getSlots; export default getSlots;

View file

@ -32,6 +32,7 @@ export default function Type(props): Type {
frequency: props.eventType.length, frequency: props.eventType.length,
inviteeDate: dayjs.utc(today) as Dayjs, inviteeDate: dayjs.utc(today) as Dayjs,
workingHours: props.workingHours, workingHours: props.workingHours,
organizerTimeZone: props.eventType.timeZone,
minimumBookingNotice: 0, minimumBookingNotice: 0,
}).length === 0, }).length === 0,
[today, props.eventType.length, props.workingHours] [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} | {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
Calendso Calendso
</title> </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 name="description" content={props.eventType.description} />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://calendso/" /> <meta property="og:url" content="https://calendso/" />
<meta property="og:title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}/> <meta
<meta property="og:description" content={props.eventType.description}/> property="og:title"
<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)} /> 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:card" content="summary_large_image" />
<meta property="twitter:url" content="https://calendso/" /> <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: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> </Head>
<main <main
className={ className={
@ -184,6 +210,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
description: true, description: true,
length: true, length: true,
availability: true, availability: true,
timeZone: true,
}, },
}); });

View file

@ -1,81 +1,111 @@
import type {NextApiRequest, NextApiResponse} from 'next'; import type { NextApiRequest, NextApiResponse } from "next";
import {getSession} from 'next-auth/client'; import { getSession } from "next-auth/client";
import prisma from '../../../lib/prisma'; import prisma from "../../../lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req}); const session = await getSession({ req: req });
if (!session) { if (!session) {
res.status(401).json({message: "Not authenticated"}); res.status(401).json({ message: "Not authenticated" });
return; return;
} }
if (req.method == "PATCH" || req.method == "POST") { if (req.method == "PATCH" || req.method == "POST") {
const data = {
const data = { title: req.body.title,
title: req.body.title, slug: req.body.slug,
slug: req.body.slug, description: req.body.description,
description: req.body.description, length: parseInt(req.body.length),
length: parseInt(req.body.length), hidden: req.body.hidden,
hidden: req.body.hidden, locations: req.body.locations,
locations: req.body.locations, eventName: req.body.eventName,
eventName: req.body.eventName, customInputs: !req.body.customInputs
customInputs: !req.body.customInputs ? undefined
? undefined : {
: { deleteMany: {
deleteMany: { eventTypeId: req.body.id,
eventTypeId: req.body.id, NOT: {
NOT: { id: { in: req.body.customInputs.filter((input) => !!input.id).map((e) => e.id) },
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 == "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 Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Select, { OptionBase } from "react-select"; import Select, { OptionBase } from "react-select";
import prisma from "../../../lib/prisma"; import prisma from "@lib/prisma";
import { LocationType } from "../../../lib/location"; import { LocationType } from "@lib/location";
import Shell from "../../../components/Shell"; import Shell from "@components/Shell";
import { getSession } from "next-auth/client"; 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 { 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 { PlusIcon } from "@heroicons/react/solid";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
dayjs.extend(utc); dayjs.extend(utc);
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import { EventType, User, Availability } from "@prisma/client";
import { validJson } from "@lib/jsonUtils";
dayjs.extend(timezone); 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 router = useRouter();
const inputOptions: OptionBase[] = [ const inputOptions: OptionBase[] = [
@ -30,17 +70,17 @@ export default function EventType(props: any): JSX.Element {
{ value: EventTypeCustomInputType.Bool, label: "Checkbox" }, { value: EventTypeCustomInputType.Bool, label: "Checkbox" },
]; ];
const [enteredAvailability, setEnteredAvailability] = useState();
const [showLocationModal, setShowLocationModal] = useState(false); const [showLocationModal, setShowLocationModal] = useState(false);
const [showAddCustomModal, setShowAddCustomModal] = useState(false); const [showAddCustomModal, setShowAddCustomModal] = useState(false);
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined); const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined);
const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]); const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]);
const [locations, setLocations] = useState(props.eventType.locations || []); const [locations, setLocations] = useState(eventType.locations || []);
const [schedule, setSchedule] = useState(undefined);
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined); const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>( 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 titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>(); const slugRef = useRef<HTMLInputElement>();
@ -49,60 +89,55 @@ export default function EventType(props: any): JSX.Element {
const isHiddenRef = useRef<HTMLInputElement>(); const isHiddenRef = useRef<HTMLInputElement>();
const eventNameRef = useRef<HTMLInputElement>(); const eventNameRef = useRef<HTMLInputElement>();
useEffect(() => {
setSelectedTimeZone(eventType.timeZone || user.timeZone);
}, []);
async function updateEventTypeHandler(event) { async function updateEventTypeHandler(event) {
event.preventDefault(); event.preventDefault();
const enteredTitle = titleRef.current.value; const enteredTitle: string = titleRef.current.value;
const enteredSlug = slugRef.current.value; const enteredSlug: string = slugRef.current.value;
const enteredDescription = descriptionRef.current.value; const enteredDescription: string = descriptionRef.current.value;
const enteredLength = lengthRef.current.value; const enteredLength: number = parseInt(lengthRef.current.value);
const enteredIsHidden = isHiddenRef.current.checked; const enteredIsHidden: boolean = isHiddenRef.current.checked;
const enteredEventName = eventNameRef.current.value; const enteredEventName: string = eventNameRef.current.value;
// TODO: Add validation // 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", { await fetch("/api/availability/eventtype", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify(payload),
id: props.eventType.id,
title: enteredTitle,
slug: enteredSlug,
description: enteredDescription,
length: enteredLength,
hidden: enteredIsHidden,
locations,
eventName: enteredEventName,
customInputs,
}),
headers: { headers: {
"Content-Type": "application/json", "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"); router.push("/availability");
} }
@ -111,7 +146,7 @@ export default function EventType(props: any): JSX.Element {
await fetch("/api/availability/eventtype", { await fetch("/api/availability/eventtype", {
method: "DELETE", method: "DELETE",
body: JSON.stringify({ id: props.eventType.id }), body: JSON.stringify({ id: eventType.id }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@ -237,10 +272,10 @@ export default function EventType(props: any): JSX.Element {
return ( return (
<div> <div>
<Head> <Head>
<title>{props.eventType.title} | Event Type | Calendso</title> <title>{eventType.title} | Event Type | Calendso</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Shell heading={"Event Type - " + props.eventType.title}> <Shell heading={"Event Type - " + eventType.title}>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="col-span-3 sm:col-span-2"> <div className="col-span-3 sm:col-span-2">
<div className="bg-white overflow-hidden shadow rounded-lg mb-4"> <div className="bg-white overflow-hidden shadow rounded-lg mb-4">
@ -259,7 +294,7 @@ export default function EventType(props: any): JSX.Element {
required required
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" 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" placeholder="Quick Chat"
defaultValue={props.eventType.title} defaultValue={eventType.title}
/> />
</div> </div>
</div> </div>
@ -270,7 +305,7 @@ export default function EventType(props: any): JSX.Element {
<div className="mt-1"> <div className="mt-1">
<div className="flex rounded-md shadow-sm"> <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"> <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> </span>
<input <input
ref={slugRef} ref={slugRef}
@ -279,7 +314,7 @@ export default function EventType(props: any): JSX.Element {
id="slug" id="slug"
required 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" 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>
</div> </div>
@ -420,7 +455,7 @@ export default function EventType(props: any): JSX.Element {
id="description" id="description"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" 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." placeholder="A quick video meeting."
defaultValue={props.eventType.description}></textarea> defaultValue={eventType.description}></textarea>
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
@ -436,7 +471,7 @@ export default function EventType(props: any): JSX.Element {
required required
className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md" className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md"
placeholder="15" 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"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
minutes minutes
@ -455,7 +490,7 @@ export default function EventType(props: any): JSX.Element {
id="title" id="title"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" 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}" placeholder="Meeting with {USER}"
defaultValue={props.eventType.eventName} defaultValue={eventType.eventName}
/> />
</div> </div>
</div> </div>
@ -514,7 +549,7 @@ export default function EventType(props: any): JSX.Element {
name="ishidden" name="ishidden"
type="checkbox" type="checkbox"
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<div className="ml-3 text-sm"> <div className="ml-3 text-sm">
@ -531,9 +566,10 @@ export default function EventType(props: any): JSX.Element {
<div> <div>
<h3 className="mb-2">How do you want to offer your availability for this event type?</h3> <h3 className="mb-2">How do you want to offer your availability for this event type?</h3>
<Scheduler <Scheduler
onChange={setSchedule} setAvailability={setEnteredAvailability}
timeZone={props.user.timeZone} setTimeZone={setSelectedTimeZone}
schedules={props.schedules} timeZone={selectedTimeZone}
availability={availability}
/> />
<div className="py-4 flex justify-end"> <div className="py-4 flex justify-end">
<Link href="/availability"> <Link href="/availability">
@ -709,24 +745,18 @@ export default function EventType(props: any): JSX.Element {
); );
} }
const validJson = (jsonString: string) => { export const getServerSideProps: GetServerSideProps<Props> = async ({ req, query }) => {
try { const session = await getSession({ req });
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);
if (!session) { 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: { where: {
email: session.user.email, 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: { where: {
id: parseInt(context.query.type), id: parseInt(query.type as string),
}, },
select: { select: {
id: true, id: true,
@ -754,9 +784,16 @@ export async function getServerSideProps(context) {
eventName: true, eventName: true,
availability: true, availability: true,
customInputs: true, customInputs: true,
timeZone: true,
}, },
}); });
if (!eventType) {
return {
notFound: true,
};
}
const credentials = await prisma.credential.findMany({ const credentials = await prisma.credential.findMany({
where: { where: {
userId: user.id, userId: user.id,
@ -808,18 +845,12 @@ export async function getServerSideProps(context) {
// Assuming it's Microsoft Teams. // Assuming it's Microsoft Teams.
} }
if (!eventType) {
return {
notFound: true,
};
}
const getAvailability = (providesAvailability) => const getAvailability = (providesAvailability) =>
providesAvailability.availability && providesAvailability.availability.length providesAvailability.availability && providesAvailability.availability.length
? providesAvailability.availability ? providesAvailability.availability
: null; : null;
const schedules = getAvailability(eventType) || const availability: Availability[] = getAvailability(eventType) ||
getAvailability(user) || [ getAvailability(user) || [
{ {
days: [0, 1, 2, 3, 4, 5, 6], days: [0, 1, 2, 3, 4, 5, 6],
@ -832,8 +863,8 @@ export async function getServerSideProps(context) {
props: { props: {
user, user,
eventType, eventType,
schedules,
locationOptions, locationOptions,
availability,
}, },
}; };
} };

View file

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