Merge branch 'main' into fix/teams-create-error-and-unauthenticated-request

This commit is contained in:
mihaic195 2021-07-05 10:18:28 +03:00
commit e315b272f5
No known key found for this signature in database
GPG key ID: 18E6B791693DB416
27 changed files with 3540 additions and 1035 deletions

3
.babelrc Normal file
View file

@ -0,0 +1,3 @@
{
"presets": ["next/babel"]
}

16
.editorconfig Normal file
View file

@ -0,0 +1,16 @@
# See http://EditorConfig.org for more information
# top-most EditorConfig file
root = true
# Every File
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

View file

@ -24,7 +24,8 @@
"env": { "env": {
"browser": true, "browser": true,
"node": true, "node": true,
"es6": true "es6": true,
"jest": true
}, },
"settings": { "settings": {
"react": { "react": {

View file

@ -30,8 +30,9 @@ Let's face it: Calendly and other scheduling tools are awesome. It made our live
### Product of the Month: April ### Product of the Month: April
#### Support us on [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso) #### Support us on [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso)
<a href="https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Calendso - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Calendso - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://calendso.com/maker-grant.svg" alt="Calendso - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Calendso - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
### Built With ### Built With
@ -107,7 +108,7 @@ You will also need Google API credentials. You can get this from the [Google API
5. Set up the database using the Prisma schema (found in `prisma/schema.prisma`) 5. Set up the database using the Prisma schema (found in `prisma/schema.prisma`)
```sh ```sh
npx prisma db push --preview-feature npx prisma db push
``` ```
6. Run (in development mode) 6. Run (in development mode)
```sh ```sh
@ -157,7 +158,11 @@ You will also need Google API credentials. You can get this from the [Google API
5. Enjoy the new version. 5. Enjoy the new version.
<!-- DEPLOYMENT --> <!-- DEPLOYMENT -->
## Deployment ## Deployment
### Docker
The Docker configuration for Calendso is an effort powered by people within the community. Calendso does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
The Docker configuration can be found [in our docker repository](https://github.com/calendso/docker).
### Railway
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https%3A%2F%2Fgithub.com%2Fcalendso%2Fcalendso&plugins=postgresql&envs=GOOGLE_API_CREDENTIALS%2CBASE_URL%2CNEXTAUTH_URL%2CPORT&BASE_URLDefault=http%3A%2F%2Flocalhost%3A3000&NEXTAUTH_URLDefault=http%3A%2F%2Flocalhost%3A3000&PORTDefault=3000) [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https%3A%2F%2Fgithub.com%2Fcalendso%2Fcalendso&plugins=postgresql&envs=GOOGLE_API_CREDENTIALS%2CBASE_URL%2CNEXTAUTH_URL%2CPORT&BASE_URLDefault=http%3A%2F%2Flocalhost%3A3000&NEXTAUTH_URLDefault=http%3A%2F%2Flocalhost%3A3000&PORTDefault=3000)
You can deploy Calendso on [Railway](https://railway.app/) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Calendso on their platform. You can deploy Calendso on [Railway](https://railway.app/) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Calendso on their platform.

View file

@ -1,112 +1,40 @@
import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
dayjs.extend(isBetween);
import { useEffect, useState, useMemo } from "react";
import getSlots from "../../lib/slots";
import Link from "next/link"; import Link from "next/link";
import { timeZone } from "../../lib/clock";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Slots from "./Slots";
import { ExclamationIcon } from "@heroicons/react/solid"; import { ExclamationIcon } from "@heroicons/react/solid";
const AvailableTimes = (props) => { const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat, user }) => {
const router = useRouter(); const router = useRouter();
const { user, rescheduleUid } = router.query; const { rescheduleUid } = router.query;
const [loaded, setLoaded] = useState(false); const { slots, isFullyBooked, hasErrors } = Slots({ date, eventLength, workingHours });
const [error, setError] = useState(false);
const times = useMemo(() => {
const slots = getSlots({
calendarTimeZone: props.user.timeZone,
selectedTimeZone: timeZone(),
eventLength: props.eventType.length,
selectedDate: props.date,
dayStartTime: props.user.startTime,
dayEndTime: props.user.endTime,
});
return slots;
}, [props.date]);
const handleAvailableSlots = (busyTimes: []) => {
// Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) {
busyTimes.forEach((busyTime) => {
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Check if start times are the same
if (dayjs(times[i]).format("HH:mm") == startTime.format("HH:mm")) {
times.splice(i, 1);
}
// Check if time is between start and end times
if (dayjs(times[i]).isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if slot end time is between start and end time
if (dayjs(times[i]).add(props.eventType.length, "minutes").isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if startTime is between slot
if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, "minutes"))) {
times.splice(i, 1);
}
});
}
// Display available times
setLoaded(true);
};
// Re-render only when invitee changes date
useEffect(() => {
setLoaded(false);
setError(false);
fetch(
`/api/availability/${user}?dateFrom=${props.date.startOf("day").utc().format()}&dateTo=${props.date
.endOf("day")
.utc()
.format()}`
)
.then((res) => res.json())
.then(handleAvailableSlots)
.catch((e) => {
console.error(e);
setError(true);
});
}, [props.date]);
return ( return (
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto"> <div className="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"> <div className="text-gray-600 font-light text-xl mb-4 text-left">
<span className="w-1/2">{props.date.format("dddd DD MMMM YYYY")}</span> <span className="w-1/2">{date.format("dddd DD MMMM YYYY")}</span>
</div> </div>
{!error && {slots.length > 0 &&
loaded && slots.map((slot) => (
times.length > 0 && <div key={slot.format()}>
times.map((time) => (
<div key={dayjs(time).utc().format()}>
<Link <Link
href={ href={
`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + `/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "") (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
}> }>
<a <a className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
key={dayjs(time).format("hh:mma")} {slot.format(timeFormat)}
className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
{dayjs(time).tz(timeZone()).format(props.timeFormat)}
</a> </a>
</Link> </Link>
</div> </div>
))} ))}
{!error && loaded && times.length == 0 && ( {isFullyBooked && (
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4"> <div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
<h1 className="text-xl font">{props.user.name} is all booked today.</h1> <h1 className="text-xl font">{user.name} is all booked today.</h1>
</div> </div>
)} )}
{!error && !loaded && <div className="loader" />}
{error && ( {!isFullyBooked && slots.length === 0 && !hasErrors && <div className="loader" />}
{hasErrors && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4"> <div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -116,9 +44,9 @@ const AvailableTimes = (props) => {
<p className="text-sm text-yellow-700"> <p className="text-sm text-yellow-700">
Could not load the available time slots.{" "} Could not load the available time slots.{" "}
<a <a
href={"mailto:" + props.user.email} href={"mailto:" + user.email}
className="font-medium underline text-yellow-700 hover:text-yellow-600"> className="font-medium underline text-yellow-700 hover:text-yellow-600">
Contact {props.user.name} via e-mail Contact {user.name} via e-mail
</a> </a>
</p> </p>
</div> </div>

View file

@ -0,0 +1,134 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
import { useEffect, useState } from "react";
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import getSlots from "@lib/slots";
dayjs.extend(utc);
dayjs.extend(timezone);
const DatePicker = ({
weekStart,
onDatePicked,
workingHours,
organizerTimeZone,
inviteeTimeZone,
eventLength,
}) => {
const [calendar, setCalendar] = useState([]);
const [selectedMonth, setSelectedMonth]: number = useState();
const [selectedDate, setSelectedDate]: Dayjs = useState();
useEffect(() => {
setSelectedMonth(dayjs().tz(inviteeTimeZone).month());
}, []);
useEffect(() => {
if (selectedDate) onDatePicked(selectedDate);
}, [selectedDate]);
// Handle month changes
const incrementMonth = () => {
setSelectedMonth(selectedMonth + 1);
};
const decrementMonth = () => {
setSelectedMonth(selectedMonth - 1);
};
useEffect(() => {
if (!selectedMonth) {
// wish next had a way of dealing with this magically;
return;
}
const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth);
const isDisabled = (day: number) => {
const date: Dayjs = inviteeDate.date(day);
return (
date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
!getSlots({
inviteeDate: date,
frequency: eventLength,
workingHours,
organizerTimeZone,
}).length
);
};
// Set up calendar
const daysInMonth = inviteeDate.daysInMonth();
const days = [];
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
}
// Create placeholder elements for empty days in first week
let weekdayOfFirst = inviteeDate.date(1).day();
if (weekStart === "Monday") {
weekdayOfFirst -= 1;
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
}
const emptyDays = Array(weekdayOfFirst)
.fill(null)
.map((day, i) => (
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
{null}
</div>
));
// Combine placeholder days with actual days
setCalendar([
...emptyDays,
...days.map((day) => (
<button
key={day}
onClick={() => setSelectedDate(inviteeDate.date(day))}
disabled={isDisabled(day)}
className={
"text-center w-10 h-10 rounded-full mx-auto" +
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
? " bg-blue-600 text-white-important"
: !isDisabled(day)
? " bg-blue-50"
: "")
}>
{day}
</button>
)),
]);
}, [selectedMonth, inviteeTimeZone, selectedDate]);
return selectedMonth ? (
<div className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}>
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
<div className="w-1/2 text-right">
<button
onClick={decrementMonth}
className={"mr-4 " + (selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400")}
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
<ChevronLeftIcon className="w-5 h-5" />
</button>
<button onClick={incrementMonth}>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-y-4 text-center">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
.map((weekDay) => (
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
{weekDay}
</div>
))}
{calendar}
</div>
</div>
) : null;
};
export default DatePicker;

View file

@ -0,0 +1,96 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
import getSlots from "../../lib/slots";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
dayjs.extend(isBetween);
dayjs.extend(utc);
type Props = {
eventLength: number;
minimumBookingNotice?: number;
date: Dayjs;
};
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerUtcOffset }: Props) => {
minimumBookingNotice = minimumBookingNotice || 0;
const router = useRouter();
const { user } = router.query;
const [slots, setSlots] = useState([]);
const [isFullyBooked, setIsFullyBooked] = useState(false);
const [hasErrors, setHasErrors] = useState(false);
useEffect(() => {
setSlots([]);
setIsFullyBooked(false);
setHasErrors(false);
fetch(
`/api/availability/${user}?dateFrom=${date.startOf("day").utc().startOf("day").format()}&dateTo=${date
.endOf("day")
.utc()
.endOf("day")
.format()}`
)
.then((res) => res.json())
.then(handleAvailableSlots)
.catch((e) => {
console.error(e);
setHasErrors(true);
});
}, [date]);
const handleAvailableSlots = (busyTimes: []) => {
const times = getSlots({
frequency: eventLength,
inviteeDate: date,
workingHours,
minimumBookingNotice,
organizerUtcOffset,
});
const timesLengthBeforeConflicts: number = times.length;
// Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) {
busyTimes.every((busyTime): boolean => {
const startTime = dayjs(busyTime.start).utc();
const endTime = dayjs(busyTime.end).utc();
// Check if start times are the same
if (times[i].utc().isSame(startTime)) {
times.splice(i, 1);
}
// Check if time is between start and end times
else if (times[i].utc().isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if slot end time is between start and end time
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if startTime is between slot
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
times.splice(i, 1);
} else {
return true;
}
return false;
});
}
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
setIsFullyBooked(true);
}
// Display available times
setSlots(times);
};
return {
slots,
isFullyBooked,
hasErrors,
};
};
export default Slots;

View file

@ -1,15 +1,14 @@
import { Switch } from "@headlessui/react"; import { Switch } from "@headlessui/react";
import TimezoneSelect from "react-timezone-select"; import TimezoneSelect from "react-timezone-select";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {timeZone, is24h} from '../../lib/clock'; import { timeZone, is24h } from "../../lib/clock";
function classNames(...classes) { function classNames(...classes) {
return classes.filter(Boolean).join(' ') return classes.filter(Boolean).join(" ");
} }
const TimeOptions = (props) => { const TimeOptions = (props) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [selectedTimeZone, setSelectedTimeZone] = useState('');
const [is24hClock, setIs24hClock] = useState(false); const [is24hClock, setIs24hClock] = useState(false);
useEffect(() => { useEffect(() => {
@ -18,22 +17,22 @@ const TimeOptions = (props) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
props.onSelectTimeZone(timeZone(selectedTimeZone)); props.onSelectTimeZone(timeZone(selectedTimeZone));
}
}, [selectedTimeZone]); }, [selectedTimeZone]);
useEffect(() => { useEffect(() => {
props.onToggle24hClock(is24h(is24hClock)); props.onToggle24hClock(is24h(is24hClock));
}, [is24hClock]); }, [is24hClock]);
return selectedTimeZone !== "" && ( return (
selectedTimeZone !== "" && (
<div className="w-full rounded shadow border bg-white px-4 py-2"> <div className="w-full rounded shadow border bg-white px-4 py-2">
<div className="flex mb-4"> <div className="flex mb-4">
<div className="w-1/2 font-medium">Time Options</div> <div className="w-1/2 font-medium">Time Options</div>
<div className="w-1/2"> <div className="w-1/2">
<Switch.Group <Switch.Group as="div" className="flex items-center justify-end">
as="div"
className="flex items-center justify-end"
>
<Switch.Label as="span" className="mr-3"> <Switch.Label as="span" className="mr-3">
<span className="text-sm text-gray-500">am/pm</span> <span className="text-sm text-gray-500">am/pm</span>
</Switch.Label> </Switch.Label>
@ -43,8 +42,7 @@ const TimeOptions = (props) => {
className={classNames( className={classNames(
is24hClock ? "bg-blue-600" : "bg-gray-200", is24hClock ? "bg-blue-600" : "bg-gray-200",
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" "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 className="sr-only">Use setting</span>
<span <span
aria-hidden="true" aria-hidden="true"
@ -67,7 +65,8 @@ const TimeOptions = (props) => {
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" 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> </div>
)
); );
} };
export default TimeOptions; export default TimeOptions;

View file

@ -0,0 +1,22 @@
import Link from "next/link";
const PoweredByCalendso = () => (
<div className="text-xs text-center sm:text-right pt-1">
<Link href="https://calendso.com">
<a
style={{ color: "#104D86" }}
className="opacity-50 hover:opacity-100"
>
powered by{" "}
<img
style={{ top: -2 }}
className="w-auto inline h-3 relative"
src="/calendso-logo-word.svg"
alt="Calendso Logo"
/>
</a>
</Link>
</div>
);
export default PoweredByCalendso;

143
components/ui/Scheduler.tsx Normal file
View file

@ -0,0 +1,143 @@
import React, { useEffect, useState } from "react";
import TimezoneSelect from "react-timezone-select";
import { TrashIcon } from "@heroicons/react/outline";
import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { Availability } from "@prisma/client";
dayjs.extend(utc);
dayjs.extend(timezone);
type Props = {
timeZone: string;
availability: Availability[];
setTimeZone: unknown;
};
export const Scheduler = ({
availability,
setAvailability,
timeZone: selectedTimeZone,
setTimeZone,
}: Props) => {
const [editSchedule, setEditSchedule] = useState(-1);
const [dateOverrides, setDateOverrides] = useState([]);
const [openingHours, setOpeningHours] = useState([]);
useEffect(() => {
setOpeningHours(
availability
.filter((item: Availability) => item.days.length !== 0)
.map((item) => {
item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes");
item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes");
return item;
})
);
setDateOverrides(availability.filter((item: Availability) => item.date));
}, []);
// updates availability to how it should be formatted outside this component.
useEffect(() => {
setAvailability({
dateOverrides: dateOverrides,
openingHours: openingHours,
});
}, [dateOverrides, openingHours]);
const addNewSchedule = () => setEditSchedule(openingHours.length);
const applyEditSchedule = (changed) => {
// new entry
if (!changed.days) {
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
setOpeningHours(openingHours.concat(changed));
} else {
// update
const replaceWith = { ...openingHours[editSchedule], ...changed };
openingHours.splice(editSchedule, 1, replaceWith);
setOpeningHours([].concat(openingHours));
}
};
const removeScheduleAt = (toRemove: number) => {
openingHours.splice(toRemove, 1);
setOpeningHours([].concat(openingHours));
};
const OpeningHours = ({ idx, item }) => (
<li className="py-2 flex justify-between border-t">
<div className="inline-flex ml-2">
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
<button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}>
{dayjs()
.startOf("day")
.add(item.startTime, "minutes")
.format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
&nbsp;until&nbsp;
{dayjs()
.startOf("day")
.add(item.endTime, "minutes")
.format(item.endTime % 60 === 0 ? "ha" : "h:mma")}
</button>
</div>
<button
type="button"
onClick={() => removeScheduleAt(idx)}
className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
</button>
</li>
);
return (
<div>
<div className="rounded border flex">
<div className="w-3/5">
<div className="w-3/4 p-2">
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
Timezone
</label>
<div className="mt-1">
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(tz) => setTimeZone(tz.value)}
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<ul>
{openingHours.map((item, idx) => (
<OpeningHours key={idx} idx={idx} item={item} />
))}
</ul>
<hr />
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">
Add another
</button>
</div>
<div className="border-l p-2 w-2/5 text-sm bg-gray-50">
{/*<p className="font-bold mb-2">Add date overrides</p>
<p className="mb-2">
Add dates when your availability changes from your weekly hours
</p>
<button className="btn-sm btn-white">Add a date override</button>*/}
</div>
</div>
{editSchedule >= 0 && (
<SetTimesModal
startTime={openingHours[editSchedule] ? openingHours[editSchedule].startTime : 540}
endTime={openingHours[editSchedule] ? openingHours[editSchedule].endTime : 1020}
onChange={(times) => applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })}
onExit={() => setEditSchedule(-1)}
/>
)}
{/*{showDateOverrideModal &&
<DateOverrideModal />
}*/}
</div>
);
};

View file

@ -0,0 +1,53 @@
import React, { useEffect, useState } from "react";
export const WeekdaySelect = (props) => {
const [activeDays, setActiveDays] = useState(
[...Array(7).keys()].map((v, i) => (props.defaultValue || []).includes(i))
);
const days = ["S", "M", "T", "W", "T", "F", "S"];
useEffect(() => {
props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
}, [activeDays]);
const toggleDay = (e, idx: number) => {
e.preventDefault();
activeDays[idx] = !activeDays[idx];
setActiveDays([].concat(activeDays));
};
return (
<div className="weekdaySelect">
<div className="inline-flex">
{days.map((day, idx) =>
activeDays[idx] ? (
<button
key={idx}
onClick={(e) => toggleDay(e, idx)}
style={{ marginLeft: "-2px" }}
className={`
active focus:outline-none border-2 border-blue-500 px-2 py-1 rounded
${activeDays[idx + 1] ? "rounded-r-none" : ""}
${activeDays[idx - 1] ? "rounded-l-none" : ""}
${idx === 0 ? "rounded-l" : ""}
${idx === days.length - 1 ? "rounded-r" : ""}
`}>
{day}
</button>
) : (
<button
key={idx}
onClick={(e) => toggleDay(e, idx)}
style={{ marginTop: "1px", marginBottom: "1px" }}
className={`border focus:outline-none px-2 py-1 rounded-none ${
idx === 0 ? "rounded-l" : "border-l-0"
} ${idx === days.length - 1 ? "rounded-r" : ""}`}>
{day}
</button>
)
)}
</div>
</div>
);
};

View file

@ -0,0 +1,146 @@
import { ClockIcon } from "@heroicons/react/outline";
import { useRef } from "react";
export default function SetTimesModal(props) {
const [startHours, startMinutes] = [Math.floor(props.startTime / 60), props.startTime % 60];
const [endHours, endMinutes] = [Math.floor(props.endTime / 60), props.endTime % 60];
const startHoursRef = useRef<HTMLInputElement>();
const startMinsRef = useRef<HTMLInputElement>();
const endHoursRef = useRef<HTMLInputElement>();
const endMinsRef = useRef<HTMLInputElement>();
function updateStartEndTimesHandler(event) {
event.preventDefault();
const enteredStartHours = parseInt(startHoursRef.current.value);
const enteredStartMins = parseInt(startMinsRef.current.value);
const enteredEndHours = parseInt(endHoursRef.current.value);
const enteredEndMins = parseInt(endMinsRef.current.value);
props.onChange({
startTime: enteredStartHours * 60 + enteredStartMins,
endTime: enteredEndHours * 60 + enteredEndMins,
});
props.onExit(0);
}
return (
<div
className="fixed z-10 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<ClockIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Change when you are available for bookings
</h3>
<div>
<p className="text-sm text-gray-500">Set your work schedule</p>
</div>
</div>
</div>
<div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label>
<div>
<label htmlFor="startHours" className="sr-only">
Hours
</label>
<input
ref={startHoursRef}
type="number"
min="0"
max="23"
maxLength="2"
name="hours"
id="startHours"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="9"
defaultValue={startHours}
/>
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="startMinutes" className="sr-only">
Minutes
</label>
<input
ref={startMinsRef}
type="number"
min="0"
max="59"
step="15"
maxLength="2"
name="minutes"
id="startMinutes"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="30"
defaultValue={startMinutes}
/>
</div>
</div>
<div className="flex">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label>
<div>
<label htmlFor="endHours" className="sr-only">
Hours
</label>
<input
ref={endHoursRef}
type="number"
min="0"
max="24"
maxLength="2"
name="hours"
id="endHours"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="17"
defaultValue={endHours}
/>
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="endMinutes" className="sr-only">
Minutes
</label>
<input
ref={endMinsRef}
type="number"
min="0"
max="59"
maxLength="2"
step="15"
name="minutes"
id="endMinutes"
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="30"
defaultValue={endMinutes}
/>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button onClick={updateStartEndTimesHandler} type="submit" className="btn btn-primary">
Save
</button>
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</div>
</div>
</div>
);
}

11
lib/jsonUtils.ts Normal file
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,94 +1,134 @@
const dayjs = require("dayjs"); import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
const isToday = require("dayjs/plugin/isToday"); import timezone from "dayjs/plugin/timezone";
const utc = require("dayjs/plugin/utc");
const timezone = require("dayjs/plugin/timezone");
dayjs.extend(isToday);
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
const getMinutesFromMidnight = (date) => { type WorkingHour = {
return date.hour() * 60 + date.minute(); days: number[];
startTime: number;
endTime: number;
};
type GetSlots = {
inviteeDate: Dayjs;
frequency: number;
workingHours: WorkingHour[];
minimumBookingNotice?: number;
organizerTimeZone: string;
};
type Boundary = {
lowerBound: number;
upperBound: number;
};
const freqApply = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency;
const intersectBoundary = (a: Boundary, b: Boundary) => {
if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) {
return;
}
return {
lowerBound: Math.max(b.lowerBound, a.lowerBound),
upperBound: Math.min(b.upperBound, a.upperBound),
};
};
// 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 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");
workingHours.forEach((item) => {
const lowerBound: number = item.startTime - dayjs().tz(organizerTimeZone).utcOffset();
const upperBound: number = item.endTime - dayjs().tz(organizerTimeZone).utcOffset();
if (startDay !== endDay) {
if (inviteeBounds.lowerBound < 0) {
// lowerBound edges into the previous day
if (item.days.includes(startDay)) {
boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 });
}
if (item.days.includes(endDay)) {
boundaries.push({ lowerBound, upperBound });
}
} else {
// upperBound edges into the next day
if (item.days.includes(endDay)) {
boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 });
}
if (item.days.includes(startDay)) {
boundaries.push({ lowerBound, upperBound });
}
}
} else {
boundaries.push({ lowerBound, upperBound });
}
});
return boundaries;
};
const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => {
const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency);
const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency);
return {
lowerBound,
upperBound,
};
};
const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => {
const slots: Dayjs[] = [];
for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
slots.push(
<Dayjs>dayjs
.utc()
.startOf("day")
.add(lowerBound + minutes, "minutes")
);
}
return slots;
}; };
const getSlots = ({ const getSlots = ({
calendarTimeZone, inviteeDate,
eventLength, frequency,
selectedTimeZone, minimumBookingNotice,
selectedDate, workingHours,
dayStartTime, organizerTimeZone,
dayEndTime }: GetSlots): Dayjs[] => {
}) => { const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
? inviteeDate.hour() * 60 + inviteeDate.minute() + (minimumBookingNotice || 0)
: 0;
if(!selectedDate) return [] const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day"); return getOverlaps(
inviteeBounds,
// Simple case, same timezone organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone)
if (calendarTimeZone === selectedTimeZone) { )
const slots = []; .reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
const now = dayjs(); .map((slot) =>
for ( slot.month(inviteeDate.month()).date(inviteeDate.date()).utcOffset(inviteeDate.utcOffset())
let minutes = dayStartTime; );
minutes <= dayEndTime - eventLength;
minutes += parseInt(eventLength, 10)
) {
const slot = lowerBound.add(minutes, "minutes");
if (slot > now) {
slots.push(slot);
}
}
return slots;
}
const upperBound = selectedDate.tz(selectedTimeZone).endOf("day");
// We need to start generating slots from the start of the calendarTimeZone day
const startDateTime = lowerBound
.tz(calendarTimeZone)
.startOf("day")
.add(dayStartTime, "minutes");
let phase = 0;
if (startDateTime < lowerBound) {
// Getting minutes of the first event in the day of the chooser
const diff = lowerBound.diff(startDateTime, "minutes");
// finding first event
phase = diff + eventLength - (diff % eventLength);
}
// We can stop as soon as the selectedTimeZone day ends
const endDateTime = upperBound
.tz(calendarTimeZone)
.subtract(eventLength, "minutes");
const maxMinutes = endDateTime.diff(startDateTime, "minutes");
const slots = [];
const now = dayjs();
for (
let minutes = phase;
minutes <= maxMinutes;
minutes += parseInt(eventLength, 10)
) {
const slot = startDateTime.add(minutes, "minutes");
const minutesFromMidnight = getMinutesFromMidnight(slot);
if (
minutesFromMidnight < dayStartTime ||
minutesFromMidnight > dayEndTime - eventLength ||
slot < now
) {
continue;
}
slots.push(slot.tz(selectedTimeZone));
}
return slots;
}; };
export default getSlots export default getSlots;

View file

@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"postinstall": "prisma generate", "postinstall": "prisma generate",
@ -38,6 +39,7 @@
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^26.0.23",
"@types/node": "^14.14.33", "@types/node": "^14.14.33",
"@types/nodemailer": "^6.4.2", "@types/nodemailer": "^6.4.2",
"@types/react": "^17.0.3", "@types/react": "^17.0.3",
@ -50,7 +52,9 @@
"eslint-plugin-react": "^7.24.0", "eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
"husky": "^6.0.0", "husky": "^6.0.0",
"jest": "^27.0.5",
"lint-staged": "^11.0.0", "lint-staged": "^11.0.0",
"mockdate": "^3.0.5",
"postcss": "^8.2.8", "postcss": "^8.2.8",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"prisma": "^2.23.0", "prisma": "^2.23.0",
@ -62,5 +66,19 @@
"prettier --write", "prettier --write",
"eslint" "eslint"
] ]
},
"jest": {
"verbose": true,
"extensionsToTreatAsEsm": [
".ts"
],
"moduleFileExtensions": [
"js",
"ts"
],
"moduleNameMapper": {
"^@components(.*)$": "<rootDir>/components$1",
"^@lib(.*)$": "<rootDir>/lib$1"
}
} }
} }

View file

@ -1,115 +1,47 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
import prisma from "../../lib/prisma"; import prisma from "../../lib/prisma";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dayjs, { Dayjs } from "dayjs"; import { Dayjs } from "dayjs";
import {
ClockIcon,
GlobeIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "@heroicons/react/solid";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(isSameOrBefore);
dayjs.extend(utc);
dayjs.extend(timezone);
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
import AvailableTimes from "../../components/booking/AvailableTimes"; import AvailableTimes from "../../components/booking/AvailableTimes";
import TimeOptions from "../../components/booking/TimeOptions"; import TimeOptions from "../../components/booking/TimeOptions";
import Avatar from "../../components/Avatar"; import Avatar from "../../components/Avatar";
import { timeZone } from "../../lib/clock"; import { timeZone } from "../../lib/clock";
import DatePicker from "../../components/booking/DatePicker";
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
export default function Type(props): Type { export default function Type(props): Type {
// Get router variables // Get router variables
const router = useRouter(); const router = useRouter();
const { rescheduleUid } = router.query; const { rescheduleUid } = router.query;
// Initialise state
const [selectedDate, setSelectedDate] = useState<Dayjs>(); const [selectedDate, setSelectedDate] = useState<Dayjs>();
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState("h:mma"); const [timeFormat, setTimeFormat] = useState("h:mma");
const telemetry = useTelemetry(); const telemetry = useTelemetry();
useEffect((): void => { useEffect(() => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())); telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
}, [telemetry]); }, [telemetry]);
// Handle month changes const changeDate = (date: Dayjs) => {
const incrementMonth = () => {
setSelectedMonth(selectedMonth + 1);
};
const decrementMonth = () => {
setSelectedMonth(selectedMonth - 1);
};
// Set up calendar
const daysInMonth = dayjs().month(selectedMonth).daysInMonth();
const days = [];
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
}
// Create placeholder elements for empty days in first week
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
if (props.user.weekStart === "Monday") {
weekdayOfFirst -= 1;
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
}
const emptyDays = Array(weekdayOfFirst)
.fill(null)
.map((day, i) => (
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
{null}
</div>
));
const changeDate = (day): void => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())); telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
setSelectedDate(dayjs().month(selectedMonth).date(day)); setSelectedDate(date);
}; };
// Combine placeholder days with actual days
const calendar = [
...emptyDays,
...days.map((day) => (
<button
key={day}
onClick={() => changeDate(day)}
disabled={
selectedMonth < parseInt(dayjs().format("MM")) && dayjs().month(selectedMonth).format("D") > day
}
className={
"text-center w-10 h-10 rounded-full mx-auto " +
(dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth))
? "bg-blue-50 text-blue-600 font-medium"
: "text-gray-400 font-light") +
(dayjs(selectedDate).month(selectedMonth).format("D") == day
? " bg-blue-600 text-white-important"
: "")
}>
{day}
</button>
)),
];
const handleSelectTimeZone = (selectedTimeZone: string): void => { const handleSelectTimeZone = (selectedTimeZone: string): void => {
if (selectedDate) { if (selectedDate) {
setSelectedDate(selectedDate.tz(selectedTimeZone)); setSelectedDate(selectedDate.tz(selectedTimeZone));
} }
setIsTimeOptionsOpen(false);
}; };
const handleToggle24hClock = (is24hClock: boolean): void => { const handleToggle24hClock = (is24hClock: boolean) => {
if (selectedDate) {
setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
}
}; };
return ( return (
@ -162,10 +94,10 @@ export default function Type(props): Type {
</Head> </Head>
<main <main
className={ className={
"mx-auto my-24 transition-max-width ease-in-out duration-500 " + "mx-auto my-0 sm:my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-6xl" : "max-w-3xl") (selectedDate ? "max-w-6xl" : "max-w-3xl")
}> }>
<div className="bg-white shadow rounded-lg"> <div className="bg-white sm:shadow sm:rounded-lg">
<div className="sm:flex px-4 py-5 sm:p-4"> <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")}> <div className={"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")}>
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" /> <Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
@ -190,63 +122,27 @@ export default function Type(props): Type {
)} )}
<p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p> <p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
</div> </div>
<div <DatePicker
className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}> weekStart={props.user.weekStart}
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2"> onDatePicked={changeDate}
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span> workingHours={props.workingHours}
<div className="w-1/2 text-right"> organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
<button inviteeTimeZone={timeZone()}
onClick={decrementMonth} eventLength={props.eventType.length}
className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")} />
disabled={selectedMonth < parseInt(dayjs().format("MM"))}>
<ChevronLeftIcon className="w-5 h-5" />
</button>
<button onClick={incrementMonth}>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-y-4 text-center">
{props.user.weekStart !== "Monday" ? (
<div className="uppercase text-gray-400 text-xs tracking-widest">Sun</div>
) : null}
<div className="uppercase text-gray-400 text-xs tracking-widest">Mon</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">Tue</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">Wed</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">Thu</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">Fri</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">Sat</div>
{props.user.weekStart === "Monday" ? (
<div className="uppercase text-gray-400 text-xs tracking-widest">Sun</div>
) : null}
{calendar}
</div>
</div>
{selectedDate && ( {selectedDate && (
<AvailableTimes <AvailableTimes
workingHours={props.workingHours}
timeFormat={timeFormat} timeFormat={timeFormat}
user={props.user} eventLength={props.eventType.length}
eventType={props.eventType} eventTypeId={props.eventType.id}
date={selectedDate} date={selectedDate}
user={props.user}
/> />
)} )}
</div> </div>
</div> </div>
{!props.user.hideBranding && ( {!props.user.hideBranding && <PoweredByCalendso />}
<div className="text-xs text-right pt-1">
<Link href="https://calendso.com">
<a style={{ color: "#104D86" }} className="opacity-50 hover:opacity-100">
powered by{" "}
<img
style={{ top: -2 }}
className="w-auto inline h-3 relative"
src="/calendso-logo-word.svg"
alt="Calendso Logo"
/>
</a>
</Link>
</div>
)}
</main> </main>
</div> </div>
); );
@ -269,6 +165,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
timeZone: true, timeZone: true,
endTime: true, endTime: true,
weekStart: true, weekStart: true,
availability: true,
hideBranding: true, hideBranding: true,
}, },
}); });
@ -291,6 +188,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
title: true, title: true,
description: true, description: true,
length: true, length: true,
availability: true,
timeZone: true,
}, },
}); });
@ -300,10 +199,29 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
}; };
} }
const getWorkingHours = (providesAvailability) =>
providesAvailability.availability && providesAvailability.availability.length
? providesAvailability.availability
: null;
const workingHours: [] =
getWorkingHours(eventType) ||
getWorkingHours(user) ||
[
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: user.startTime,
endTime: user.endTime,
},
].filter((availability): boolean => typeof availability["days"] !== "undefined");
workingHours.sort((a, b) => a.startTime - b.startTime);
return { return {
props: { props: {
user, user,
eventType, eventType,
workingHours,
}, },
}; };
}; };

View file

@ -147,8 +147,8 @@ export default function Book(props: any): JSX.Element {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="max-w-3xl mx-auto my-24"> <main className="max-w-3xl mx-auto my-0 sm:my-24">
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="bg-white overflow-hidden sm:shadow sm:rounded-lg">
<div className="sm:flex px-4 py-5 sm:p-6"> <div className="sm:flex px-4 py-5 sm:p-6">
<div className="sm:w-1/2 sm:border-r"> <div className="sm:w-1/2 sm:border-r">
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" /> <Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
@ -171,9 +171,9 @@ export default function Book(props: any): JSX.Element {
.tz(preferredTimeZone) .tz(preferredTimeZone)
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")} .format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
</p> </p>
<p className="text-gray-600">{props.eventType.description}</p> <p className="text-gray-600 mb-8">{props.eventType.description}</p>
</div> </div>
<div className="sm:w-1/2 pl-8 pr-4"> <div className="sm:w-1/2 sm:pl-8 sm:pr-4">
<form onSubmit={bookingHandler}> <form onSubmit={bookingHandler}>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700"> <label htmlFor="name" className="block text-sm font-medium text-gray-700">

View file

@ -1,6 +1,6 @@
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 });
@ -10,7 +10,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
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,
@ -25,57 +24,88 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
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: { createMany: {
data: req.body.customInputs.filter(input => !input.id).map(input => ({ data: req.body.customInputs
.filter((input) => !input.id)
.map((input) => ({
type: input.type, type: input.type,
label: input.label, label: input.label,
required: input.required required: input.required,
})) })),
}, },
update: req.body.customInputs.filter(input => !!input.id).map(input => ({ update: req.body.customInputs
.filter((input) => !!input.id)
.map((input) => ({
data: { data: {
type: input.type, type: input.type,
label: input.label, label: input.label,
required: input.required required: input.required,
}, },
where: { where: {
id: input.id id: input.id,
} },
})) })),
}, },
}; };
if (req.method == "POST") { if (req.method == "POST") {
const createEventType = await prisma.eventType.create({ await prisma.eventType.create({
data: { data: {
userId: session.user.id, userId: session.user.id,
...data, ...data,
}, },
}); });
res.status(200).json({message: 'Event created successfully'}); res.status(200).json({ message: "Event created successfully" });
} else if (req.method == "PATCH") {
if (req.body.timeZone) {
data.timeZone = req.body.timeZone;
} }
else if (req.method == "PATCH") {
const updateEventType = await prisma.eventType.update({ 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: { where: {
id: req.body.id, id: req.body.id,
}, },
data, data,
}); });
res.status(200).json({message: 'Event updated successfully'}); res.status(200).json({ message: "Event updated successfully" });
} }
} }
if (req.method == "DELETE") { if (req.method == "DELETE") {
await prisma.eventType.delete({
const deleteEventType = await prisma.eventType.delete({
where: { where: {
id: req.body.id, id: req.body.id,
}, },
}); });
res.status(200).json({message: 'Event deleted successfully'}); res.status(200).json({ message: "Event deleted successfully" });
} }
} }

