Resolved conflicts
This commit is contained in:
commit
3c09837104
37 changed files with 3980 additions and 1192 deletions
3
.babelrc
Normal file
3
.babelrc
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"presets": ["next/babel"]
|
||||||
|
}
|
16
.editorconfig
Normal file
16
.editorconfig
Normal 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
|
|
@ -24,7 +24,8 @@
|
||||||
"env": {
|
"env": {
|
||||||
"browser": true,
|
"browser": true,
|
||||||
"node": true,
|
"node": true,
|
||||||
"es6": true
|
"es6": true,
|
||||||
|
"jest": true
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"react": {
|
"react": {
|
||||||
|
|
|
@ -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
|
||||||
[](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)
|
[](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.
|
||||||
|
|
|
@ -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>
|
||||||
|
|
135
components/booking/DatePicker.tsx
Normal file
135
components/booking/DatePicker.tsx
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
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;
|
97
components/booking/Slots.tsx
Normal file
97
components/booking/Slots.tsx
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import { useEffect, useState } 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;
|
|
@ -1,39 +1,38 @@
|
||||||
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 { is24h, timeZone } 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(() => {
|
||||||
setIs24hClock(is24h());
|
setIs24hClock(is24h());
|
||||||
setSelectedTimeZone(timeZone());
|
setSelectedTimeZone(timeZone());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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;
|
19
components/ui/PoweredByCalendso.tsx
Normal file
19
components/ui/PoweredByCalendso.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
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;
|
144
components/ui/Scheduler.tsx
Normal file
144
components/ui/Scheduler.tsx
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
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")}
|
||||||
|
until
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
53
components/ui/WeekdaySelect.tsx
Normal file
53
components/ui/WeekdaySelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
146
components/ui/modal/SetTimesModal.tsx
Normal file
146
components/ui/modal/SetTimesModal.tsx
Normal 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">
|
||||||
|
​
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -102,15 +102,13 @@ const o365Auth = (credential) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
interface Person {
|
interface Person {
|
||||||
name?: string;
|
name?: string;
|
||||||
email: string;
|
email: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
export interface CalendarEvent {
|
||||||
interface CalendarEvent {
|
|
||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
@ -122,28 +120,25 @@ interface CalendarEvent {
|
||||||
conferenceData?: ConferenceData;
|
conferenceData?: ConferenceData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
export interface ConferenceData {
|
||||||
interface ConferenceData {
|
createRequest: unknown;
|
||||||
createRequest: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
export interface IntegrationCalendar {
|
||||||
interface IntegrationCalendar {
|
|
||||||
integration: string;
|
integration: string;
|
||||||
primary: boolean;
|
primary: boolean;
|
||||||
externalId: string;
|
externalId: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
export interface CalendarApiAdapter {
|
||||||
interface CalendarApiAdapter {
|
createEvent(event: CalendarEvent): Promise<unknown>;
|
||||||
createEvent(event: CalendarEvent): Promise<any>;
|
|
||||||
|
|
||||||
updateEvent(uid: string, event: CalendarEvent);
|
updateEvent(uid: string, event: CalendarEvent);
|
||||||
|
|
||||||
deleteEvent(uid: string);
|
deleteEvent(uid: string);
|
||||||
|
|
||||||
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>;
|
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
|
||||||
|
|
||||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||||
}
|
}
|
||||||
|
@ -375,6 +370,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
auth: myGoogleAuth,
|
auth: myGoogleAuth,
|
||||||
calendarId: "primary",
|
calendarId: "primary",
|
||||||
resource: payload,
|
resource: payload,
|
||||||
|
conferenceDataVersion: 1,
|
||||||
},
|
},
|
||||||
function (err, event) {
|
function (err, event) {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
@ -508,15 +504,29 @@ const listCalendars = (withCredentials) =>
|
||||||
results.reduce((acc, calendars) => acc.concat(calendars), [])
|
results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => {
|
const createEvent = async (credential, calEvent: CalendarEvent): Promise<unknown> => {
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
const uid: string = parser.getUid();
|
const uid: string = parser.getUid();
|
||||||
const richEvent: CalendarEvent = parser.asRichEvent();
|
const richEvent: CalendarEvent = parser.asRichEvent();
|
||||||
|
|
||||||
const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null;
|
const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null;
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerMail(calEvent, uid);
|
const maybeHangoutLink = creationResult?.hangoutLink;
|
||||||
const attendeeMail = new EventAttendeeMail(calEvent, uid);
|
const maybeEntryPoints = creationResult?.entryPoints;
|
||||||
|
const maybeConferenceData = creationResult?.conferenceData;
|
||||||
|
|
||||||
|
const organizerMail = new EventOrganizerMail(calEvent, uid, {
|
||||||
|
hangoutLink: maybeHangoutLink,
|
||||||
|
conferenceData: maybeConferenceData,
|
||||||
|
entryPoints: maybeEntryPoints,
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendeeMail = new EventAttendeeMail(calEvent, uid, {
|
||||||
|
hangoutLink: maybeHangoutLink,
|
||||||
|
conferenceData: maybeConferenceData,
|
||||||
|
entryPoints: maybeEntryPoints,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -537,7 +547,7 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> =>
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise<any> => {
|
const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise<unknown> => {
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
const newUid: string = parser.getUid();
|
const newUid: string = parser.getUid();
|
||||||
const richEvent: CalendarEvent = parser.asRichEvent();
|
const richEvent: CalendarEvent = parser.asRichEvent();
|
||||||
|
@ -568,7 +578,7 @@ const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEv
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteEvent = (credential, uid: string): Promise<any> => {
|
const deleteEvent = (credential, uid: string): Promise<unknown> => {
|
||||||
if (credential) {
|
if (credential) {
|
||||||
return calendars([credential])[0].deleteEvent(uid);
|
return calendars([credential])[0].deleteEvent(uid);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import EventMail from "./EventMail";
|
||||||
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 localizedFormat from "dayjs/plugin/localizedFormat";
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
dayjs.extend(localizedFormat);
|
dayjs.extend(localizedFormat);
|
||||||
|
@ -28,7 +29,7 @@ export default class EventAttendeeMail extends EventMail {
|
||||||
)} is scheduled.<br />
|
)} is scheduled.<br />
|
||||||
<br />` +
|
<br />` +
|
||||||
this.getAdditionalBody() +
|
this.getAdditionalBody() +
|
||||||
(this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "") +
|
"<br />" +
|
||||||
`<strong>Additional notes:</strong><br />
|
`<strong>Additional notes:</strong><br />
|
||||||
${this.calEvent.description}<br />
|
${this.calEvent.description}<br />
|
||||||
` +
|
` +
|
||||||
|
@ -39,6 +40,38 @@ export default class EventAttendeeMail extends EventMail {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the video call information to the mail body.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getLocation(): string {
|
||||||
|
if (this.additionInformation?.hangoutLink) {
|
||||||
|
return `<strong>Location:</strong> <a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
|
||||||
|
const locations = this.additionInformation?.entryPoints
|
||||||
|
.map((entryPoint) => {
|
||||||
|
return `
|
||||||
|
Join by ${entryPoint.entryPointType}: <br />
|
||||||
|
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("<br />");
|
||||||
|
|
||||||
|
return `<strong>Locations:</strong><br /> ${locations}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAdditionalBody(): string {
|
||||||
|
return `
|
||||||
|
${this.getLocation()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the payload object for the nodemailer.
|
* Returns the payload object for the nodemailer.
|
||||||
*
|
*
|
||||||
|
|
|
@ -1,13 +1,31 @@
|
||||||
import { CalendarEvent } from "../calendarClient";
|
|
||||||
import { serverConfig } from "../serverConfig";
|
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
import CalEventParser from "../CalEventParser";
|
import CalEventParser from "../CalEventParser";
|
||||||
import { stripHtml } from "./helpers";
|
import { stripHtml } from "./helpers";
|
||||||
|
import { CalendarEvent, ConferenceData } from "../calendarClient";
|
||||||
|
import { serverConfig } from "../serverConfig";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
interface EntryPoint {
|
||||||
|
entryPointType?: string;
|
||||||
|
uri?: string;
|
||||||
|
label?: string;
|
||||||
|
pin?: string;
|
||||||
|
accessCode?: string;
|
||||||
|
meetingCode?: string;
|
||||||
|
passcode?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdditionInformation {
|
||||||
|
conferenceData?: ConferenceData;
|
||||||
|
entryPoints?: EntryPoint[];
|
||||||
|
hangoutLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default abstract class EventMail {
|
export default abstract class EventMail {
|
||||||
calEvent: CalendarEvent;
|
calEvent: CalendarEvent;
|
||||||
parser: CalEventParser;
|
parser: CalEventParser;
|
||||||
uid: string;
|
uid: string;
|
||||||
|
additionInformation?: AdditionInformation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An EventMail always consists of a CalendarEvent
|
* An EventMail always consists of a CalendarEvent
|
||||||
|
@ -17,10 +35,11 @@ export default abstract class EventMail {
|
||||||
* @param calEvent
|
* @param calEvent
|
||||||
* @param uid
|
* @param uid
|
||||||
*/
|
*/
|
||||||
constructor(calEvent: CalendarEvent, uid: string) {
|
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
|
||||||
this.calEvent = calEvent;
|
this.calEvent = calEvent;
|
||||||
this.uid = uid;
|
this.uid = uid;
|
||||||
this.parser = new CalEventParser(calEvent);
|
this.parser = new CalEventParser(calEvent);
|
||||||
|
this.additionInformation = additionInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -88,6 +107,8 @@ export default abstract class EventMail {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected abstract getLocation(): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints out the desired information when an error
|
* Prints out the desired information when an error
|
||||||
* occured while sending the mail.
|
* occured while sending the mail.
|
||||||
|
|
|
@ -34,7 +34,7 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
stripHtml(this.getAdditionalFooter()),
|
stripHtml(this.getAdditionalFooter()),
|
||||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||||
attendees: this.calEvent.attendees.map((attendee: any) => ({
|
attendees: this.calEvent.attendees.map((attendee: unknown) => ({
|
||||||
name: attendee.name,
|
name: attendee.name,
|
||||||
email: attendee.email,
|
email: attendee.email,
|
||||||
})),
|
})),
|
||||||
|
@ -66,13 +66,7 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
|
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
|
||||||
<br />` +
|
<br />` +
|
||||||
this.getAdditionalBody() +
|
this.getAdditionalBody() +
|
||||||
(this.calEvent.location
|
"<br />" +
|
||||||
? `
|
|
||||||
<strong>Location:</strong><br />
|
|
||||||
${this.calEvent.location}<br />
|
|
||||||
<br />
|
|
||||||
`
|
|
||||||
: "") +
|
|
||||||
`<strong>Invitee Time Zone:</strong><br />
|
`<strong>Invitee Time Zone:</strong><br />
|
||||||
${this.calEvent.attendees[0].timeZone}<br />
|
${this.calEvent.attendees[0].timeZone}<br />
|
||||||
<br />
|
<br />
|
||||||
|
@ -86,6 +80,37 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the video call information to the mail body.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getLocation(): string {
|
||||||
|
if (this.additionInformation?.hangoutLink) {
|
||||||
|
return `<strong>Location:</strong> <a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
|
||||||
|
const locations = this.additionInformation?.entryPoints
|
||||||
|
.map((entryPoint) => {
|
||||||
|
return `
|
||||||
|
Join by ${entryPoint.entryPointType}: <br />
|
||||||
|
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("<br />");
|
||||||
|
|
||||||
|
return `<strong>Locations:</strong><br /> ${locations}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAdditionalBody(): string {
|
||||||
|
return `
|
||||||
|
${this.getLocation()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Returns the payload object for the nodemailer.
|
* Returns the payload object for the nodemailer.
|
||||||
*
|
*
|
||||||
|
|
11
lib/jsonUtils.ts
Normal file
11
lib/jsonUtils.ts
Normal 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;
|
||||||
|
};
|
|
@ -1,9 +1,9 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
let prisma: PrismaClient;
|
let prisma: PrismaClient;
|
||||||
const globalAny:any = global;
|
const globalAny: any = global;
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === "production") {
|
||||||
prisma = new PrismaClient();
|
prisma = new PrismaClient();
|
||||||
} else {
|
} else {
|
||||||
if (!globalAny.prisma) {
|
if (!globalAny.prisma) {
|
||||||
|
@ -12,4 +12,27 @@ if (process.env.NODE_ENV === 'production') {
|
||||||
prisma = globalAny.prisma;
|
prisma = globalAny.prisma;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluck = (select: Record<string, boolean>, attr: string) => {
|
||||||
|
const parts = attr.split(".");
|
||||||
|
const alwaysAttr = parts[0];
|
||||||
|
const pluckedValue =
|
||||||
|
parts.length > 1
|
||||||
|
? {
|
||||||
|
select: pluck(select[alwaysAttr] ? select[alwaysAttr].select : {}, parts.slice(1).join(".")),
|
||||||
|
}
|
||||||
|
: true;
|
||||||
|
return {
|
||||||
|
...select,
|
||||||
|
[alwaysAttr]: pluckedValue,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const whereAndSelect = (modelQuery, criteria: Record<string, unknown>, pluckedAttributes: string[]) =>
|
||||||
|
modelQuery({
|
||||||
|
where: criteria,
|
||||||
|
select: pluckedAttributes.reduce(pluck, {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export { whereAndSelect };
|
||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
207
lib/slots.ts
207
lib/slots.ts
|
@ -1,94 +1,135 @@
|
||||||
const dayjs = require("dayjs");
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
|
||||||
const isToday = require("dayjs/plugin/isToday");
|
|
||||||
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;
|
||||||
|
|
18
package.json
18
package.json
|
@ -4,6 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"test": "node 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { ChevronDownIcon, ClockIcon, GlobeIcon } 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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import '../styles/globals.css';
|
import "../styles/globals.css";
|
||||||
import {createTelemetryClient, TelemetryProvider} from '../lib/telemetry';
|
import { createTelemetryClient, TelemetryProvider } from "../lib/telemetry";
|
||||||
import { Provider } from 'next-auth/client';
|
import { Provider } from "next-auth/client";
|
||||||
|
import type { AppProps } from "next/app";
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }) {
|
function MyApp({ Component, pageProps }: AppProps) {
|
||||||
return (
|
return (
|
||||||
<TelemetryProvider value={createTelemetryClient()}>
|
<TelemetryProvider value={createTelemetryClient()}>
|
||||||
<Provider session={pageProps.session}>
|
<Provider session={pageProps.session}>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import prisma from '../../../lib/prisma';
|
import prisma from "../../../lib/prisma";
|
||||||
import {getBusyCalendarTimes} from '../../../lib/calendarClient';
|
import { getBusyCalendarTimes } from "../../../lib/calendarClient";
|
||||||
import {getBusyVideoTimes} from '../../../lib/videoClient';
|
import { getBusyVideoTimes } from "../../../lib/videoClient";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { user } = req.query
|
const { user } = req.query;
|
||||||
|
|
||||||
const currentUser = await prisma.user.findFirst({
|
const currentUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
@ -14,35 +14,49 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
select: {
|
select: {
|
||||||
credentials: true,
|
credentials: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
bufferTime: true
|
bufferTime: true,
|
||||||
}
|
id: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCalendars = (await prisma.selectedCalendar.findMany({
|
const selectedCalendars = await prisma.selectedCalendar.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: currentUser.id
|
userId: currentUser.id,
|
||||||
}
|
},
|
||||||
}));
|
});
|
||||||
|
|
||||||
const hasCalendarIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith('_calendar')).length > 0;
|
const hasCalendarIntegrations =
|
||||||
const hasVideoIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith('_video')).length > 0;
|
currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
|
||||||
|
const hasVideoIntegrations =
|
||||||
|
currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0;
|
||||||
|
|
||||||
const calendarAvailability = await getBusyCalendarTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars);
|
const calendarAvailability = await getBusyCalendarTimes(
|
||||||
const videoAvailability = await getBusyVideoTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo);
|
currentUser.credentials,
|
||||||
|
req.query.dateFrom,
|
||||||
|
req.query.dateTo,
|
||||||
|
selectedCalendars
|
||||||
|
);
|
||||||
|
const videoAvailability = await getBusyVideoTimes(
|
||||||
|
currentUser.credentials,
|
||||||
|
req.query.dateFrom,
|
||||||
|
req.query.dateTo
|
||||||
|
);
|
||||||
|
|
||||||
let commonAvailability = [];
|
let commonAvailability = [];
|
||||||
|
|
||||||
if(hasCalendarIntegrations && hasVideoIntegrations) {
|
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
||||||
commonAvailability = calendarAvailability.filter(availability => videoAvailability.includes(availability));
|
commonAvailability = calendarAvailability.filter((availability) =>
|
||||||
} else if(hasVideoIntegrations) {
|
videoAvailability.includes(availability)
|
||||||
|
);
|
||||||
|
} else if (hasVideoIntegrations) {
|
||||||
commonAvailability = videoAvailability;
|
commonAvailability = videoAvailability;
|
||||||
} else if(hasCalendarIntegrations) {
|
} else if (hasCalendarIntegrations) {
|
||||||
commonAvailability = calendarAvailability;
|
commonAvailability = calendarAvailability;
|
||||||
}
|
}
|
||||||
|
|
||||||
commonAvailability = commonAvailability.map(a => ({
|
commonAvailability = commonAvailability.map((a) => ({
|
||||||
start: dayjs(a.start).subtract(currentUser.bufferTime, 'minute').toString(),
|
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
||||||
end: dayjs(a.end).add(currentUser.bufferTime, 'minute').toString()
|
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.status(200).json(commonAvailability);
|
res.status(200).json(commonAvailability);
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
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,
|
||||||
|
@ -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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
29
pages/api/availability/week.ts
Normal file
29
pages/api/availability/week.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,18 +1,16 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import prisma from '../../lib/prisma';
|
import prisma from "../../lib/prisma";
|
||||||
import {getSession} from "next-auth/client";
|
import { getSession } from "next-auth/client";
|
||||||
|
|
||||||
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 === "POST") {
|
if (req.method === "POST") {
|
||||||
|
|
||||||
// TODO: Prevent creating a team with identical names?
|
// TODO: Prevent creating a team with identical names?
|
||||||
|
|
||||||
const createTeam = await prisma.team.create({
|
const createTeam = await prisma.team.create({
|
||||||
|
@ -21,17 +19,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMembership = await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
teamId: createTeam.id,
|
teamId: createTeam.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
role: 'OWNER',
|
role: "OWNER",
|
||||||
accepted: true,
|
accepted: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(201).setHeader('Location', process.env.BASE_URL + '/api/teams/1').send(null);
|
return res.status(201).json({ message: "Team created" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(404).send(null);
|
res.status(404).json({ message: "Team not found" });
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,67 @@
|
||||||
|
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 { Scheduler } from "@components/ui/Scheduler";
|
||||||
|
|
||||||
import { LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon } from "@heroicons/react/outline";
|
import { LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon } 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";
|
||||||
|
|
||||||
export default function EventType(props: any): JSX.Element {
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import { Availability, EventType, User } from "@prisma/client";
|
||||||
|
import { validJson } from "@lib/jsonUtils";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
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 +71,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 +90,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 +115,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 +138,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 +163,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 +228,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 +264,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 +286,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 +297,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 +306,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 +447,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 +463,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 +482,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 +541,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 +554,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 +718,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 +737,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 +837,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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { getSession, useSession } from "next-auth/client";
|
||||||
import Shell from "../../components/Shell";
|
import Shell from "../../components/Shell";
|
||||||
|
|
||||||
export default function Bookings({ bookings }) {
|
export default function Bookings({ bookings }) {
|
||||||
const [session, loading] = useSession();
|
const [, loading] = useSession();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-gray-400">Loading...</p>;
|
return <p className="text-gray-400">Loading...</p>;
|
||||||
|
@ -27,43 +27,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 +111,9 @@ export async function getServerSideProps(context) {
|
||||||
description: true,
|
description: true,
|
||||||
attendees: true,
|
attendees: true,
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
startTime: "desc",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { props: { bookings } };
|
return { props: { bookings } };
|
||||||
|
|
|
@ -1,31 +1,41 @@
|
||||||
import Head from 'next/head';
|
import { GetServerSideProps } from "next";
|
||||||
import prisma from '../../lib/prisma';
|
import Head from "next/head";
|
||||||
import Modal from '../../components/Modal';
|
import Shell from "../../components/Shell";
|
||||||
import Shell from '../../components/Shell';
|
import SettingsShell from "../../components/Settings";
|
||||||
import SettingsShell from '../../components/Settings';
|
import { useEffect, useState } from "react";
|
||||||
import { useEffect, useState } from 'react';
|
import type { Session } from "next-auth";
|
||||||
import { useSession, getSession } from 'next-auth/client';
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import {
|
import { UsersIcon } from "@heroicons/react/outline";
|
||||||
UsersIcon,
|
|
||||||
} from "@heroicons/react/outline";
|
|
||||||
import TeamList from "../../components/team/TeamList";
|
import TeamList from "../../components/team/TeamList";
|
||||||
import TeamListItem from "../../components/team/TeamListItem";
|
import TeamListItem from "../../components/team/TeamListItem";
|
||||||
|
|
||||||
export default function Teams(props) {
|
export default function Teams() {
|
||||||
|
const [, loading] = useSession();
|
||||||
const [session, loading] = useSession();
|
|
||||||
const [teams, setTeams] = useState([]);
|
const [teams, setTeams] = useState([]);
|
||||||
const [invites, setInvites] = useState([]);
|
const [invites, setInvites] = useState([]);
|
||||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||||
|
|
||||||
const loadTeams = () => fetch('/api/user/membership').then((res: any) => res.json()).then(
|
const handleErrors = async (resp) => {
|
||||||
(data) => {
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.message);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
fetch("/api/user/membership")
|
||||||
|
.then(handleErrors)
|
||||||
|
.then((data) => {
|
||||||
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
|
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
|
||||||
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
|
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
|
||||||
}
|
})
|
||||||
);
|
.catch(console.log);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => { loadTeams(); }, []);
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-gray-400">Loading...</p>;
|
return <p className="text-gray-400">Loading...</p>;
|
||||||
|
@ -33,17 +43,18 @@ export default function Teams(props) {
|
||||||
|
|
||||||
const createTeam = (e) => {
|
const createTeam = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return fetch('/api/teams', {
|
|
||||||
method: 'POST',
|
return fetch("/api/teams", {
|
||||||
body: JSON.stringify({ name: e.target.elements['name'].value }),
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: e.target.elements["name"].value }),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
loadTeams();
|
loadData();
|
||||||
setShowCreateTeamModal(false);
|
setShowCreateTeamModal(false);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell heading="Teams">
|
<Shell heading="Teams">
|
||||||
|
@ -60,10 +71,12 @@ export default function Teams(props) {
|
||||||
<p className="mt-1 text-sm text-gray-500 mb-4">
|
<p className="mt-1 text-sm text-gray-500 mb-4">
|
||||||
View, edit and create teams to organise relationships between users
|
View, edit and create teams to organise relationships between users
|
||||||
</p>
|
</p>
|
||||||
{!(invites.length || teams.length) &&
|
{!(invites.length || teams.length) && (
|
||||||
<div className="bg-gray-50 sm:rounded-lg">
|
<div className="bg-gray-50 sm:rounded-lg">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="px-4 py-5 sm:p-6">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Create a team to get started</h3>
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Create a team to get started
|
||||||
|
</h3>
|
||||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||||
<p>Create your first team and invite other users to work together with you.</p>
|
<p>Create your first team and invite other users to work together with you.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -71,31 +84,35 @@ export default function Teams(props) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCreateTeamModal(true)}
|
onClick={() => setShowCreateTeamModal(true)}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary">
|
||||||
>
|
|
||||||
Create new team
|
Create new team
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!!(invites.length || teams.length) && <div>
|
{!!(invites.length || teams.length) && (
|
||||||
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>Create new team</button>
|
<div>
|
||||||
</div>}
|
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>
|
||||||
|
Create new team
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{!!teams.length &&
|
{!!teams.length && <TeamList teams={teams} onChange={loadData}></TeamList>}
|
||||||
<TeamList teams={teams} onChange={loadTeams}>
|
|
||||||
</TeamList>
|
|
||||||
}
|
|
||||||
|
|
||||||
{!!invites.length && <div>
|
{!!invites.length && (
|
||||||
|
<div>
|
||||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
|
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
|
||||||
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
|
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
|
||||||
{invites.map((team) => <TeamListItem onChange={loadTeams} key={team.id} team={team}></TeamListItem>)}
|
{invites.map((team) => (
|
||||||
|
<TeamListItem onChange={loadData} key={team.id} team={team}></TeamListItem>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>}
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/*{teamsLoaded && <div className="flex justify-between">
|
{/*{teamsLoaded && <div className="flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
@ -111,12 +128,20 @@ export default function Teams(props) {
|
||||||
</div>}*/}
|
</div>}*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showCreateTeamModal &&
|
{showCreateTeamModal && (
|
||||||
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
<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="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>
|
<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">​</span>
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left 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="sm:flex sm:items-start mb-4">
|
||||||
|
@ -124,24 +149,36 @@ export default function Teams(props) {
|
||||||
<UsersIcon className="h-6 w-6 text-blue-600" />
|
<UsersIcon className="h-6 w-6 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<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">Create a new team</h3>
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||||
|
Create a new team
|
||||||
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">Create a new team to collaborate with users.</p>
|
||||||
Create a new team to collaborate with users.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={createTeam}>
|
<form onSubmit={createTeam}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
<input type="text" name="name" id="name" placeholder="Acme Inc." required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
placeholder="Acme Inc."
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
Create team
|
Create team
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowCreateTeamModal(false)} type="button" className="btn btn-white mr-2">
|
<button
|
||||||
|
onClick={() => setShowCreateTeamModal(false)}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-white mr-2">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -149,8 +186,20 @@ export default function Teams(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the `session` prop to use sessions with Server Side Rendering
|
||||||
|
export const getServerSideProps: GetServerSideProps<{ session: Session | null }> = async (context) => {
|
||||||
|
const session = await getSession(context);
|
||||||
|
if (!session) {
|
||||||
|
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: { session },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -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;
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
108
test/lib/prisma.test.ts
Normal file
108
test/lib/prisma.test.ts
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import { expect, it } from "@jest/globals";
|
||||||
|
import { whereAndSelect } from "@lib/prisma";
|
||||||
|
|
||||||
|
it("can decorate using whereAndSelect", async () => {
|
||||||
|
whereAndSelect(
|
||||||
|
(queryObj) => {
|
||||||
|
expect(queryObj).toStrictEqual({ where: { id: 1 }, select: { example: true } });
|
||||||
|
},
|
||||||
|
{ id: 1 },
|
||||||
|
[
|
||||||
|
"example",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can do nested selects using . seperator", async () => {
|
||||||
|
|
||||||
|
whereAndSelect(
|
||||||
|
(queryObj) => {
|
||||||
|
expect(queryObj).toStrictEqual({
|
||||||
|
where: {
|
||||||
|
uid: 1,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
description: true,
|
||||||
|
attendees: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ uid: 1 },
|
||||||
|
[
|
||||||
|
"description",
|
||||||
|
"attendees.email",
|
||||||
|
"attendees.name",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can handle nesting deeply", async () => {
|
||||||
|
whereAndSelect(
|
||||||
|
(queryObj) => {
|
||||||
|
expect(queryObj).toStrictEqual({
|
||||||
|
where: {
|
||||||
|
uid: 1,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
description: true,
|
||||||
|
attendees: {
|
||||||
|
select: {
|
||||||
|
email: {
|
||||||
|
select: {
|
||||||
|
nested: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ uid: 1 },
|
||||||
|
[
|
||||||
|
"description",
|
||||||
|
"attendees.email.nested",
|
||||||
|
"attendees.name",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can handle nesting multiple", async () => {
|
||||||
|
whereAndSelect(
|
||||||
|
(queryObj) => {
|
||||||
|
expect(queryObj).toStrictEqual({
|
||||||
|
where: {
|
||||||
|
uid: 1,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
description: true,
|
||||||
|
attendees: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bookings: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ uid: 1 },
|
||||||
|
[
|
||||||
|
"description",
|
||||||
|
"attendees.email",
|
||||||
|
"attendees.name",
|
||||||
|
"bookings.id",
|
||||||
|
"bookings.name",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
57
test/lib/slots.test.ts
Normal file
57
test/lib/slots.test.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import getSlots from "@lib/slots";
|
||||||
|
import { expect, it } from "@jest/globals";
|
||||||
|
import MockDate from "mockdate";
|
||||||
|
import 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);
|
||||||
|
});
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue