Merge branch 'main' into google-meet
This commit is contained in:
commit
5112347293
36 changed files with 4142 additions and 1311 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": {
|
||||||
|
|
17
README.md
17
README.md
|
@ -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
|
||||||
|
@ -155,6 +156,17 @@ You will also need Google API credentials. You can get this from the [Google API
|
||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
5. Enjoy the new version.
|
5. Enjoy the new version.
|
||||||
|
<!-- 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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
<!-- ROADMAP -->
|
<!-- ROADMAP -->
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
@ -239,4 +251,3 @@ Special thanks to these amazing projects which help power Calendso:
|
||||||
* [Prisma](https://prisma.io/)
|
* [Prisma](https://prisma.io/)
|
||||||
|
|
||||||
[product-screenshot]: https://i.imgur.com/4yvFj2E.png
|
[product-screenshot]: https://i.imgur.com/4yvFj2E.png
|
||||||
|
|
||||||
|
|
|
@ -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} isn't available at this time.</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>
|
||||||
|
|
134
components/booking/DatePicker.tsx
Normal file
134
components/booking/DatePicker.tsx
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import getSlots from "@lib/slots";
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
const DatePicker = ({
|
||||||
|
weekStart,
|
||||||
|
onDatePicked,
|
||||||
|
workingHours,
|
||||||
|
organizerTimeZone,
|
||||||
|
inviteeTimeZone,
|
||||||
|
eventLength,
|
||||||
|
}) => {
|
||||||
|
const [calendar, setCalendar] = useState([]);
|
||||||
|
const [selectedMonth, setSelectedMonth]: number = useState();
|
||||||
|
const [selectedDate, setSelectedDate]: Dayjs = useState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedMonth(dayjs().tz(inviteeTimeZone).month());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate) onDatePicked(selectedDate);
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
// Handle month changes
|
||||||
|
const incrementMonth = () => {
|
||||||
|
setSelectedMonth(selectedMonth + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrementMonth = () => {
|
||||||
|
setSelectedMonth(selectedMonth - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
// wish next had a way of dealing with this magically;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth);
|
||||||
|
|
||||||
|
const isDisabled = (day: number) => {
|
||||||
|
const date: Dayjs = inviteeDate.date(day);
|
||||||
|
return (
|
||||||
|
date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
|
||||||
|
!getSlots({
|
||||||
|
inviteeDate: date,
|
||||||
|
frequency: eventLength,
|
||||||
|
workingHours,
|
||||||
|
organizerTimeZone,
|
||||||
|
}).length
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up calendar
|
||||||
|
const daysInMonth = inviteeDate.daysInMonth();
|
||||||
|
const days = [];
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create placeholder elements for empty days in first week
|
||||||
|
let weekdayOfFirst = inviteeDate.date(1).day();
|
||||||
|
if (weekStart === "Monday") {
|
||||||
|
weekdayOfFirst -= 1;
|
||||||
|
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||||
|
}
|
||||||
|
const emptyDays = Array(weekdayOfFirst)
|
||||||
|
.fill(null)
|
||||||
|
.map((day, i) => (
|
||||||
|
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
||||||
|
{null}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
// Combine placeholder days with actual days
|
||||||
|
setCalendar([
|
||||||
|
...emptyDays,
|
||||||
|
...days.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
onClick={() => setSelectedDate(inviteeDate.date(day))}
|
||||||
|
disabled={isDisabled(day)}
|
||||||
|
className={
|
||||||
|
"text-center w-10 h-10 rounded-full mx-auto" +
|
||||||
|
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
|
||||||
|
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
|
||||||
|
? " bg-blue-600 text-white-important"
|
||||||
|
: !isDisabled(day)
|
||||||
|
? " bg-blue-50"
|
||||||
|
: "")
|
||||||
|
}>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
}, [selectedMonth, inviteeTimeZone, selectedDate]);
|
||||||
|
|
||||||
|
return selectedMonth ? (
|
||||||
|
<div className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}>
|
||||||
|
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
||||||
|
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
|
||||||
|
<div className="w-1/2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={decrementMonth}
|
||||||
|
className={"mr-4 " + (selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400")}
|
||||||
|
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
|
||||||
|
<ChevronLeftIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={incrementMonth}>
|
||||||
|
<ChevronRightIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||||
|
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||||
|
.map((weekDay) => (
|
||||||
|
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
|
||||||
|
{weekDay}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{calendar}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DatePicker;
|
96
components/booking/Slots.tsx
Normal file
96
components/booking/Slots.tsx
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import getSlots from "../../lib/slots";
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
dayjs.extend(isBetween);
|
||||||
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
eventLength: number;
|
||||||
|
minimumBookingNotice?: number;
|
||||||
|
date: Dayjs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerUtcOffset }: Props) => {
|
||||||
|
minimumBookingNotice = minimumBookingNotice || 0;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = router.query;
|
||||||
|
const [slots, setSlots] = useState([]);
|
||||||
|
const [isFullyBooked, setIsFullyBooked] = useState(false);
|
||||||
|
const [hasErrors, setHasErrors] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSlots([]);
|
||||||
|
setIsFullyBooked(false);
|
||||||
|
setHasErrors(false);
|
||||||
|
fetch(
|
||||||
|
`/api/availability/${user}?dateFrom=${date.startOf("day").utc().startOf("day").format()}&dateTo=${date
|
||||||
|
.endOf("day")
|
||||||
|
.utc()
|
||||||
|
.endOf("day")
|
||||||
|
.format()}`
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(handleAvailableSlots)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
setHasErrors(true);
|
||||||
|
});
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
const handleAvailableSlots = (busyTimes: []) => {
|
||||||
|
const times = getSlots({
|
||||||
|
frequency: eventLength,
|
||||||
|
inviteeDate: date,
|
||||||
|
workingHours,
|
||||||
|
minimumBookingNotice,
|
||||||
|
organizerUtcOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timesLengthBeforeConflicts: number = times.length;
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
for (let i = times.length - 1; i >= 0; i -= 1) {
|
||||||
|
busyTimes.every((busyTime): boolean => {
|
||||||
|
const startTime = dayjs(busyTime.start).utc();
|
||||||
|
const endTime = dayjs(busyTime.end).utc();
|
||||||
|
// Check if start times are the same
|
||||||
|
if (times[i].utc().isSame(startTime)) {
|
||||||
|
times.splice(i, 1);
|
||||||
|
}
|
||||||
|
// Check if time is between start and end times
|
||||||
|
else if (times[i].utc().isBetween(startTime, endTime)) {
|
||||||
|
times.splice(i, 1);
|
||||||
|
}
|
||||||
|
// Check if slot end time is between start and end time
|
||||||
|
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
|
||||||
|
times.splice(i, 1);
|
||||||
|
}
|
||||||
|
// Check if startTime is between slot
|
||||||
|
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
|
||||||
|
times.splice(i, 1);
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
|
||||||
|
setIsFullyBooked(true);
|
||||||
|
}
|
||||||
|
// Display available times
|
||||||
|
setSlots(times);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
slots,
|
||||||
|
isFullyBooked,
|
||||||
|
hasErrors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Slots;
|
|
@ -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 { timeZone, is24h } from "../../lib/clock";
|
||||||
|
|
||||||
function classNames(...classes) {
|
function classNames(...classes) {
|
||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimeOptions = (props) => {
|
const TimeOptions = (props) => {
|
||||||
|
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState('');
|
|
||||||
const [is24hClock, setIs24hClock] = useState(false);
|
const [is24hClock, setIs24hClock] = useState(false);
|
||||||
|
|
||||||
useEffect( () => {
|
useEffect(() => {
|
||||||
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;
|
22
components/ui/PoweredByCalendso.tsx
Normal file
22
components/ui/PoweredByCalendso.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const PoweredByCalendso = () => (
|
||||||
|
<div className="text-xs text-center sm:text-right pt-1">
|
||||||
|
<Link href="https://calendso.com">
|
||||||
|
<a
|
||||||
|
style={{ color: "#104D86" }}
|
||||||
|
className="opacity-50 hover:opacity-100"
|
||||||
|
>
|
||||||
|
powered by{" "}
|
||||||
|
<img
|
||||||
|
style={{ top: -2 }}
|
||||||
|
className="w-auto inline h-3 relative"
|
||||||
|
src="/calendso-logo-word.svg"
|
||||||
|
alt="Calendso Logo"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PoweredByCalendso;
|
143
components/ui/Scheduler.tsx
Normal file
143
components/ui/Scheduler.tsx
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import TimezoneSelect from "react-timezone-select";
|
||||||
|
import { TrashIcon } from "@heroicons/react/outline";
|
||||||
|
import { WeekdaySelect } from "./WeekdaySelect";
|
||||||
|
import SetTimesModal from "./modal/SetTimesModal";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import { Availability } from "@prisma/client";
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
timeZone: string;
|
||||||
|
availability: Availability[];
|
||||||
|
setTimeZone: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Scheduler = ({
|
||||||
|
availability,
|
||||||
|
setAvailability,
|
||||||
|
timeZone: selectedTimeZone,
|
||||||
|
setTimeZone,
|
||||||
|
}: Props) => {
|
||||||
|
const [editSchedule, setEditSchedule] = useState(-1);
|
||||||
|
const [dateOverrides, setDateOverrides] = useState([]);
|
||||||
|
const [openingHours, setOpeningHours] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpeningHours(
|
||||||
|
availability
|
||||||
|
.filter((item: Availability) => item.days.length !== 0)
|
||||||
|
.map((item) => {
|
||||||
|
item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes");
|
||||||
|
item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes");
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setDateOverrides(availability.filter((item: Availability) => item.date));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// updates availability to how it should be formatted outside this component.
|
||||||
|
useEffect(() => {
|
||||||
|
setAvailability({
|
||||||
|
dateOverrides: dateOverrides,
|
||||||
|
openingHours: openingHours,
|
||||||
|
});
|
||||||
|
}, [dateOverrides, openingHours]);
|
||||||
|
|
||||||
|
const addNewSchedule = () => setEditSchedule(openingHours.length);
|
||||||
|
|
||||||
|
const applyEditSchedule = (changed) => {
|
||||||
|
// new entry
|
||||||
|
if (!changed.days) {
|
||||||
|
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
|
||||||
|
setOpeningHours(openingHours.concat(changed));
|
||||||
|
} else {
|
||||||
|
// update
|
||||||
|
const replaceWith = { ...openingHours[editSchedule], ...changed };
|
||||||
|
openingHours.splice(editSchedule, 1, replaceWith);
|
||||||
|
setOpeningHours([].concat(openingHours));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeScheduleAt = (toRemove: number) => {
|
||||||
|
openingHours.splice(toRemove, 1);
|
||||||
|
setOpeningHours([].concat(openingHours));
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpeningHours = ({ idx, item }) => (
|
||||||
|
<li className="py-2 flex justify-between border-t">
|
||||||
|
<div className="inline-flex ml-2">
|
||||||
|
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
|
||||||
|
<button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}>
|
||||||
|
{dayjs()
|
||||||
|
.startOf("day")
|
||||||
|
.add(item.startTime, "minutes")
|
||||||
|
.format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
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;
|
208
lib/slots.ts
208
lib/slots.ts
|
@ -1,94 +1,134 @@
|
||||||
const dayjs = require("dayjs");
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
const isToday = require("dayjs/plugin/isToday");
|
import timezone from "dayjs/plugin/timezone";
|
||||||
const utc = require("dayjs/plugin/utc");
|
|
||||||
const timezone = require("dayjs/plugin/timezone");
|
|
||||||
|
|
||||||
dayjs.extend(isToday);
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
const getMinutesFromMidnight = (date) => {
|
type WorkingHour = {
|
||||||
return date.hour() * 60 + date.minute();
|
days: number[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetSlots = {
|
||||||
|
inviteeDate: Dayjs;
|
||||||
|
frequency: number;
|
||||||
|
workingHours: WorkingHour[];
|
||||||
|
minimumBookingNotice?: number;
|
||||||
|
organizerTimeZone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Boundary = {
|
||||||
|
lowerBound: number;
|
||||||
|
upperBound: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const freqApply = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency;
|
||||||
|
|
||||||
|
const intersectBoundary = (a: Boundary, b: Boundary) => {
|
||||||
|
if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lowerBound: Math.max(b.lowerBound, a.lowerBound),
|
||||||
|
upperBound: Math.min(b.upperBound, a.upperBound),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
|
||||||
|
const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
|
||||||
|
boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
|
||||||
|
|
||||||
|
const organizerBoundaries = (
|
||||||
|
workingHours: [],
|
||||||
|
inviteeDate: Dayjs,
|
||||||
|
inviteeBounds: Boundary,
|
||||||
|
organizerTimeZone
|
||||||
|
): Boundary[] => {
|
||||||
|
const boundaries: Boundary[] = [];
|
||||||
|
|
||||||
|
const startDay: number = +inviteeDate
|
||||||
|
.utc()
|
||||||
|
.startOf("day")
|
||||||
|
.add(inviteeBounds.lowerBound, "minutes")
|
||||||
|
.format("d");
|
||||||
|
const endDay: number = +inviteeDate
|
||||||
|
.utc()
|
||||||
|
.startOf("day")
|
||||||
|
.add(inviteeBounds.upperBound, "minutes")
|
||||||
|
.format("d");
|
||||||
|
|
||||||
|
workingHours.forEach((item) => {
|
||||||
|
const lowerBound: number = item.startTime - dayjs().tz(organizerTimeZone).utcOffset();
|
||||||
|
const upperBound: number = item.endTime - dayjs().tz(organizerTimeZone).utcOffset();
|
||||||
|
if (startDay !== endDay) {
|
||||||
|
if (inviteeBounds.lowerBound < 0) {
|
||||||
|
// lowerBound edges into the previous day
|
||||||
|
if (item.days.includes(startDay)) {
|
||||||
|
boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 });
|
||||||
|
}
|
||||||
|
if (item.days.includes(endDay)) {
|
||||||
|
boundaries.push({ lowerBound, upperBound });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// upperBound edges into the next day
|
||||||
|
if (item.days.includes(endDay)) {
|
||||||
|
boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 });
|
||||||
|
}
|
||||||
|
if (item.days.includes(startDay)) {
|
||||||
|
boundaries.push({ lowerBound, upperBound });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
boundaries.push({ lowerBound, upperBound });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return boundaries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => {
|
||||||
|
const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency);
|
||||||
|
const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency);
|
||||||
|
return {
|
||||||
|
lowerBound,
|
||||||
|
upperBound,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => {
|
||||||
|
const slots: Dayjs[] = [];
|
||||||
|
for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
|
||||||
|
slots.push(
|
||||||
|
<Dayjs>dayjs
|
||||||
|
.utc()
|
||||||
|
.startOf("day")
|
||||||
|
.add(lowerBound + minutes, "minutes")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSlots = ({
|
const getSlots = ({
|
||||||
calendarTimeZone,
|
inviteeDate,
|
||||||
eventLength,
|
frequency,
|
||||||
selectedTimeZone,
|
minimumBookingNotice,
|
||||||
selectedDate,
|
workingHours,
|
||||||
dayStartTime,
|
organizerTimeZone,
|
||||||
dayEndTime
|
}: GetSlots): Dayjs[] => {
|
||||||
}) => {
|
const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
|
||||||
|
? inviteeDate.hour() * 60 + inviteeDate.minute() + (minimumBookingNotice || 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
if(!selectedDate) return []
|
const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
|
||||||
|
|
||||||
const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day");
|
return getOverlaps(
|
||||||
|
inviteeBounds,
|
||||||
// Simple case, same timezone
|
organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone)
|
||||||
if (calendarTimeZone === selectedTimeZone) {
|
)
|
||||||
const slots = [];
|
.reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
|
||||||
const now = dayjs();
|
.map((slot) =>
|
||||||
for (
|
slot.month(inviteeDate.month()).date(inviteeDate.date()).utcOffset(inviteeDate.utcOffset())
|
||||||
let minutes = dayStartTime;
|
);
|
||||||
minutes <= dayEndTime - eventLength;
|
|
||||||
minutes += parseInt(eventLength, 10)
|
|
||||||
) {
|
|
||||||
const slot = lowerBound.add(minutes, "minutes");
|
|
||||||
if (slot > now) {
|
|
||||||
slots.push(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slots;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upperBound = selectedDate.tz(selectedTimeZone).endOf("day");
|
|
||||||
|
|
||||||
// We need to start generating slots from the start of the calendarTimeZone day
|
|
||||||
const startDateTime = lowerBound
|
|
||||||
.tz(calendarTimeZone)
|
|
||||||
.startOf("day")
|
|
||||||
.add(dayStartTime, "minutes");
|
|
||||||
|
|
||||||
let phase = 0;
|
|
||||||
if (startDateTime < lowerBound) {
|
|
||||||
// Getting minutes of the first event in the day of the chooser
|
|
||||||
const diff = lowerBound.diff(startDateTime, "minutes");
|
|
||||||
|
|
||||||
// finding first event
|
|
||||||
phase = diff + eventLength - (diff % eventLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can stop as soon as the selectedTimeZone day ends
|
|
||||||
const endDateTime = upperBound
|
|
||||||
.tz(calendarTimeZone)
|
|
||||||
.subtract(eventLength, "minutes");
|
|
||||||
|
|
||||||
const maxMinutes = endDateTime.diff(startDateTime, "minutes");
|
|
||||||
|
|
||||||
const slots = [];
|
|
||||||
const now = dayjs();
|
|
||||||
for (
|
|
||||||
let minutes = phase;
|
|
||||||
minutes <= maxMinutes;
|
|
||||||
minutes += parseInt(eventLength, 10)
|
|
||||||
) {
|
|
||||||
const slot = startDateTime.add(minutes, "minutes");
|
|
||||||
|
|
||||||
const minutesFromMidnight = getMinutesFromMidnight(slot);
|
|
||||||
|
|
||||||
if (
|
|
||||||
minutesFromMidnight < dayStartTime ||
|
|
||||||
minutesFromMidnight > dayEndTime - eventLength ||
|
|
||||||
slot < now
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
slots.push(slot.tz(selectedTimeZone));
|
|
||||||
}
|
|
||||||
|
|
||||||
return slots;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default getSlots
|
export default getSlots;
|
||||||
|
|
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 { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
|
||||||
import prisma from "../../lib/prisma";
|
import prisma from "../../lib/prisma";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import { Dayjs } from "dayjs";
|
||||||
import {
|
|
||||||
ClockIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
} from "@heroicons/react/solid";
|
|
||||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
|
||||||
import utc from "dayjs/plugin/utc";
|
|
||||||
import timezone from "dayjs/plugin/timezone";
|
|
||||||
dayjs.extend(isSameOrBefore);
|
|
||||||
dayjs.extend(utc);
|
|
||||||
dayjs.extend(timezone);
|
|
||||||
|
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||||
import AvailableTimes from "../../components/booking/AvailableTimes";
|
import AvailableTimes from "../../components/booking/AvailableTimes";
|
||||||
import TimeOptions from "../../components/booking/TimeOptions";
|
import TimeOptions from "../../components/booking/TimeOptions";
|
||||||
import Avatar from "../../components/Avatar";
|
import Avatar from "../../components/Avatar";
|
||||||
import { timeZone } from "../../lib/clock";
|
import { timeZone } from "../../lib/clock";
|
||||||
|
import DatePicker from "../../components/booking/DatePicker";
|
||||||
|
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
|
||||||
|
|
||||||
export default function Type(props): Type {
|
export default function Type(props): Type {
|
||||||
// Get router variables
|
// Get router variables
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { rescheduleUid } = router.query;
|
const { rescheduleUid } = router.query;
|
||||||
|
|
||||||
// Initialise state
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||||
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
|
||||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||||
const [timeFormat, setTimeFormat] = useState("h:mma");
|
const [timeFormat, setTimeFormat] = useState("h:mma");
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
useEffect((): void => {
|
useEffect(() => {
|
||||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
||||||
}, [telemetry]);
|
}, [telemetry]);
|
||||||
|
|
||||||
// Handle month changes
|
const changeDate = (date: Dayjs) => {
|
||||||
const incrementMonth = () => {
|
|
||||||
setSelectedMonth(selectedMonth + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const decrementMonth = () => {
|
|
||||||
setSelectedMonth(selectedMonth - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up calendar
|
|
||||||
const daysInMonth = dayjs().month(selectedMonth).daysInMonth();
|
|
||||||
const days = [];
|
|
||||||
for (let i = 1; i <= daysInMonth; i++) {
|
|
||||||
days.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create placeholder elements for empty days in first week
|
|
||||||
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
|
|
||||||
if (props.user.weekStart === "Monday") {
|
|
||||||
weekdayOfFirst -= 1;
|
|
||||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
|
||||||
}
|
|
||||||
const emptyDays = Array(weekdayOfFirst)
|
|
||||||
.fill(null)
|
|
||||||
.map((day, i) => (
|
|
||||||
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
|
||||||
{null}
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
|
|
||||||
const changeDate = (day): void => {
|
|
||||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
|
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
|
||||||
setSelectedDate(dayjs().month(selectedMonth).date(day));
|
setSelectedDate(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine placeholder days with actual days
|
|
||||||
const calendar = [
|
|
||||||
...emptyDays,
|
|
||||||
...days.map((day) => (
|
|
||||||
<button
|
|
||||||
key={day}
|
|
||||||
onClick={() => changeDate(day)}
|
|
||||||
disabled={
|
|
||||||
selectedMonth < parseInt(dayjs().format("MM")) && dayjs().month(selectedMonth).format("D") > day
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
"text-center w-10 h-10 rounded-full mx-auto " +
|
|
||||||
(dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth))
|
|
||||||
? "bg-blue-50 text-blue-600 font-medium"
|
|
||||||
: "text-gray-400 font-light") +
|
|
||||||
(dayjs(selectedDate).month(selectedMonth).format("D") == day
|
|
||||||
? " bg-blue-600 text-white-important"
|
|
||||||
: "")
|
|
||||||
}>
|
|
||||||
{day}
|
|
||||||
</button>
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
setSelectedDate(selectedDate.tz(selectedTimeZone));
|
setSelectedDate(selectedDate.tz(selectedTimeZone));
|
||||||
}
|
}
|
||||||
|
setIsTimeOptionsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle24hClock = (is24hClock: boolean): void => {
|
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||||
if (selectedDate) {
|
|
||||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -124,23 +56,48 @@ export default function Type(props): Type {
|
||||||
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://calendso/" />
|
<meta property="og:url" content="https://calendso/" />
|
||||||
<meta property="og:title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}/>
|
<meta
|
||||||
<meta property="og:description" content={props.eventType.description}/>
|
property="og:title"
|
||||||
<meta property="og:image" content={"https://og-image-one-pi.vercel.app/" + encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} />
|
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
||||||
|
/>
|
||||||
|
<meta property="og:description" content={props.eventType.description} />
|
||||||
|
<meta
|
||||||
|
property="og:image"
|
||||||
|
content={
|
||||||
|
"https://og-image-one-pi.vercel.app/" +
|
||||||
|
encodeURIComponent(
|
||||||
|
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
|
||||||
|
).replace(/'/g, "%27") +
|
||||||
|
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
||||||
|
encodeURIComponent(props.user.avatar)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:url" content="https://calendso/" />
|
<meta property="twitter:url" content="https://calendso/" />
|
||||||
<meta property="twitter:title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
|
<meta
|
||||||
|
property="twitter:title"
|
||||||
|
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
||||||
|
/>
|
||||||
<meta property="twitter:description" content={props.eventType.description} />
|
<meta property="twitter:description" content={props.eventType.description} />
|
||||||
<meta property="twitter:image" content={"https://og-image-one-pi.vercel.app/" + encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} />
|
<meta
|
||||||
|
property="twitter:image"
|
||||||
|
content={
|
||||||
|
"https://og-image-one-pi.vercel.app/" +
|
||||||
|
encodeURIComponent(
|
||||||
|
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
|
||||||
|
).replace(/'/g, "%27") +
|
||||||
|
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
||||||
|
encodeURIComponent(props.user.avatar)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
<main
|
<main
|
||||||
className={
|
className={
|
||||||
"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" />
|
||||||
|
@ -165,64 +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>
|
||||||
{/* note(peer):
|
{!props.user.hideBranding && <PoweredByCalendso />}
|
||||||
you can remove calendso branding here, but we'd also appreciate it, if you don't <3
|
|
||||||
*/}
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
@ -245,6 +165,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
weekStart: true,
|
weekStart: true,
|
||||||
|
availability: true,
|
||||||
|
hideBranding: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -266,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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -275,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,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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
30
pages/api/availability/week.ts
Normal file
30
pages/api/availability/week.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { getSession } from 'next-auth/client';
|
||||||
|
import prisma from '../../../lib/prisma';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const session = await getSession({req: req});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(401).json({message: "Not authenticated"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method == "PATCH") {
|
||||||
|
|
||||||
|
const startMins = req.body.start;
|
||||||
|
const endMins = req.body.end;
|
||||||
|
|
||||||
|
const updateWeek = await prisma.schedule.update({
|
||||||
|
where: {
|
||||||
|
id: session.user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
startTime: startMins,
|
||||||
|
endTime: endMins
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({message: 'Start and end times updated successfully'});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,47 +1,49 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import prisma from "../../../lib/prisma";
|
import prisma from "../../../lib/prisma";
|
||||||
import { CalendarEvent, createEvent, updateEvent, getBusyCalendarTimes } from "../../../lib/calendarClient";
|
import { CalendarEvent, createEvent, updateEvent } from "../../../lib/calendarClient";
|
||||||
import async from "async";
|
import async from "async";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { createMeeting, updateMeeting, getBusyVideoTimes } from "../../../lib/videoClient";
|
import { createMeeting, updateMeeting } from "../../../lib/videoClient";
|
||||||
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
|
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
|
||||||
import { getEventName } from "../../../lib/event";
|
import { getEventName } from "../../../lib/event";
|
||||||
import { LocationType } from "../../../lib/location";
|
import { LocationType } from "../../../lib/location";
|
||||||
import merge from "lodash.merge";
|
import merge from "lodash.merge";
|
||||||
const translator = short();
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const isAvailable = (busyTimes, time, length) => {
|
const translator = short();
|
||||||
// Check for conflicts
|
|
||||||
let t = true;
|
|
||||||
busyTimes.forEach((busyTime) => {
|
|
||||||
const startTime = dayjs(busyTime.start);
|
|
||||||
const endTime = dayjs(busyTime.end);
|
|
||||||
|
|
||||||
// Check if start times are the same
|
// Commented out because unused and thus throwing an error in linter.
|
||||||
if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) {
|
// const isAvailable = (busyTimes, time, length) => {
|
||||||
t = false;
|
// // Check for conflicts
|
||||||
}
|
// let t = true;
|
||||||
|
// busyTimes.forEach((busyTime) => {
|
||||||
// Check if time is between start and end times
|
// const startTime = dayjs(busyTime.start);
|
||||||
if (dayjs(time).isBetween(startTime, endTime)) {
|
// const endTime = dayjs(busyTime.end);
|
||||||
t = false;
|
//
|
||||||
}
|
// // Check if start times are the same
|
||||||
|
// if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) {
|
||||||
// Check if slot end time is between start and end time
|
// t = false;
|
||||||
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
|
// }
|
||||||
t = false;
|
//
|
||||||
}
|
// // Check if time is between start and end times
|
||||||
|
// if (dayjs(time).isBetween(startTime, endTime)) {
|
||||||
// Check if startTime is between slot
|
// t = false;
|
||||||
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
|
// }
|
||||||
t = false;
|
//
|
||||||
}
|
// // Check if slot end time is between start and end time
|
||||||
});
|
// if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
|
||||||
|
// t = false;
|
||||||
return t;
|
// }
|
||||||
};
|
//
|
||||||
|
// // Check if startTime is between slot
|
||||||
|
// if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
|
||||||
|
// t = false;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return t;
|
||||||
|
// };
|
||||||
|
|
||||||
interface GetLocationRequestFromIntegrationRequest {
|
interface GetLocationRequestFromIntegrationRequest {
|
||||||
location: string;
|
location: string;
|
||||||
|
@ -63,10 +65,20 @@ const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromI
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||||
const { user } = req.query;
|
const { user } = req.query;
|
||||||
|
|
||||||
const currentUser = await prisma.user.findFirst({
|
const isTimeInPast = (time) => {
|
||||||
|
return dayjs(time).isBefore(new Date(), "day");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isTimeInPast(req.body.start)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ errorCode: "BookingDateInPast", message: "Attempting to create a meeting in the past." });
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username: user,
|
username: user,
|
||||||
},
|
},
|
||||||
|
@ -79,42 +91,62 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCalendars = await prisma.selectedCalendar.findMany({
|
// Commented out because unused and thus throwing an error in linter.
|
||||||
|
// const selectedCalendars = await prisma.selectedCalendar.findMany({
|
||||||
|
// where: {
|
||||||
|
// userId: currentUser.id,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// Split credentials up into calendar credentials and video credentials
|
||||||
|
let calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
||||||
|
let videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||||
|
|
||||||
|
// Commented out because unused and thus throwing an error in linter.
|
||||||
|
// const hasCalendarIntegrations =
|
||||||
|
// currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
|
||||||
|
// const hasVideoIntegrations =
|
||||||
|
// currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0;
|
||||||
|
|
||||||
|
// Commented out because unused and thus throwing an error in linter.
|
||||||
|
// const calendarAvailability = await getBusyCalendarTimes(
|
||||||
|
// currentUser.credentials,
|
||||||
|
// dayjs(req.body.start).startOf("day").utc().format(),
|
||||||
|
// dayjs(req.body.end).endOf("day").utc().format(),
|
||||||
|
// selectedCalendars
|
||||||
|
// );
|
||||||
|
// const videoAvailability = await getBusyVideoTimes(
|
||||||
|
// currentUser.credentials,
|
||||||
|
// dayjs(req.body.start).startOf("day").utc().format(),
|
||||||
|
// dayjs(req.body.end).endOf("day").utc().format()
|
||||||
|
// );
|
||||||
|
// let commonAvailability = [];
|
||||||
|
|
||||||
|
// Commented out because unused and thus throwing an error in linter.
|
||||||
|
// if (hasCalendarIntegrations && hasVideoIntegrations) {
|
||||||
|
// commonAvailability = calendarAvailability.filter((availability) =>
|
||||||
|
// videoAvailability.includes(availability)
|
||||||
|
// );
|
||||||
|
// } else if (hasVideoIntegrations) {
|
||||||
|
// commonAvailability = videoAvailability;
|
||||||
|
// } else if (hasCalendarIntegrations) {
|
||||||
|
// commonAvailability = calendarAvailability;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Now, get the newly stored credentials (new refresh token for example).
|
||||||
|
currentUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: currentUser.id,
|
username: user,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
credentials: true,
|
||||||
|
timeZone: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Split credentials up into calendar credentials and video credentials
|
calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
||||||
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||||
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
|
||||||
|
|
||||||
const hasCalendarIntegrations =
|
|
||||||
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,
|
|
||||||
dayjs(req.body.start).startOf("day").utc().format(),
|
|
||||||
dayjs(req.body.end).endOf("day").utc().format(),
|
|
||||||
selectedCalendars
|
|
||||||
);
|
|
||||||
const videoAvailability = await getBusyVideoTimes(
|
|
||||||
currentUser.credentials,
|
|
||||||
dayjs(req.body.start).startOf("day").utc().format(),
|
|
||||||
dayjs(req.body.end).endOf("day").utc().format()
|
|
||||||
);
|
|
||||||
let commonAvailability = [];
|
|
||||||
|
|
||||||
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
|
||||||
commonAvailability = calendarAvailability.filter((availability) =>
|
|
||||||
videoAvailability.includes(availability)
|
|
||||||
);
|
|
||||||
} else if (hasVideoIntegrations) {
|
|
||||||
commonAvailability = videoAvailability;
|
|
||||||
} else if (hasCalendarIntegrations) {
|
|
||||||
commonAvailability = calendarAvailability;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rescheduleUid = req.body.rescheduleUid;
|
const rescheduleUid = req.body.rescheduleUid;
|
||||||
|
|
||||||
|
@ -170,7 +202,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO isAvailable was throwing an error
|
// TODO isAvailable was throwing an error
|
||||||
const isAvailableToBeBooked = true;//isAvailable(commonAvailability, req.body.start, selectedEventType.length);
|
const isAvailableToBeBooked = true; //isAvailable(commonAvailability, req.body.start, selectedEventType.length);
|
||||||
|
|
||||||
if (!isAvailableToBeBooked) {
|
if (!isAvailableToBeBooked) {
|
||||||
return res.status(400).json({ message: `${currentUser.name} is unavailable at this time.` });
|
return res.status(400).json({ message: `${currentUser.name} is unavailable at this time.` });
|
||||||
|
|
|
@ -41,6 +41,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
const avatar = req.body.avatar;
|
const avatar = req.body.avatar;
|
||||||
const timeZone = req.body.timeZone;
|
const timeZone = req.body.timeZone;
|
||||||
const weekStart = req.body.weekStart;
|
const weekStart = req.body.weekStart;
|
||||||
|
const hideBranding = req.body.hideBranding;
|
||||||
|
|
||||||
const updateUser = await prisma.user.update({
|
const updateUser = await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -53,6 +54,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
bio: description,
|
bio: description,
|
||||||
timeZone: timeZone,
|
timeZone: timeZone,
|
||||||
weekStart: weekStart,
|
weekStart: weekStart,
|
||||||
|
hideBranding: hideBranding,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getCsrfToken } from "next-auth/client";
|
import { getCsrfToken } from "next-auth/client";
|
||||||
import debounce from "lodash.debounce";
|
import debounce from "lodash.debounce";
|
||||||
|
@ -137,6 +138,15 @@ export default function Page({ csrfToken }) {
|
||||||
Request Password Reset
|
Request Password Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link href="/auth/login">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,17 +1,66 @@
|
||||||
|
import { GetServerSideProps } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Select, { OptionBase } from "react-select";
|
import Select, { OptionBase } from "react-select";
|
||||||
import prisma from "../../../lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { LocationType } from "../../../lib/location";
|
import { LocationType } from "@lib/location";
|
||||||
import Shell from "../../../components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import { getSession, useSession } from "next-auth/client";
|
import { getSession } from "next-auth/client";
|
||||||
import { LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon } from "@heroicons/react/outline";
|
import { Scheduler } from "@components/ui/Scheduler";
|
||||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput";
|
|
||||||
|
import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline";
|
||||||
|
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
|
||||||
import { PlusIcon } from "@heroicons/react/solid";
|
import { PlusIcon } from "@heroicons/react/solid";
|
||||||
|
|
||||||
export default function EventType(props: any): JSX.Element {
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
dayjs.extend(utc);
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import { EventType, User, Availability } from "@prisma/client";
|
||||||
|
import { validJson } from "@lib/jsonUtils";
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: User;
|
||||||
|
eventType: EventType;
|
||||||
|
locationOptions: OptionBase[];
|
||||||
|
availability: Availability[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpeningHours = {
|
||||||
|
days: number[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DateOverride = {
|
||||||
|
date: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventTypeInput = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
length: number;
|
||||||
|
hidden: boolean;
|
||||||
|
locations: unknown;
|
||||||
|
eventName: string;
|
||||||
|
customInputs: EventTypeCustomInput[];
|
||||||
|
timeZone: string;
|
||||||
|
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EventTypePage({
|
||||||
|
user,
|
||||||
|
eventType,
|
||||||
|
locationOptions,
|
||||||
|
availability,
|
||||||
|
}: Props): JSX.Element {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const inputOptions: OptionBase[] = [
|
const inputOptions: OptionBase[] = [
|
||||||
|
@ -21,17 +70,17 @@ export default function EventType(props: any): JSX.Element {
|
||||||
{ value: EventTypeCustomInputType.Bool, label: "Checkbox" },
|
{ value: EventTypeCustomInputType.Bool, label: "Checkbox" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [, loading] = useSession();
|
const [enteredAvailability, setEnteredAvailability] = useState();
|
||||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||||
const [showAddCustomModal, setShowAddCustomModal] = useState(false);
|
const [showAddCustomModal, setShowAddCustomModal] = useState(false);
|
||||||
|
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined);
|
const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined);
|
||||||
const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]);
|
const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]);
|
||||||
|
const [locations, setLocations] = useState(eventType.locations || []);
|
||||||
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
||||||
const [locations, setLocations] = useState(props.eventType.locations || []);
|
|
||||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||||
props.eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||||
);
|
);
|
||||||
const locationOptions = props.locationOptions;
|
|
||||||
|
|
||||||
const titleRef = useRef<HTMLInputElement>();
|
const titleRef = useRef<HTMLInputElement>();
|
||||||
const slugRef = useRef<HTMLInputElement>();
|
const slugRef = useRef<HTMLInputElement>();
|
||||||
|
@ -40,25 +89,23 @@ export default function EventType(props: any): JSX.Element {
|
||||||
const isHiddenRef = useRef<HTMLInputElement>();
|
const isHiddenRef = useRef<HTMLInputElement>();
|
||||||
const eventNameRef = useRef<HTMLInputElement>();
|
const eventNameRef = useRef<HTMLInputElement>();
|
||||||
|
|
||||||
if (loading) {
|
useEffect(() => {
|
||||||
return <p className="text-gray-400">Loading...</p>;
|
setSelectedTimeZone(eventType.timeZone || user.timeZone);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
async function updateEventTypeHandler(event) {
|
async function updateEventTypeHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const enteredTitle = titleRef.current.value;
|
const enteredTitle: string = titleRef.current.value;
|
||||||
const enteredSlug = slugRef.current.value;
|
const enteredSlug: string = slugRef.current.value;
|
||||||
const enteredDescription = descriptionRef.current.value;
|
const enteredDescription: string = descriptionRef.current.value;
|
||||||
const enteredLength = lengthRef.current.value;
|
const enteredLength: number = parseInt(lengthRef.current.value);
|
||||||
const enteredIsHidden = isHiddenRef.current.checked;
|
const enteredIsHidden: boolean = isHiddenRef.current.checked;
|
||||||
const enteredEventName = eventNameRef.current.value;
|
const enteredEventName: string = eventNameRef.current.value;
|
||||||
// TODO: Add validation
|
// TODO: Add validation
|
||||||
|
|
||||||
await fetch("/api/availability/eventtype", {
|
const payload: EventTypeInput = {
|
||||||
method: "PATCH",
|
id: eventType.id,
|
||||||
body: JSON.stringify({
|
|
||||||
id: props.eventType.id,
|
|
||||||
title: enteredTitle,
|
title: enteredTitle,
|
||||||
slug: enteredSlug,
|
slug: enteredSlug,
|
||||||
description: enteredDescription,
|
description: enteredDescription,
|
||||||
|
@ -67,7 +114,16 @@ export default function EventType(props: any): JSX.Element {
|
||||||
locations,
|
locations,
|
||||||
eventName: enteredEventName,
|
eventName: enteredEventName,
|
||||||
customInputs,
|
customInputs,
|
||||||
}),
|
timeZone: selectedTimeZone,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (enteredAvailability) {
|
||||||
|
payload.availability = enteredAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch("/api/availability/eventtype", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
@ -81,7 +137,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
|
|
||||||
await fetch("/api/availability/eventtype", {
|
await fetch("/api/availability/eventtype", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify({ id: props.eventType.id }),
|
body: JSON.stringify({ id: eventType.id }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
@ -106,6 +162,30 @@ export default function EventType(props: any): JSX.Element {
|
||||||
setSelectedCustomInput(undefined);
|
setSelectedCustomInput(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateLocations = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let details = {};
|
||||||
|
if (e.target.location.value === LocationType.InPerson) {
|
||||||
|
details = { address: e.target.address.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type);
|
||||||
|
if (existingIdx !== -1) {
|
||||||
|
const copy = locations;
|
||||||
|
copy[existingIdx] = { ...locations[existingIdx], ...details };
|
||||||
|
setLocations(copy);
|
||||||
|
} else {
|
||||||
|
setLocations(locations.concat({ type: e.target.location.value, ...details }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowLocationModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLocation = (selectedLocation) => {
|
||||||
|
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
||||||
|
};
|
||||||
|
|
||||||
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
|
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
|
||||||
setSelectedCustomInput(customInput);
|
setSelectedCustomInput(customInput);
|
||||||
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
|
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
|
||||||
|
@ -147,30 +227,6 @@ export default function EventType(props: any): JSX.Element {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateLocations = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
let details = {};
|
|
||||||
if (e.target.location.value === LocationType.InPerson) {
|
|
||||||
details = { address: e.target.address.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type);
|
|
||||||
if (existingIdx !== -1) {
|
|
||||||
const copy = locations;
|
|
||||||
copy[existingIdx] = { ...locations[existingIdx], ...details };
|
|
||||||
setLocations(copy);
|
|
||||||
} else {
|
|
||||||
setLocations(locations.concat({ type: e.target.location.value, ...details }));
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowLocationModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLocation = (selectedLocation) => {
|
|
||||||
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCustom = (e) => {
|
const updateCustom = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
@ -207,13 +263,13 @@ export default function EventType(props: any): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{props.eventType.title} | Event Type | Calendso</title>
|
<title>{eventType.title} | Event Type | Calendso</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<Shell heading={"Event Type - " + props.eventType.title}>
|
<Shell heading={"Event Type - " + eventType.title}>
|
||||||
<div>
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="mb-8">
|
<div className="col-span-3 sm:col-span-2">
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
<div className="bg-white overflow-hidden shadow rounded-lg mb-4">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="px-4 py-5 sm:p-6">
|
||||||
<form onSubmit={updateEventTypeHandler}>
|
<form onSubmit={updateEventTypeHandler}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
@ -229,7 +285,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
required
|
required
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
placeholder="Quick Chat"
|
placeholder="Quick Chat"
|
||||||
defaultValue={props.eventType.title}
|
defaultValue={eventType.title}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -240,7 +296,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="flex rounded-md shadow-sm">
|
<div className="flex rounded-md shadow-sm">
|
||||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
|
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
|
||||||
{location.hostname}/{props.user.username}/
|
{typeof location !== "undefined" ? location.hostname : ""}/{user.username}/
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={slugRef}
|
ref={slugRef}
|
||||||
|
@ -249,7 +305,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
id="slug"
|
id="slug"
|
||||||
required
|
required
|
||||||
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
|
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
|
||||||
defaultValue={props.eventType.slug}
|
defaultValue={eventType.slug}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -390,7 +446,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
id="description"
|
id="description"
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
placeholder="A quick video meeting."
|
placeholder="A quick video meeting."
|
||||||
defaultValue={props.eventType.description}></textarea>
|
defaultValue={eventType.description}></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
@ -406,7 +462,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
required
|
required
|
||||||
className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md"
|
className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md"
|
||||||
placeholder="15"
|
placeholder="15"
|
||||||
defaultValue={props.eventType.length}
|
defaultValue={eventType.length}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
|
||||||
minutes
|
minutes
|
||||||
|
@ -425,7 +481,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
id="title"
|
id="title"
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
placeholder="Meeting with {USER}"
|
placeholder="Meeting with {USER}"
|
||||||
defaultValue={props.eventType.eventName}
|
defaultValue={eventType.eventName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -484,7 +540,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
name="ishidden"
|
name="ishidden"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
|
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
defaultChecked={props.eventType.hidden}
|
defaultChecked={eventType.hidden}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm">
|
<div className="ml-3 text-sm">
|
||||||
|
@ -497,12 +553,24 @@ export default function EventType(props: any): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<hr className="my-4" />
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2">How do you want to offer your availability for this event type?</h3>
|
||||||
|
<Scheduler
|
||||||
|
setAvailability={setEnteredAvailability}
|
||||||
|
setTimeZone={setSelectedTimeZone}
|
||||||
|
timeZone={selectedTimeZone}
|
||||||
|
availability={availability}
|
||||||
|
/>
|
||||||
|
<div className="py-4 flex justify-end">
|
||||||
|
<Link href="/availability">
|
||||||
|
<a className="mr-2 btn btn-white">Cancel</a>
|
||||||
|
</Link>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
<Link href="/availability">
|
</div>
|
||||||
<a className="ml-2 btn btn-white">Cancel</a>
|
</div>
|
||||||
</Link>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -649,9 +717,7 @@ export default function EventType(props: any): JSX.Element {
|
||||||
Is required
|
Is required
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="id" id="id" value={selectedCustomInput?.id} />
|
<input type="hidden" name="id" id="id" value={selectedCustomInput?.id} />
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Save
|
Save
|
||||||
|
@ -670,32 +736,55 @@ export default function EventType(props: any): JSX.Element {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validJson = (jsonString: string) => {
|
export const getServerSideProps: GetServerSideProps<Props> = async ({ req, query }) => {
|
||||||
try {
|
const session = await getSession({ req });
|
||||||
const o = JSON.parse(jsonString);
|
|
||||||
if (o && typeof o === "object") {
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Invalid JSON:", e);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
|
||||||
const session = await getSession(context);
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: "/auth/login",
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
|
const user: User = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
username: true,
|
username: true,
|
||||||
|
timeZone: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
availability: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const eventType: EventType | null = await prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parseInt(query.type as string),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
length: true,
|
||||||
|
hidden: true,
|
||||||
|
locations: true,
|
||||||
|
eventName: true,
|
||||||
|
availability: true,
|
||||||
|
customInputs: true,
|
||||||
|
timeZone: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!eventType) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const credentials = await prisma.credential.findMany({
|
const credentials = await prisma.credential.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
@ -747,28 +836,28 @@ export async function getServerSideProps(context) {
|
||||||
// Assuming it's Microsoft Teams.
|
// Assuming it's Microsoft Teams.
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findUnique({
|
const getAvailability = (providesAvailability) =>
|
||||||
where: {
|
providesAvailability.availability && providesAvailability.availability.length
|
||||||
id: parseInt(context.query.type),
|
? providesAvailability.availability
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const availability: Availability[] = getAvailability(eventType) ||
|
||||||
|
getAvailability(user) || [
|
||||||
|
{
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
startTime: user.startTime,
|
||||||
|
endTime: user.endTime,
|
||||||
},
|
},
|
||||||
select: {
|
];
|
||||||
id: true,
|
|
||||||
title: true,
|
availability.sort((a, b) => a.startTime - b.startTime);
|
||||||
slug: true,
|
|
||||||
description: true,
|
|
||||||
length: true,
|
|
||||||
hidden: true,
|
|
||||||
locations: true,
|
|
||||||
eventName: true,
|
|
||||||
customInputs: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
user,
|
user,
|
||||||
eventType,
|
eventType,
|
||||||
locationOptions,
|
locationOptions,
|
||||||
|
availability,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import Head from "next/head";
|
||||||
import prisma from "../../lib/prisma";
|
import prisma from "../../lib/prisma";
|
||||||
import { getSession, useSession } from "next-auth/client";
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import Shell from "../../components/Shell";
|
import Shell from "../../components/Shell";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export default function Bookings({ bookings }) {
|
export default function Bookings({ bookings }) {
|
||||||
const [session, loading] = useSession();
|
const [session, loading] = useSession();
|
||||||
|
@ -27,43 +28,39 @@ export default function Bookings({ bookings }) {
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Title
|
Person
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Description
|
Event
|
||||||
</th>
|
</th>
|
||||||
<th
|
{/* <th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Name
|
Date
|
||||||
</th>
|
</th> */}
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Email
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="relative px-6 py-3">
|
<th scope="col" className="relative px-6 py-3">
|
||||||
<span className="sr-only">Edit</span>
|
<span className="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{bookings.map((booking) => (
|
{bookings.map((booking) => (
|
||||||
<tr key={booking.uid}>
|
<tr key={booking.id}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{booking.title}
|
<div className="text-sm font-medium text-gray-900">{booking.attendees[0].name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{booking.attendees[0].email}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{booking.description}
|
<div className="text-sm text-gray-900">{booking.title}</div>
|
||||||
</td>
|
<div className="text-sm text-gray-500">{booking.description}</div>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{booking.attendees[0].name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{booking.attendees[0].email}
|
|
||||||
</td>
|
</td>
|
||||||
|
{/* <td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{dayjs(booking.startTime).format("D MMMM YYYY HH:mm")}
|
||||||
|
</div>
|
||||||
|
</td> */}
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<a
|
<a
|
||||||
href={window.location.href + "/../reschedule/" + booking.uid}
|
href={window.location.href + "/../reschedule/" + booking.uid}
|
||||||
|
@ -115,6 +112,9 @@ export async function getServerSideProps(context) {
|
||||||
description: true,
|
description: true,
|
||||||
attendees: true,
|
attendees: true,
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
startTime: "desc",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { props: { bookings } };
|
return { props: { bookings } };
|
||||||
|
|
|
@ -17,6 +17,7 @@ export default function Settings(props) {
|
||||||
const nameRef = useRef<HTMLInputElement>();
|
const nameRef = useRef<HTMLInputElement>();
|
||||||
const descriptionRef = useRef<HTMLTextAreaElement>();
|
const descriptionRef = useRef<HTMLTextAreaElement>();
|
||||||
const avatarRef = useRef<HTMLInputElement>();
|
const avatarRef = useRef<HTMLInputElement>();
|
||||||
|
const hideBrandingRef = useRef<HTMLInputElement>();
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
|
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
|
||||||
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState(props.user.weekStart || "Sunday");
|
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState(props.user.weekStart || "Sunday");
|
||||||
|
|
||||||
|
@ -43,6 +44,7 @@ export default function Settings(props) {
|
||||||
const enteredAvatar = avatarRef.current.value;
|
const enteredAvatar = avatarRef.current.value;
|
||||||
const enteredTimeZone = selectedTimeZone.value;
|
const enteredTimeZone = selectedTimeZone.value;
|
||||||
const enteredWeekStartDay = selectedWeekStartDay;
|
const enteredWeekStartDay = selectedWeekStartDay;
|
||||||
|
const enteredHideBranding = hideBrandingRef.current.checked;
|
||||||
|
|
||||||
// TODO: Add validation
|
// TODO: Add validation
|
||||||
|
|
||||||
|
@ -55,6 +57,7 @@ export default function Settings(props) {
|
||||||
avatar: enteredAvatar,
|
avatar: enteredAvatar,
|
||||||
timeZone: enteredTimeZone,
|
timeZone: enteredTimeZone,
|
||||||
weekStart: enteredWeekStartDay,
|
weekStart: enteredWeekStartDay,
|
||||||
|
hideBranding: enteredHideBranding,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
@ -154,6 +157,26 @@ export default function Settings(props) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id="hide-branding"
|
||||||
|
name="hide-branding"
|
||||||
|
type="checkbox"
|
||||||
|
ref={hideBrandingRef}
|
||||||
|
defaultChecked={props.user.hideBranding}
|
||||||
|
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm">
|
||||||
|
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||||
|
Disable Calendso branding
|
||||||
|
</label>
|
||||||
|
<p className="text-gray-500">Hide all Calendso branding from your public pages.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex-grow lg:mt-0 lg:ml-6 lg:flex-grow-0 lg:flex-shrink-0">
|
<div className="mt-6 flex-grow lg:mt-0 lg:ml-6 lg:flex-grow-0 lg:flex-shrink-0">
|
||||||
|
@ -247,6 +270,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
avatar: true,
|
avatar: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
weekStart: true,
|
weekStart: true,
|
||||||
|
hideBranding: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import prisma from '../lib/prisma';
|
import prisma from "../lib/prisma";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {useRouter} from 'next/router';
|
import { useRouter } from "next/router";
|
||||||
import {CheckIcon} from '@heroicons/react/outline';
|
import { CheckIcon } from "@heroicons/react/outline";
|
||||||
import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid';
|
import { CalendarIcon, ClockIcon, LocationMarkerIcon } from "@heroicons/react/solid";
|
||||||
import dayjs from 'dayjs';
|
import dayjs from "dayjs";
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from "dayjs/plugin/utc";
|
||||||
import toArray from 'dayjs/plugin/toArray';
|
import toArray from "dayjs/plugin/toArray";
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import {createEvent} from 'ics';
|
import { createEvent } from "ics";
|
||||||
import {getEventName} from "../lib/event";
|
import { getEventName } from "../lib/event";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(toArray);
|
dayjs.extend(toArray);
|
||||||
|
@ -18,32 +18,35 @@ dayjs.extend(timezone);
|
||||||
|
|
||||||
export default function Success(props) {
|
export default function Success(props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {location, name} = router.query;
|
const { location, name } = router.query;
|
||||||
|
|
||||||
const [ is24h, setIs24h ] = useState(false);
|
const [is24h, setIs24h] = useState(false);
|
||||||
const [ date, setDate ] = useState(dayjs.utc(router.query.date));
|
const [date, setDate] = useState(dayjs.utc(router.query.date));
|
||||||
|
|
||||||
useEffect( () => {
|
useEffect(() => {
|
||||||
setDate(date.tz(localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess()));
|
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
||||||
setIs24h(!!localStorage.getItem('timeOption.is24hClock'));
|
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const eventName = getEventName(name, props.eventType.title, props.eventType.eventName);
|
const eventName = getEventName(name, props.eventType.title, props.eventType.eventName);
|
||||||
|
|
||||||
function eventLink(): string {
|
function eventLink(): string {
|
||||||
|
|
||||||
let optional = {};
|
let optional = {};
|
||||||
if (location) {
|
if (location) {
|
||||||
optional['location'] = location;
|
optional["location"] = location;
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = createEvent({
|
const event = createEvent({
|
||||||
start: date.utc().toArray().slice(0, 6).map((v, i) => i === 1 ? v + 1 : v),
|
start: date
|
||||||
startInputType: 'utc',
|
.utc()
|
||||||
|
.toArray()
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((v, i) => (i === 1 ? v + 1 : v)),
|
||||||
|
startInputType: "utc",
|
||||||
title: eventName,
|
title: eventName,
|
||||||
description: props.eventType.description,
|
description: props.eventType.description,
|
||||||
duration: { minutes: props.eventType.length },
|
duration: { minutes: props.eventType.length },
|
||||||
...optional
|
...optional,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (event.error) {
|
if (event.error) {
|
||||||
|
@ -53,7 +56,7 @@ export default function Success(props) {
|
||||||
return encodeURIComponent(event.value);
|
return encodeURIComponent(event.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return(
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Booking Confirmed | {eventName} | Calendso</title>
|
<title>Booking Confirmed | {eventName} | Calendso</title>
|
||||||
|
@ -63,8 +66,14 @@ export default function Success(props) {
|
||||||
<div className="fixed z-10 inset-0 overflow-y-auto">
|
<div className="fixed z-10 inset-0 overflow-y-auto">
|
||||||
<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 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
||||||
<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">
|
||||||
<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-sm sm:w-full sm:p-6" role="dialog" aria-modal="true" aria-labelledby="modal-headline">
|
​
|
||||||
|
</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-sm sm:w-full sm:p-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-headline">
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||||
<CheckIcon className="h-6 w-6 text-green-600" />
|
<CheckIcon className="h-6 w-6 text-green-600" />
|
||||||
|
@ -84,50 +93,118 @@ export default function Success(props) {
|
||||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{props.eventType.length} minutes
|
{props.eventType.length} minutes
|
||||||
</p>
|
</p>
|
||||||
{location && <p className="text-gray-500 mb-1">
|
{location && (
|
||||||
|
<p className="text-gray-500 mb-1">
|
||||||
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{location}
|
{location}
|
||||||
</p>}
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{date.format((is24h ? 'H:mm' : 'h:mma') + ", dddd DD MMMM YYYY")}
|
{date.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 sm:mt-6 text-center">
|
<div className="mt-5 sm:mt-0 pt-2 text-center">
|
||||||
<span className="font-medium text-gray-500">Add to your calendar</span>
|
<span className="font-medium text-gray-500">Add to your calendar</span>
|
||||||
<div className="flex mt-2">
|
<div className="flex mt-2">
|
||||||
<Link href={`https://calendar.google.com/calendar/r/eventedit?dates=${date.utc().format('YYYYMMDDTHHmmss[Z]')}/${date.add(props.eventType.length, 'minute').utc().format('YYYYMMDDTHHmmss[Z]')}&text=${eventName}&details=${props.eventType.description}` + ( location ? "&location=" + encodeURIComponent(location) : '')}>
|
<Link
|
||||||
|
href={
|
||||||
|
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||||
|
.utc()
|
||||||
|
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||||
|
.add(props.eventType.length, "minute")
|
||||||
|
.utc()
|
||||||
|
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||||
|
props.eventType.description
|
||||||
|
}` + (location ? "&location=" + encodeURIComponent(location) : "")
|
||||||
|
}>
|
||||||
<a className="mx-2 btn-wide btn-white">
|
<a className="mx-2 btn-wide btn-white">
|
||||||
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>
|
<svg
|
||||||
|
className="inline-block w-4 h-4 mr-1 -mt-1"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<title>Google</title>
|
||||||
|
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={encodeURI("https://outlook.live.com/calendar/0/deeplink/compose?body=" + props.eventType.description + "&enddt=" + date.add(props.eventType.length, 'minute').format() + "&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" + date.format() + "&subject=" + eventName) + (location ? "&location=" + location : '')}>
|
<Link
|
||||||
|
href={
|
||||||
|
encodeURI(
|
||||||
|
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
||||||
|
props.eventType.description +
|
||||||
|
"&enddt=" +
|
||||||
|
date.add(props.eventType.length, "minute").format() +
|
||||||
|
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||||
|
date.format() +
|
||||||
|
"&subject=" +
|
||||||
|
eventName
|
||||||
|
) + (location ? "&location=" + location : "")
|
||||||
|
}>
|
||||||
<a className="mx-2 btn-wide btn-white">
|
<a className="mx-2 btn-wide btn-white">
|
||||||
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Microsoft Outlook</title><path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z"/></svg>
|
<svg
|
||||||
|
className="inline-block w-4 h-4 mr-1 -mt-1"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<title>Microsoft Outlook</title>
|
||||||
|
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={encodeURI("https://outlook.office.com/calendar/0/deeplink/compose?body=" + props.eventType.description + "&enddt=" + date.add(props.eventType.length, 'minute').format() + "&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" + date.format() + "&subject=" + eventName) + (location ? "&location=" + location : '')}>
|
<Link
|
||||||
|
href={
|
||||||
|
encodeURI(
|
||||||
|
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
||||||
|
props.eventType.description +
|
||||||
|
"&enddt=" +
|
||||||
|
date.add(props.eventType.length, "minute").format() +
|
||||||
|
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||||
|
date.format() +
|
||||||
|
"&subject=" +
|
||||||
|
eventName
|
||||||
|
) + (location ? "&location=" + location : "")
|
||||||
|
}>
|
||||||
<a className="mx-2 btn-wide btn-white">
|
<a className="mx-2 btn-wide btn-white">
|
||||||
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Microsoft Office</title><path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z"/></svg>
|
<svg
|
||||||
|
className="inline-block w-4 h-4 mr-1 -mt-1"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<title>Microsoft Office</title>
|
||||||
|
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={"data:text/calendar," + eventLink()}>
|
<Link href={"data:text/calendar," + eventLink()}>
|
||||||
<a className="mx-2 btn-wide btn-white" download={props.eventType.title + '.ics'}>
|
<a className="mx-2 btn-wide btn-white" download={props.eventType.title + ".ics"}>
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" className="inline-block w-4 h-4 mr-1 -mt-1"><title>Other</title><path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z"/>
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 1000 1000"
|
||||||
|
className="inline-block w-4 h-4 mr-1 -mt-1">
|
||||||
|
<title>Other</title>
|
||||||
|
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!props.user.hideBranding && (
|
||||||
|
<div className="mt-4 pt-4 border-t text-gray-400 text-center text-xs">
|
||||||
|
<a href="https://checkout.calendso.com">Create your own booking link with Calendso</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context) {
|
||||||
|
@ -140,8 +217,9 @@ export async function getServerSideProps(context) {
|
||||||
name: true,
|
name: true,
|
||||||
bio: true,
|
bio: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
eventTypes: true
|
eventTypes: true,
|
||||||
}
|
hideBranding: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findUnique({
|
const eventType = await prisma.eventType.findUnique({
|
||||||
|
@ -153,14 +231,14 @@ export async function getServerSideProps(context) {
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
length: true,
|
length: true,
|
||||||
eventName: true
|
eventName: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
user,
|
user,
|
||||||
eventType
|
eventType,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "hideBranding" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -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 {
|
||||||
|
@ -47,12 +49,15 @@ model User {
|
||||||
startTime Int @default(0)
|
startTime Int @default(0)
|
||||||
endTime Int @default(1440)
|
endTime Int @default(1440)
|
||||||
bufferTime Int @default(0)
|
bufferTime Int @default(0)
|
||||||
|
hideBranding Boolean @default(false)
|
||||||
createdDate DateTime @default(now()) @map(name: "created")
|
createdDate DateTime @default(now()) @map(name: "created")
|
||||||
eventTypes EventType[]
|
eventTypes EventType[]
|
||||||
credentials Credential[]
|
credentials Credential[]
|
||||||
teams Membership[]
|
teams Membership[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
|
availability Availability[]
|
||||||
selectedCalendars SelectedCalendar[]
|
selectedCalendars SelectedCalendar[]
|
||||||
|
|
||||||
@@map(name: "users")
|
@@map(name: "users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,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
|
||||||
|
@ -149,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;
|
||||||
|
}
|
109
test/lib/prisma.test.ts
Normal file
109
test/lib/prisma.test.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
|
||||||
|
import { it, expect } 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",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
56
test/lib/slots.test.ts
Normal file
56
test/lib/slots.test.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import getSlots from '@lib/slots';
|
||||||
|
import {it, expect} from '@jest/globals';
|
||||||
|
import MockDate from 'mockdate';
|
||||||
|
import dayjs, {Dayjs} from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
MockDate.set('2021-06-20T11:59:59Z');
|
||||||
|
|
||||||
|
it('can fit 24 hourly slots for an empty day', async () => {
|
||||||
|
// 24h in a day.
|
||||||
|
expect(getSlots({
|
||||||
|
inviteeDate: dayjs().add(1, 'day'),
|
||||||
|
frequency: 60,
|
||||||
|
workingHours: [
|
||||||
|
{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }
|
||||||
|
],
|
||||||
|
organizerTimeZone: 'Europe/London'
|
||||||
|
})).toHaveLength(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only shows future booking slots on the same day', async () => {
|
||||||
|
// The mock date is 1s to midday, so 12 slots should be open given 0 booking notice.
|
||||||
|
expect(getSlots({
|
||||||
|
inviteeDate: dayjs(),
|
||||||
|
frequency: 60,
|
||||||
|
workingHours: [
|
||||||
|
{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }
|
||||||
|
],
|
||||||
|
organizerTimeZone: 'GMT'
|
||||||
|
})).toHaveLength(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can cut off dates that due to invitee timezone differences fall on the next day', async () => {
|
||||||
|
expect(getSlots({
|
||||||
|
inviteeDate: dayjs().tz('Europe/Amsterdam').startOf('day'), // time translation +01:00
|
||||||
|
frequency: 60,
|
||||||
|
workingHours: [
|
||||||
|
{ days: [0], startTime: 1380, endTime: 1440 }
|
||||||
|
],
|
||||||
|
organizerTimeZone: 'Europe/London'
|
||||||
|
})).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can cut off dates that due to invitee timezone differences fall on the previous day', async () => {
|
||||||
|
expect(getSlots({
|
||||||
|
inviteeDate: dayjs().startOf('day'), // time translation -01:00
|
||||||
|
frequency: 60,
|
||||||
|
workingHours: [
|
||||||
|
{ days: [0], startTime: 0, endTime: 60 }
|
||||||
|
],
|
||||||
|
organizerTimeZone: 'Europe/London'
|
||||||
|
})).toHaveLength(0);
|
||||||
|
});
|
|
@ -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