View file

@ -0,0 +1,30 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req});
if (!session) {
res.status(401).json({message: "Not authenticated"});
return;
}
if (req.method == "PATCH") {
const startMins = req.body.start;
const endMins = req.body.end;
const updateWeek = await prisma.schedule.update({
where: {
id: session.user.id,
},
data: {
startTime: startMins,
endTime: endMins
},
});
res.status(200).json({message: 'Start and end times updated successfully'});
}
}

View file

@ -1,17 +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, useSession } from "next-auth/client"; import { getSession } from "next-auth/client";
import { LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon } from "@heroicons/react/outline"; import { Scheduler } from "@components/ui/Scheduler";
import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput";
import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline";
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
import { PlusIcon } from "@heroicons/react/solid"; import { PlusIcon } from "@heroicons/react/solid";
export default function EventType(props: any): JSX.Element { import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
import timezone from "dayjs/plugin/timezone";
import { EventType, User, Availability } from "@prisma/client";
import { validJson } from "@lib/jsonUtils";
dayjs.extend(timezone);
type Props = {
user: User;
eventType: EventType;
locationOptions: OptionBase[];
availability: Availability[];
};
type OpeningHours = {
days: number[];
startTime: number;
endTime: number;
};
type DateOverride = {
date: string;
startTime: number;
endTime: number;
};
type EventTypeInput = {
id: number;
title: string;
slug: string;
description: string;
length: number;
hidden: boolean;
locations: unknown;
eventName: string;
customInputs: EventTypeCustomInput[];
timeZone: string;
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
};
export default function EventTypePage({
user,
eventType,
locationOptions,
availability,
}: Props): JSX.Element {
const router = useRouter(); const router = useRouter();
const inputOptions: OptionBase[] = [ const inputOptions: OptionBase[] = [
@ -21,17 +70,17 @@ export default function EventType(props: any): JSX.Element {
{ value: EventTypeCustomInputType.Bool, label: "Checkbox" }, { value: EventTypeCustomInputType.Bool, label: "Checkbox" },
]; ];
const [, loading] = useSession(); 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(eventType.locations || []);
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined); const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
const [locations, setLocations] = useState(props.eventType.locations || []);
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>();
@ -40,25 +89,23 @@ export default function EventType(props: any): JSX.Element {
const isHiddenRef = useRef<HTMLInputElement>(); const isHiddenRef = useRef<HTMLInputElement>();
const eventNameRef = useRef<HTMLInputElement>(); const eventNameRef = useRef<HTMLInputElement>();
if (loading) { useEffect(() => {
return <p className="text-gray-400">Loading...</p>; 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
await fetch("/api/availability/eventtype", { const payload: EventTypeInput = {
method: "PATCH", id: eventType.id,
body: JSON.stringify({
id: props.eventType.id,
title: enteredTitle, title: enteredTitle,
slug: enteredSlug, slug: enteredSlug,
description: enteredDescription, description: enteredDescription,
@ -67,7 +114,16 @@ export default function EventType(props: any): JSX.Element {
locations, locations,
eventName: enteredEventName, eventName: enteredEventName,
customInputs, customInputs,
}), timeZone: selectedTimeZone,
};
if (enteredAvailability) {
payload.availability = enteredAvailability;
}
await fetch("/api/availability/eventtype", {
method: "PATCH",
body: JSON.stringify(payload),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@ -81,7 +137,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",
}, },
@ -106,6 +162,30 @@ export default function EventType(props: any): JSX.Element {
setSelectedCustomInput(undefined); setSelectedCustomInput(undefined);
}; };
const updateLocations = (e) => {
e.preventDefault();
let details = {};
if (e.target.location.value === LocationType.InPerson) {
details = { address: e.target.address.value };
}
const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type);
if (existingIdx !== -1) {
const copy = locations;
copy[existingIdx] = { ...locations[existingIdx], ...details };
setLocations(copy);
} else {
setLocations(locations.concat({ type: e.target.location.value, ...details }));
}
setShowLocationModal(false);
};
const removeLocation = (selectedLocation) => {
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
};
const openEditCustomModel = (customInput: EventTypeCustomInput) => { const openEditCustomModel = (customInput: EventTypeCustomInput) => {
setSelectedCustomInput(customInput); setSelectedCustomInput(customInput);
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type)); setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
@ -147,30 +227,6 @@ export default function EventType(props: any): JSX.Element {
return null; return null;
}; };
const updateLocations = (e) => {
e.preventDefault();
let details = {};
if (e.target.location.value === LocationType.InPerson) {
details = { address: e.target.address.value };
}
const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type);
if (existingIdx !== -1) {
const copy = locations;
copy[existingIdx] = { ...locations[existingIdx], ...details };
setLocations(copy);
} else {
setLocations(locations.concat({ type: e.target.location.value, ...details }));
}
setShowLocationModal(false);
};
const removeLocation = (selectedLocation) => {
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
};
const updateCustom = (e) => { const updateCustom = (e) => {
e.preventDefault(); e.preventDefault();
@ -207,13 +263,13 @@ 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> <div className="grid grid-cols-3 gap-4">
<div className="mb-8"> <div className="col-span-3 sm:col-span-2">
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="bg-white overflow-hidden shadow rounded-lg mb-4">
<div className="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
<form onSubmit={updateEventTypeHandler}> <form onSubmit={updateEventTypeHandler}>
<div className="mb-4"> <div className="mb-4">
@ -229,7 +285,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>
@ -240,7 +296,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">
{location.hostname}/{props.user.username}/ {typeof location !== "undefined" ? location.hostname : ""}/{user.username}/
</span> </span>
<input <input
ref={slugRef} ref={slugRef}
@ -249,7 +305,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>
@ -390,7 +446,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">
@ -406,7 +462,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
@ -425,7 +481,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>
@ -484,7 +540,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">
@ -497,12 +553,24 @@ export default function EventType(props: any): JSX.Element {
</div> </div>
</div> </div>
</div> </div>
<hr className="my-4" />
<div>
<h3 className="mb-2">How do you want to offer your availability for this event type?</h3>
<Scheduler
setAvailability={setEnteredAvailability}
setTimeZone={setSelectedTimeZone}
timeZone={selectedTimeZone}
availability={availability}
/>
<div className="py-4 flex justify-end">
<Link href="/availability">
<a className="mr-2 btn btn-white">Cancel</a>
</Link>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Update Update
</button> </button>
<Link href="/availability"> </div>
<a className="ml-2 btn btn-white">Cancel</a> </div>
</Link>
</form> </form>
</div> </div>
</div> </div>
@ -649,9 +717,7 @@ export default function EventType(props: any): JSX.Element {
Is required Is required
</label> </label>
</div> </div>
<input type="hidden" name="id" id="id" value={selectedCustomInput?.id} /> <input type="hidden" name="id" id="id" value={selectedCustomInput?.id} />
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"> <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Save Save
@ -670,32 +736,55 @@ 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,
}, },
select: { select: {
username: true, username: true,
timeZone: true,
startTime: true,
endTime: true,
availability: true,
}, },
}); });
const eventType: EventType | null = await prisma.eventType.findUnique({
where: {
id: parseInt(query.type as string),
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
hidden: true,
locations: true,
eventName: true,
availability: true,
customInputs: true,
timeZone: true,
},
});
if (!eventType) {
return {
notFound: true,
};
}
const credentials = await prisma.credential.findMany({ const credentials = await prisma.credential.findMany({
where: { where: {
userId: user.id, userId: user.id,
@ -747,28 +836,28 @@ export async function getServerSideProps(context) {
// Assuming it's Microsoft Teams. // Assuming it's Microsoft Teams.
} }
const eventType = await prisma.eventType.findUnique({ const getAvailability = (providesAvailability) =>
where: { providesAvailability.availability && providesAvailability.availability.length
id: parseInt(context.query.type), ? providesAvailability.availability
: null;
const availability: Availability[] = getAvailability(eventType) ||
getAvailability(user) || [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: user.startTime,
endTime: user.endTime,
}, },
select: { ];
id: true,
title: true, availability.sort((a, b) => a.startTime - b.startTime);
slug: true,
description: true,
length: true,
hidden: true,
locations: true,
eventName: true,
customInputs: true,
},
});
return { return {
props: { props: {
user, user,
eventType, eventType,
locationOptions, locationOptions,
availability,
}, },
}; };
} };

View file

@ -2,6 +2,7 @@ import Head from "next/head";
import prisma from "../../lib/prisma"; import prisma from "../../lib/prisma";
import { getSession, useSession } from "next-auth/client"; import { getSession, useSession } from "next-auth/client";
import Shell from "../../components/Shell"; import Shell from "../../components/Shell";
import dayjs from "dayjs";
export default function Bookings({ bookings }) { export default function Bookings({ bookings }) {
const [session, loading] = useSession(); const [session, loading] = useSession();
@ -27,43 +28,39 @@ export default function Bookings({ bookings }) {
<th <th
scope="col" scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Title Person
</th> </th>
<th <th
scope="col" scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description Event
</th> </th>
<th {/* <th
scope="col" scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name Date
</th> </th> */}
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th scope="col" className="relative px-6 py-3"> <th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span> <span className="sr-only">Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{bookings.map((booking) => ( {bookings.map((booking) => (
<tr key={booking.uid}> <tr key={booking.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> <td className="px-6 py-4 whitespace-nowrap">
{booking.title} <div className="text-sm font-medium text-gray-900">{booking.attendees[0].name}</div>
<div className="text-sm text-gray-500">{booking.attendees[0].email}</div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap">
{booking.description} <div className="text-sm text-gray-900">{booking.title}</div>
</td> <div className="text-sm text-gray-500">{booking.description}</div>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{booking.attendees[0].name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{booking.attendees[0].email}
</td> </td>
{/* <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{dayjs(booking.startTime).format("D MMMM YYYY HH:mm")}
</div>
</td> */}
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a <a
href={window.location.href + "/../reschedule/" + booking.uid} href={window.location.href + "/../reschedule/" + booking.uid}
@ -115,6 +112,9 @@ export async function getServerSideProps(context) {
description: true, description: true,
attendees: true, attendees: true,
}, },
orderBy: {
startTime: "desc",
},
}); });
return { props: { bookings } }; return { props: { bookings } };

View file

@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "timeZone" TEXT;
-- CreateTable
CREATE TABLE "Availability" (
"id" SERIAL NOT NULL,
"label" TEXT,
"userId" INTEGER,
"eventTypeId" INTEGER,
"days" INTEGER[],
"startTime" INTEGER NOT NULL,
"endTime" INTEGER NOT NULL,
"date" DATE,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Availability" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Availability" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -21,8 +21,10 @@ model EventType {
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId Int? userId Int?
bookings Booking[] bookings Booking[]
availability Availability[]
eventName String? eventName String?
customInputs EventTypeCustomInput[] customInputs EventTypeCustomInput[]
timeZone String?
} }
model Credential { model Credential {
@ -53,7 +55,9 @@ model User {
credentials Credential[] credentials Credential[]
teams Membership[] teams Membership[]
bookings Booking[] bookings Booking[]
availability Availability[]
selectedCalendars SelectedCalendar[] selectedCalendars SelectedCalendar[]
@@map(name: "users") @@map(name: "users")
} }
@ -126,6 +130,19 @@ model Booking {
updatedAt DateTime? updatedAt DateTime?
} }
model Availability {
id Int @default(autoincrement()) @id
label String?
user User? @relation(fields: [userId], references: [id])
userId Int?
eventType EventType? @relation(fields: [eventTypeId], references: [id])
eventTypeId Int?
days Int[]
startTime Int
endTime Int
date DateTime? @db.Date
}
model SelectedCalendar { model SelectedCalendar {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int userId Int
@ -150,4 +167,3 @@ model ResetPasswordRequest {
email String email String
expires DateTime expires DateTime
} }

View file

@ -136,3 +136,23 @@ body {
#timeZone input:focus { #timeZone input:focus {
box-shadow: none; box-shadow: none;
} }
.weekdaySelect {
font-family: "Courier New", sans-serif;
}
.weekdaySelect button.active:first-child {
margin-left: -1px !important;
}
.weekdaySelect button:not(.active) {
padding-left: calc(0.5rem + 0px);
margin-right: 1px;
}
.weekdaySelect button.active + button.active {
border-color: rgba(3, 169, 244, var(--tw-border-opacity))
rgba(3, 169, 244, var(--tw-border-opacity))
rgba(3, 169, 244, var(--tw-border-opacity))
white;
}

56
test/lib/slots.test.ts Normal file
View file

@ -0,0 +1,56 @@
import getSlots from '@lib/slots';
import {it, expect} from '@jest/globals';
import MockDate from 'mockdate';
import dayjs, {Dayjs} from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
MockDate.set('2021-06-20T11:59:59Z');
it('can fit 24 hourly slots for an empty day', async () => {
// 24h in a day.
expect(getSlots({
inviteeDate: dayjs().add(1, 'day'),
frequency: 60,
workingHours: [
{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }
],
organizerTimeZone: 'Europe/London'
})).toHaveLength(24);
});
it('only shows future booking slots on the same day', async () => {
// The mock date is 1s to midday, so 12 slots should be open given 0 booking notice.
expect(getSlots({
inviteeDate: dayjs(),
frequency: 60,
workingHours: [
{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }
],
organizerTimeZone: 'GMT'
})).toHaveLength(12);
});
it('can cut off dates that due to invitee timezone differences fall on the next day', async () => {
expect(getSlots({
inviteeDate: dayjs().tz('Europe/Amsterdam').startOf('day'), // time translation +01:00
frequency: 60,
workingHours: [
{ days: [0], startTime: 1380, endTime: 1440 }
],
organizerTimeZone: 'Europe/London'
})).toHaveLength(0);
});
it('can cut off dates that due to invitee timezone differences fall on the previous day', async () => {
expect(getSlots({
inviteeDate: dayjs().startOf('day'), // time translation -01:00
frequency: 60,
workingHours: [
{ days: [0], startTime: 0, endTime: 60 }
],
organizerTimeZone: 'Europe/London'
})).toHaveLength(0);
});

View file

@ -6,6 +6,11 @@
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@lib/*": ["lib/*"]
},
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": false, "strict": false,

2652
yarn.lock

File diff suppressed because it is too large Load diff