Resolved conflicts
commit
3c09837104
|
@ -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": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es6": true
|
||||
"es6": true,
|
||||
"jest": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
|
|
11
README.md
11
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
|
||||
#### 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
|
||||
|
||||
|
@ -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`)
|
||||
```sh
|
||||
npx prisma db push --preview-feature
|
||||
npx prisma db push
|
||||
```
|
||||
6. Run (in development mode)
|
||||
```sh
|
||||
|
@ -157,7 +158,11 @@ You will also need Google API credentials. You can get this from the [Google API
|
|||
5. Enjoy the new version.
|
||||
<!-- DEPLOYMENT -->
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
The Docker configuration for Calendso is an effort powered by people within the community. Calendso does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
||||
|
||||
The Docker configuration can be found [in our docker repository](https://github.com/calendso/docker).
|
||||
### Railway
|
||||
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https%3A%2F%2Fgithub.com%2Fcalendso%2Fcalendso&plugins=postgresql&envs=GOOGLE_API_CREDENTIALS%2CBASE_URL%2CNEXTAUTH_URL%2CPORT&BASE_URLDefault=http%3A%2F%2Flocalhost%3A3000&NEXTAUTH_URLDefault=http%3A%2F%2Flocalhost%3A3000&PORTDefault=3000)
|
||||
|
||||
You can deploy Calendso on [Railway](https://railway.app/) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Calendso on their platform.
|
||||
|
|
|
@ -1,112 +1,40 @@
|
|||
import dayjs from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
dayjs.extend(isBetween);
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import getSlots from "../../lib/slots";
|
||||
import Link from "next/link";
|
||||
import { timeZone } from "../../lib/clock";
|
||||
import { useRouter } from "next/router";
|
||||
import Slots from "./Slots";
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
|
||||
const AvailableTimes = (props) => {
|
||||
const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat, user }) => {
|
||||
const router = useRouter();
|
||||
const { user, rescheduleUid } = router.query;
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
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]);
|
||||
|
||||
const { rescheduleUid } = router.query;
|
||||
const { slots, isFullyBooked, hasErrors } = Slots({ date, eventLength, workingHours });
|
||||
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="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>
|
||||
{!error &&
|
||||
loaded &&
|
||||
times.length > 0 &&
|
||||
times.map((time) => (
|
||||
<div key={dayjs(time).utc().format()}>
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => (
|
||||
<div key={slot.format()}>
|
||||
<Link
|
||||
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 : "")
|
||||
}>
|
||||
<a
|
||||
key={dayjs(time).format("hh:mma")}
|
||||
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 className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
|
||||
{slot.format(timeFormat)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
{!error && loaded && times.length == 0 && (
|
||||
{isFullyBooked && (
|
||||
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
|
||||
<h1 className="text-xl font">{props.user.name} is all booked today.</h1>
|
||||
<h1 className="text-xl font">{user.name} is all booked today.</h1>
|
||||
</div>
|
||||
)}
|
||||
{!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="flex">
|
||||
<div className="flex-shrink-0">
|
||||
|
@ -116,9 +44,9 @@ const AvailableTimes = (props) => {
|
|||
<p className="text-sm text-yellow-700">
|
||||
Could not load the available time slots.{" "}
|
||||
<a
|
||||
href={"mailto:" + props.user.email}
|
||||
href={"mailto:" + user.email}
|
||||
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>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import { useEffect, useState } from "react";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import getSlots from "@lib/slots";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const DatePicker = ({
|
||||
weekStart,
|
||||
onDatePicked,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
inviteeTimeZone,
|
||||
eventLength,
|
||||
}) => {
|
||||
const [calendar, setCalendar] = useState([]);
|
||||
const [selectedMonth, setSelectedMonth]: number = useState();
|
||||
const [selectedDate, setSelectedDate]: Dayjs = useState();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedMonth(dayjs().tz(inviteeTimeZone).month());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) onDatePicked(selectedDate);
|
||||
}, [selectedDate]);
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth + 1);
|
||||
};
|
||||
|
||||
const decrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMonth) {
|
||||
// wish next had a way of dealing with this magically;
|
||||
return;
|
||||
}
|
||||
|
||||
const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth);
|
||||
|
||||
const isDisabled = (day: number) => {
|
||||
const date: Dayjs = inviteeDate.date(day);
|
||||
return (
|
||||
date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
}).length
|
||||
);
|
||||
};
|
||||
|
||||
// Set up calendar
|
||||
const daysInMonth = inviteeDate.daysInMonth();
|
||||
const days = [];
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = inviteeDate.date(1).day();
|
||||
if (weekStart === "Monday") {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||
}
|
||||
const emptyDays = Array(weekdayOfFirst)
|
||||
.fill(null)
|
||||
.map((day, i) => (
|
||||
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
||||
{null}
|
||||
</div>
|
||||
));
|
||||
|
||||
// Combine placeholder days with actual days
|
||||
setCalendar([
|
||||
...emptyDays,
|
||||
...days.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => setSelectedDate(inviteeDate.date(day))}
|
||||
disabled={isDisabled(day)}
|
||||
className={
|
||||
"text-center w-10 h-10 rounded-full mx-auto" +
|
||||
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
|
||||
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
|
||||
? " bg-blue-600 text-white-important"
|
||||
: !isDisabled(day)
|
||||
? " bg-blue-50"
|
||||
: "")
|
||||
}>
|
||||
{day}
|
||||
</button>
|
||||
)),
|
||||
]);
|
||||
}, [selectedMonth, inviteeTimeZone, selectedDate]);
|
||||
|
||||
return selectedMonth ? (
|
||||
<div className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}>
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
||||
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
|
||||
<div className="w-1/2 text-right">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={"mr-4 " + (selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400")}
|
||||
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={incrementMonth}>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||
.map((weekDay) => (
|
||||
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
{weekDay}
|
||||
</div>
|
||||
))}
|
||||
{calendar}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default DatePicker;
|
|
@ -0,0 +1,97 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import getSlots from "../../lib/slots";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(utc);
|
||||
|
||||
type Props = {
|
||||
eventLength: number;
|
||||
minimumBookingNotice?: number;
|
||||
date: Dayjs;
|
||||
};
|
||||
|
||||
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerUtcOffset }: Props) => {
|
||||
minimumBookingNotice = minimumBookingNotice || 0;
|
||||
|
||||
const router = useRouter();
|
||||
const { user } = router.query;
|
||||
const [slots, setSlots] = useState([]);
|
||||
const [isFullyBooked, setIsFullyBooked] = useState(false);
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSlots([]);
|
||||
setIsFullyBooked(false);
|
||||
setHasErrors(false);
|
||||
fetch(
|
||||
`/api/availability/${user}?dateFrom=${date.startOf("day").utc().startOf("day").format()}&dateTo=${date
|
||||
.endOf("day")
|
||||
.utc()
|
||||
.endOf("day")
|
||||
.format()}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then(handleAvailableSlots)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setHasErrors(true);
|
||||
});
|
||||
}, [date]);
|
||||
|
||||
const handleAvailableSlots = (busyTimes: []) => {
|
||||
const times = getSlots({
|
||||
frequency: eventLength,
|
||||
inviteeDate: date,
|
||||
workingHours,
|
||||
minimumBookingNotice,
|
||||
organizerUtcOffset,
|
||||
});
|
||||
|
||||
const timesLengthBeforeConflicts: number = times.length;
|
||||
|
||||
// Check for conflicts
|
||||
for (let i = times.length - 1; i >= 0; i -= 1) {
|
||||
busyTimes.every((busyTime): boolean => {
|
||||
const startTime = dayjs(busyTime.start).utc();
|
||||
const endTime = dayjs(busyTime.end).utc();
|
||||
// Check if start times are the same
|
||||
if (times[i].utc().isSame(startTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if time is between start and end times
|
||||
else if (times[i].utc().isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if slot end time is between start and end time
|
||||
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if startTime is between slot
|
||||
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
|
||||
times.splice(i, 1);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
|
||||
setIsFullyBooked(true);
|
||||
}
|
||||
// Display available times
|
||||
setSlots(times);
|
||||
};
|
||||
|
||||
return {
|
||||
slots,
|
||||
isFullyBooked,
|
||||
hasErrors,
|
||||
};
|
||||
};
|
||||
|
||||
export default Slots;
|
|
@ -1,73 +1,72 @@
|
|||
import {Switch} from "@headlessui/react";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import {useEffect, useState} from "react";
|
||||
import {timeZone, is24h} from '../../lib/clock';
|
||||
import { useEffect, useState } from "react";
|
||||
import { is24h, timeZone } from "../../lib/clock";
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const TimeOptions = (props) => {
|
||||
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState('');
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [is24hClock, setIs24hClock] = useState(false);
|
||||
|
||||
useEffect( () => {
|
||||
useEffect(() => {
|
||||
setIs24hClock(is24h());
|
||||
setSelectedTimeZone(timeZone());
|
||||
}, []);
|
||||
|
||||
useEffect( () => {
|
||||
props.onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
useEffect(() => {
|
||||
if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
|
||||
props.onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
}
|
||||
}, [selectedTimeZone]);
|
||||
|
||||
useEffect( () => {
|
||||
useEffect(() => {
|
||||
props.onToggle24hClock(is24h(is24hClock));
|
||||
}, [is24hClock]);
|
||||
|
||||
return selectedTimeZone !== "" && (
|
||||
<div className="w-full rounded shadow border bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group
|
||||
as="div"
|
||||
className="flex items-center justify-end"
|
||||
>
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm text-gray-500">am/pm</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={setIs24hClock}
|
||||
className={classNames(
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
return (
|
||||
selectedTimeZone !== "" && (
|
||||
<div className="w-full rounded shadow border bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group as="div" className="flex items-center justify-end">
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm text-gray-500">am/pm</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={setIs24hClock}
|
||||
className={classNames(
|
||||
is24hClock ? "translate-x-3" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm text-gray-500">24h</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
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"
|
||||
)}>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
is24hClock ? "translate-x-3" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm text-gray-500">24h</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz) => setSelectedTimeZone(tz.value)}
|
||||
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>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz) => setSelectedTimeZone(tz.value)}
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TimeOptions;
|
||||
export default TimeOptions;
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import Link from "next/link";
|
||||
|
||||
const PoweredByCalendso = () => (
|
||||
<div className="text-xs text-center sm:text-right pt-1">
|
||||
<Link href="https://calendso.com">
|
||||
<a style={{ color: "#104D86" }} className="opacity-50 hover:opacity-100">
|
||||
powered by{" "}
|
||||
<img
|
||||
style={{ top: -2 }}
|
||||
className="w-auto inline h-3 relative"
|
||||
src="/calendso-logo-word.svg"
|
||||
alt="Calendso Logo"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PoweredByCalendso;
|
|
@ -0,0 +1,144 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { WeekdaySelect } from "./WeekdaySelect";
|
||||
import SetTimesModal from "./modal/SetTimesModal";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { Availability } from "@prisma/client";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type Props = {
|
||||
timeZone: string;
|
||||
availability: Availability[];
|
||||
setTimeZone: unknown;
|
||||
};
|
||||
|
||||
export const Scheduler = ({
|
||||
availability,
|
||||
setAvailability,
|
||||
timeZone: selectedTimeZone,
|
||||
setTimeZone,
|
||||
}: Props) => {
|
||||
const [editSchedule, setEditSchedule] = useState(-1);
|
||||
const [dateOverrides, setDateOverrides] = useState([]);
|
||||
const [openingHours, setOpeningHours] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpeningHours(
|
||||
availability
|
||||
.filter((item: Availability) => item.days.length !== 0)
|
||||
.map((item) => {
|
||||
item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes");
|
||||
item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes");
|
||||
return item;
|
||||
})
|
||||
);
|
||||
setDateOverrides(availability.filter((item: Availability) => item.date));
|
||||
}, []);
|
||||
|
||||
// updates availability to how it should be formatted outside this component.
|
||||
useEffect(() => {
|
||||
setAvailability({
|
||||
dateOverrides: dateOverrides,
|
||||
openingHours: openingHours,
|
||||
});
|
||||
}, [dateOverrides, openingHours]);
|
||||
|
||||
const addNewSchedule = () => setEditSchedule(openingHours.length);
|
||||
|
||||
const applyEditSchedule = (changed) => {
|
||||
// new entry
|
||||
if (!changed.days) {
|
||||
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
|
||||
setOpeningHours(openingHours.concat(changed));
|
||||
} else {
|
||||
// update
|
||||
const replaceWith = { ...openingHours[editSchedule], ...changed };
|
||||
openingHours.splice(editSchedule, 1, replaceWith);
|
||||
setOpeningHours([].concat(openingHours));
|
||||
}
|
||||
};
|
||||
|
||||
const removeScheduleAt = (toRemove: number) => {
|
||||
openingHours.splice(toRemove, 1);
|
||||
setOpeningHours([].concat(openingHours));
|
||||
};
|
||||
|
||||
const OpeningHours = ({ idx, item }) => (
|
||||
<li className="py-2 flex justify-between border-t">
|
||||
<div className="inline-flex ml-2">
|
||||
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
|
||||
<button className="ml-2 text-sm px-2" type="button" onClick={() => setEditSchedule(idx)}>
|
||||
{dayjs()
|
||||
.startOf("day")
|
||||
.add(item.startTime, "minutes")
|
||||
.format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
|
||||
until
|
||||
{dayjs()
|
||||
.startOf("day")
|
||||
.add(item.endTime, "minutes")
|
||||
.format(item.endTime % 60 === 0 ? "ha" : "h:mma")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeScheduleAt(idx)}
|
||||
className="btn-sm bg-transparent px-2 py-1 ml-1">
|
||||
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded border flex">
|
||||
<div className="w-3/5">
|
||||
<div className="w-3/4 p-2">
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
Timezone
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz) => setTimeZone(tz.value)}
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{openingHours.map((item, idx) => (
|
||||
<OpeningHours key={idx} idx={idx} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
<hr />
|
||||
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm m-2">
|
||||
Add another
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-l p-2 w-2/5 text-sm bg-gray-50">
|
||||
{/*<p className="font-bold mb-2">Add date overrides</p>
|
||||
<p className="mb-2">
|
||||
Add dates when your availability changes from your weekly hours
|
||||
</p>
|
||||
<button className="btn-sm btn-white">Add a date override</button>*/}
|
||||
</div>
|
||||
</div>
|
||||
{editSchedule >= 0 && (
|
||||
<SetTimesModal
|
||||
startTime={openingHours[editSchedule] ? openingHours[editSchedule].startTime : 540}
|
||||
endTime={openingHours[editSchedule] ? openingHours[editSchedule].endTime : 1020}
|
||||
onChange={(times) => applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })}
|
||||
onExit={() => setEditSchedule(-1)}
|
||||
/>
|
||||
)}
|
||||
{/*{showDateOverrideModal &&
|
||||
<DateOverrideModal />
|
||||
}*/}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,146 @@
|
|||
import { ClockIcon } from "@heroicons/react/outline";
|
||||
import { useRef } from "react";
|
||||
|
||||
export default function SetTimesModal(props) {
|
||||
const [startHours, startMinutes] = [Math.floor(props.startTime / 60), props.startTime % 60];
|
||||
const [endHours, endMinutes] = [Math.floor(props.endTime / 60), props.endTime % 60];
|
||||
|
||||
const startHoursRef = useRef<HTMLInputElement>();
|
||||
const startMinsRef = useRef<HTMLInputElement>();
|
||||
const endHoursRef = useRef<HTMLInputElement>();
|
||||
const endMinsRef = useRef<HTMLInputElement>();
|
||||
|
||||
function updateStartEndTimesHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const enteredStartHours = parseInt(startHoursRef.current.value);
|
||||
const enteredStartMins = parseInt(startMinsRef.current.value);
|
||||
const enteredEndHours = parseInt(endHoursRef.current.value);
|
||||
const enteredEndMins = parseInt(endMinsRef.current.value);
|
||||
|
||||
props.onChange({
|
||||
startTime: enteredStartHours * 60 + enteredStartMins,
|
||||
endTime: enteredEndHours * 60 + enteredEndMins,
|
||||
});
|
||||
|
||||
props.onExit(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start mb-4">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ClockIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
Change when you are available for bookings
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Set your work schedule</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mb-4">
|
||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label>
|
||||
<div>
|
||||
<label htmlFor="startHours" className="sr-only">
|
||||
Hours
|
||||
</label>
|
||||
<input
|
||||
ref={startHoursRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
maxLength="2"
|
||||
name="hours"
|
||||
id="startHours"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="9"
|
||||
defaultValue={startHours}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<div>
|
||||
<label htmlFor="startMinutes" className="sr-only">
|
||||
Minutes
|
||||
</label>
|
||||
<input
|
||||
ref={startMinsRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
step="15"
|
||||
maxLength="2"
|
||||
name="minutes"
|
||||
id="startMinutes"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="30"
|
||||
defaultValue={startMinutes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label>
|
||||
<div>
|
||||
<label htmlFor="endHours" className="sr-only">
|
||||
Hours
|
||||
</label>
|
||||
<input
|
||||
ref={endHoursRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
maxLength="2"
|
||||
name="hours"
|
||||
id="endHours"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="17"
|
||||
defaultValue={endHours}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<div>
|
||||
<label htmlFor="endMinutes" className="sr-only">
|
||||
Minutes
|
||||
</label>
|
||||
<input
|
||||
ref={endMinsRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
maxLength="2"
|
||||
step="15"
|
||||
name="minutes"
|
||||
id="endMinutes"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="30"
|
||||
defaultValue={endMinutes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button onClick={updateStartEndTimesHandler} type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -102,15 +102,13 @@ const o365Auth = (credential) => {
|
|||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line
|
||||
interface Person {
|
||||
name?: string;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
interface CalendarEvent {
|
||||
export interface CalendarEvent {
|
||||
type: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
|
@ -122,28 +120,25 @@ interface CalendarEvent {
|
|||
conferenceData?: ConferenceData;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
interface ConferenceData {
|
||||
createRequest: any;
|
||||
export interface ConferenceData {
|
||||
createRequest: unknown;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
interface IntegrationCalendar {
|
||||
export interface IntegrationCalendar {
|
||||
integration: string;
|
||||
primary: boolean;
|
||||
externalId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
interface CalendarApiAdapter {
|
||||
createEvent(event: CalendarEvent): Promise<any>;
|
||||
export interface CalendarApiAdapter {
|
||||
createEvent(event: CalendarEvent): Promise<unknown>;
|
||||
|
||||
updateEvent(uid: string, event: CalendarEvent);
|
||||
|
||||
deleteEvent(uid: string);
|
||||
|
||||
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>;
|
||||
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
|
||||
|
||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||
}
|
||||
|
@ -375,6 +370,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
auth: myGoogleAuth,
|
||||
calendarId: "primary",
|
||||
resource: payload,
|
||||
conferenceDataVersion: 1,
|
||||
},
|
||||
function (err, event) {
|
||||
if (err) {
|
||||
|
@ -508,15 +504,29 @@ const listCalendars = (withCredentials) =>
|
|||
results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||
);
|
||||
|
||||
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => {
|
||||
const createEvent = async (credential, calEvent: CalendarEvent): Promise<unknown> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||
const uid: string = parser.getUid();
|
||||
const richEvent: CalendarEvent = parser.asRichEvent();
|
||||
|
||||
const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null;
|
||||
|
||||
const organizerMail = new EventOrganizerMail(calEvent, uid);
|
||||
const attendeeMail = new EventAttendeeMail(calEvent, uid);
|
||||
const maybeHangoutLink = creationResult?.hangoutLink;
|
||||
const maybeEntryPoints = creationResult?.entryPoints;
|
||||
const maybeConferenceData = creationResult?.conferenceData;
|
||||
|
||||
const organizerMail = new EventOrganizerMail(calEvent, uid, {
|
||||
hangoutLink: maybeHangoutLink,
|
||||
conferenceData: maybeConferenceData,
|
||||
entryPoints: maybeEntryPoints,
|
||||
});
|
||||
|
||||
const attendeeMail = new EventAttendeeMail(calEvent, uid, {
|
||||
hangoutLink: maybeHangoutLink,
|
||||
conferenceData: maybeConferenceData,
|
||||
entryPoints: maybeEntryPoints,
|
||||
});
|
||||
|
||||
try {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
|
@ -537,7 +547,7 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> =>
|
|||
};
|
||||
};
|
||||
|
||||
const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise<any> => {
|
||||
const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise<unknown> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||
const newUid: string = parser.getUid();
|
||||
const richEvent: CalendarEvent = parser.asRichEvent();
|
||||
|
@ -568,7 +578,7 @@ const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEv
|
|||
};
|
||||
};
|
||||
|
||||
const deleteEvent = (credential, uid: string): Promise<any> => {
|
||||
const deleteEvent = (credential, uid: string): Promise<unknown> => {
|
||||
if (credential) {
|
||||
return calendars([credential])[0].deleteEvent(uid);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import EventMail from "./EventMail";
|
|||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
@ -22,13 +23,13 @@ export default class EventAttendeeMail extends EventMail {
|
|||
<br />
|
||||
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format(
|
||||
"h:mma"
|
||||
)}
|
||||
)}
|
||||
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format(
|
||||
"dddd, LL"
|
||||
)} is scheduled.<br />
|
||||
<br />` +
|
||||
this.getAdditionalBody() +
|
||||
(this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "") +
|
||||
"<br />" +
|
||||
`<strong>Additional notes:</strong><br />
|
||||
${this.calEvent.description}<br />
|
||||
` +
|
||||
|
@ -39,6 +40,38 @@ export default class EventAttendeeMail extends EventMail {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the video call information to the mail body.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getLocation(): string {
|
||||
if (this.additionInformation?.hangoutLink) {
|
||||
return `<strong>Location:</strong> <a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
|
||||
}
|
||||
|
||||
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
|
||||
const locations = this.additionInformation?.entryPoints
|
||||
.map((entryPoint) => {
|
||||
return `
|
||||
Join by ${entryPoint.entryPointType}: <br />
|
||||
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||
`;
|
||||
})
|
||||
.join("<br />");
|
||||
|
||||
return `<strong>Locations:</strong><br /> ${locations}`;
|
||||
}
|
||||
|
||||
return this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "";
|
||||
}
|
||||
|
||||
protected getAdditionalBody(): string {
|
||||
return `
|
||||
${this.getLocation()}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
*
|
||||
|
|
|
@ -1,13 +1,31 @@
|
|||
import { CalendarEvent } from "../calendarClient";
|
||||
import { serverConfig } from "../serverConfig";
|
||||
import nodemailer from "nodemailer";
|
||||
import CalEventParser from "../CalEventParser";
|
||||
import { stripHtml } from "./helpers";
|
||||
import { CalendarEvent, ConferenceData } from "../calendarClient";
|
||||
import { serverConfig } from "../serverConfig";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
interface EntryPoint {
|
||||
entryPointType?: string;
|
||||
uri?: string;
|
||||
label?: string;
|
||||
pin?: string;
|
||||
accessCode?: string;
|
||||
meetingCode?: string;
|
||||
passcode?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface AdditionInformation {
|
||||
conferenceData?: ConferenceData;
|
||||
entryPoints?: EntryPoint[];
|
||||
hangoutLink?: string;
|
||||
}
|
||||
|
||||
export default abstract class EventMail {
|
||||
calEvent: CalendarEvent;
|
||||
parser: CalEventParser;
|
||||
uid: string;
|
||||
additionInformation?: AdditionInformation;
|
||||
|
||||
/**
|
||||
* An EventMail always consists of a CalendarEvent
|
||||
|
@ -17,10 +35,11 @@ export default abstract class EventMail {
|
|||
* @param calEvent
|
||||
* @param uid
|
||||
*/
|
||||
constructor(calEvent: CalendarEvent, uid: string) {
|
||||
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
|
||||
this.calEvent = calEvent;
|
||||
this.uid = uid;
|
||||
this.parser = new CalEventParser(calEvent);
|
||||
this.additionInformation = additionInformation;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -88,6 +107,8 @@ export default abstract class EventMail {
|
|||
return "";
|
||||
}
|
||||
|
||||
protected abstract getLocation(): string;
|
||||
|
||||
/**
|
||||
* Prints out the desired information when an error
|
||||
* occured while sending the mail.
|
||||
|
|
|
@ -34,7 +34,7 @@ export default class EventOrganizerMail extends EventMail {
|
|||
stripHtml(this.getAdditionalFooter()),
|
||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||
attendees: this.calEvent.attendees.map((attendee: any) => ({
|
||||
attendees: this.calEvent.attendees.map((attendee: unknown) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
})),
|
||||
|
@ -66,26 +66,51 @@ export default class EventOrganizerMail extends EventMail {
|
|||
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
|
||||
<br />` +
|
||||
this.getAdditionalBody() +
|
||||
(this.calEvent.location
|
||||
? `
|
||||
<strong>Location:</strong><br />
|
||||
${this.calEvent.location}<br />
|
||||
<br />
|
||||
`
|
||||
: "") +
|
||||
"<br />" +
|
||||
`<strong>Invitee Time Zone:</strong><br />
|
||||
${this.calEvent.attendees[0].timeZone}<br />
|
||||
<br />
|
||||
<strong>Additional notes:</strong><br />
|
||||
${this.calEvent.description}
|
||||
` +
|
||||
${this.calEvent.attendees[0].timeZone}<br />
|
||||
<br />
|
||||
<strong>Additional notes:</strong><br />
|
||||
${this.calEvent.description}
|
||||
` +
|
||||
this.getAdditionalFooter() +
|
||||
`
|
||||
`
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the video call information to the mail body.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getLocation(): string {
|
||||
if (this.additionInformation?.hangoutLink) {
|
||||
return `<strong>Location:</strong> <a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
|
||||
}
|
||||
|
||||
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
|
||||
const locations = this.additionInformation?.entryPoints
|
||||
.map((entryPoint) => {
|
||||
return `
|
||||
Join by ${entryPoint.entryPointType}: <br />
|
||||
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||
`;
|
||||
})
|
||||
.join("<br />");
|
||||
|
||||
return `<strong>Locations:</strong><br /> ${locations}`;
|
||||
}
|
||||
|
||||
return this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "";
|
||||
}
|
||||
|
||||
protected getAdditionalBody(): string {
|
||||
return `
|
||||
${this.getLocation()}
|
||||
`;
|
||||
}
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
const globalAny:any = global;
|
||||
const globalAny: any = global;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
prisma = new PrismaClient();
|
||||
} else {
|
||||
if (!globalAny.prisma) {
|
||||
|
@ -12,4 +12,27 @@ if (process.env.NODE_ENV === 'production') {
|
|||
prisma = globalAny.prisma;
|
||||
}
|
||||
|
||||
export default 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;
|
||||
|
|
191
lib/slots.ts
191
lib/slots.ts
|
@ -1,94 +1,135 @@
|
|||
const dayjs = require("dayjs");
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
||||
const isToday = require("dayjs/plugin/isToday");
|
||||
const utc = require("dayjs/plugin/utc");
|
||||
const timezone = require("dayjs/plugin/timezone");
|
||||
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const getMinutesFromMidnight = (date) => {
|
||||
return date.hour() * 60 + date.minute();
|
||||
type WorkingHour = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
const getSlots = ({
|
||||
calendarTimeZone,
|
||||
eventLength,
|
||||
selectedTimeZone,
|
||||
selectedDate,
|
||||
dayStartTime,
|
||||
dayEndTime
|
||||
}) => {
|
||||
type GetSlots = {
|
||||
inviteeDate: Dayjs;
|
||||
frequency: number;
|
||||
workingHours: WorkingHour[];
|
||||
minimumBookingNotice?: number;
|
||||
organizerTimeZone: string;
|
||||
};
|
||||
|
||||
if(!selectedDate) return []
|
||||
|
||||
const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day");
|
||||
type Boundary = {
|
||||
lowerBound: number;
|
||||
upperBound: number;
|
||||
};
|
||||
|
||||
// Simple case, same timezone
|
||||
if (calendarTimeZone === selectedTimeZone) {
|
||||
const slots = [];
|
||||
const now = dayjs();
|
||||
for (
|
||||
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 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),
|
||||
};
|
||||
};
|
||||
|
||||
const upperBound = selectedDate.tz(selectedTimeZone).endOf("day");
|
||||
// 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);
|
||||
|
||||
// We need to start generating slots from the start of the calendarTimeZone day
|
||||
const startDateTime = lowerBound
|
||||
.tz(calendarTimeZone)
|
||||
const organizerBoundaries = (
|
||||
workingHours: [],
|
||||
inviteeDate: Dayjs,
|
||||
inviteeBounds: Boundary,
|
||||
organizerTimeZone
|
||||
): Boundary[] => {
|
||||
const boundaries: Boundary[] = [];
|
||||
|
||||
const startDay: number = +inviteeDate
|
||||
.utc()
|
||||
.startOf("day")
|
||||
.add(dayStartTime, "minutes");
|
||||
.add(inviteeBounds.lowerBound, "minutes")
|
||||
.format("d");
|
||||
const endDay: number = +inviteeDate
|
||||
.utc()
|
||||
.startOf("day")
|
||||
.add(inviteeBounds.upperBound, "minutes")
|
||||
.format("d");
|
||||
|
||||
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;
|
||||
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;
|
||||
};
|
||||
|
||||
slots.push(slot.tz(selectedTimeZone));
|
||||
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;
|
||||
};
|
||||
|
||||
export default getSlots
|
||||
const getSlots = ({
|
||||
inviteeDate,
|
||||
frequency,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
}: GetSlots): Dayjs[] => {
|
||||
const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day")
|
||||
? inviteeDate.hour() * 60 + inviteeDate.minute() + (minimumBookingNotice || 0)
|
||||
: 0;
|
||||
|
||||
const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
|
||||
|
||||
return getOverlaps(
|
||||
inviteeBounds,
|
||||
organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone)
|
||||
)
|
||||
.reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
|
||||
.map((slot) =>
|
||||
slot.month(inviteeDate.month()).date(inviteeDate.date()).utcOffset(inviteeDate.utcOffset())
|
||||
);
|
||||
};
|
||||
|
||||
export default getSlots;
|
||||
|
|
18
package.json
18
package.json
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"test": "node node_modules/.bin/jest",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "prisma generate",
|
||||
|
@ -38,6 +39,7 @@
|
|||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/node": "^14.14.33",
|
||||
"@types/nodemailer": "^6.4.2",
|
||||
"@types/react": "^17.0.3",
|
||||
|
@ -50,7 +52,9 @@
|
|||
"eslint-plugin-react": "^7.24.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"husky": "^6.0.0",
|
||||
"jest": "^27.0.5",
|
||||
"lint-staged": "^11.0.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"postcss": "^8.2.8",
|
||||
"prettier": "^2.3.1",
|
||||
"prisma": "^2.23.0",
|
||||
|
@ -62,5 +66,19 @@
|
|||
"prettier --write",
|
||||
"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 { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { ChevronDownIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { useRouter } from "next/router";
|
||||
import dayjs, { 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 { Dayjs } from "dayjs";
|
||||
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||
import AvailableTimes from "../../components/booking/AvailableTimes";
|
||||
import TimeOptions from "../../components/booking/TimeOptions";
|
||||
import Avatar from "../../components/Avatar";
|
||||
import { timeZone } from "../../lib/clock";
|
||||
import DatePicker from "../../components/booking/DatePicker";
|
||||
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
|
||||
|
||||
export default function Type(props): Type {
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
|
||||
// Initialise state
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState("h:mma");
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect((): void => {
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
||||
}, [telemetry]);
|
||||
|
||||
// Handle month changes
|
||||
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 => {
|
||||
const changeDate = (date: Dayjs) => {
|
||||
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 => {
|
||||
if (selectedDate) {
|
||||
setSelectedDate(selectedDate.tz(selectedTimeZone));
|
||||
}
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
const handleToggle24hClock = (is24hClock: boolean): void => {
|
||||
if (selectedDate) {
|
||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
}
|
||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -162,10 +94,10 @@ export default function Type(props): Type {
|
|||
</Head>
|
||||
<main
|
||||
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")
|
||||
}>
|
||||
<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={"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" />
|
||||
|
@ -190,63 +122,27 @@ export default function Type(props): Type {
|
|||
)}
|
||||
<p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
|
||||
</div>
|
||||
<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 < 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>
|
||||
<DatePicker
|
||||
weekStart={props.user.weekStart}
|
||||
onDatePicked={changeDate}
|
||||
workingHours={props.workingHours}
|
||||
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
|
||||
inviteeTimeZone={timeZone()}
|
||||
eventLength={props.eventType.length}
|
||||
/>
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
workingHours={props.workingHours}
|
||||
timeFormat={timeFormat}
|
||||
user={props.user}
|
||||
eventType={props.eventType}
|
||||
eventLength={props.eventType.length}
|
||||
eventTypeId={props.eventType.id}
|
||||
date={selectedDate}
|
||||
user={props.user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!props.user.hideBranding && (
|
||||
<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>
|
||||
)}
|
||||
{!props.user.hideBranding && <PoweredByCalendso />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
@ -269,6 +165,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||
timeZone: true,
|
||||
endTime: true,
|
||||
weekStart: true,
|
||||
availability: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
@ -291,6 +188,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||
title: true,
|
||||
description: true,
|
||||
length: true,
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -300,10 +199,29 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||
};
|
||||
}
|
||||
|
||||
const getWorkingHours = (providesAvailability) =>
|
||||
providesAvailability.availability && providesAvailability.availability.length
|
||||
? providesAvailability.availability
|
||||
: null;
|
||||
|
||||
const workingHours: [] =
|
||||
getWorkingHours(eventType) ||
|
||||
getWorkingHours(user) ||
|
||||
[
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: user.startTime,
|
||||
endTime: user.endTime,
|
||||
},
|
||||
].filter((availability): boolean => typeof availability["days"] !== "undefined");
|
||||
|
||||
workingHours.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
eventType,
|
||||
workingHours,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -147,8 +147,8 @@ export default function Book(props: any): JSX.Element {
|
|||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main className="max-w-3xl mx-auto my-24">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<main className="max-w-3xl mx-auto my-0 sm:my-24">
|
||||
<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:w-1/2 sm:border-r">
|
||||
<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)
|
||||
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
|
||||
</p>
|
||||
<p className="text-gray-600">{props.eventType.description}</p>
|
||||
<p className="text-gray-600 mb-8">{props.eventType.description}</p>
|
||||
</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}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import '../styles/globals.css';
|
||||
import {createTelemetryClient, TelemetryProvider} from '../lib/telemetry';
|
||||
import { Provider } from 'next-auth/client';
|
||||
import "../styles/globals.css";
|
||||
import { createTelemetryClient, TelemetryProvider } from "../lib/telemetry";
|
||||
import { Provider } from "next-auth/client";
|
||||
import type { AppProps } from "next/app";
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<TelemetryProvider value={createTelemetryClient()}>
|
||||
<Provider session={pageProps.session}>
|
||||
<Component {...pageProps} />
|
||||
</Provider>
|
||||
</TelemetryProvider>
|
||||
<TelemetryProvider value={createTelemetryClient()}>
|
||||
<Provider session={pageProps.session}>
|
||||
<Component {...pageProps} />
|
||||
</Provider>
|
||||
</TelemetryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,49 +1,63 @@
|
|||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import prisma from '../../../lib/prisma';
|
||||
import {getBusyCalendarTimes} from '../../../lib/calendarClient';
|
||||
import {getBusyVideoTimes} from '../../../lib/videoClient';
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import prisma from "../../../lib/prisma";
|
||||
import { getBusyCalendarTimes } from "../../../lib/calendarClient";
|
||||
import { getBusyVideoTimes } from "../../../lib/videoClient";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { user } = req.query
|
||||
const { user } = req.query;
|
||||
|
||||
const currentUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: user,
|
||||
},
|
||||
select: {
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
bufferTime: true
|
||||
}
|
||||
});
|
||||
const currentUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: user,
|
||||
},
|
||||
select: {
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
bufferTime: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCalendars = (await prisma.selectedCalendar.findMany({
|
||||
where: {
|
||||
userId: currentUser.id
|
||||
}
|
||||
}));
|
||||
const selectedCalendars = await prisma.selectedCalendar.findMany({
|
||||
where: {
|
||||
userId: currentUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
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 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, req.query.dateFrom, req.query.dateTo, selectedCalendars);
|
||||
const videoAvailability = await getBusyVideoTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo);
|
||||
const calendarAvailability = await getBusyCalendarTimes(
|
||||
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) {
|
||||
commonAvailability = calendarAvailability.filter(availability => videoAvailability.includes(availability));
|
||||
} else if(hasVideoIntegrations) {
|
||||
commonAvailability = videoAvailability;
|
||||
} else if(hasCalendarIntegrations) {
|
||||
commonAvailability = calendarAvailability;
|
||||
}
|
||||
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
||||
commonAvailability = calendarAvailability.filter((availability) =>
|
||||
videoAvailability.includes(availability)
|
||||
);
|
||||
} else if (hasVideoIntegrations) {
|
||||
commonAvailability = videoAvailability;
|
||||
} else if (hasCalendarIntegrations) {
|
||||
commonAvailability = calendarAvailability;
|
||||
}
|
||||
|
||||
commonAvailability = commonAvailability.map(a => ({
|
||||
start: dayjs(a.start).subtract(currentUser.bufferTime, 'minute').toString(),
|
||||
end: dayjs(a.end).add(currentUser.bufferTime, 'minute').toString()
|
||||
}));
|
||||
commonAvailability = commonAvailability.map((a) => ({
|
||||
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
||||
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
||||
}));
|
||||
|
||||
res.status(200).json(commonAvailability);
|
||||
res.status(200).json(commonAvailability);
|
||||
}
|
||||
|
|
|
@ -1,81 +1,111 @@
|
|||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import {getSession} from 'next-auth/client';
|
||||
import prisma from '../../../lib/prisma';
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/client";
|
||||
import prisma from "../../../lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({req: req});
|
||||
if (!session) {
|
||||
res.status(401).json({message: "Not authenticated"});
|
||||
return;
|
||||
}
|
||||
const session = await getSession({ req: req });
|
||||
if (!session) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method == "PATCH" || req.method == "POST") {
|
||||
|
||||
const data = {
|
||||
title: req.body.title,
|
||||
slug: req.body.slug,
|
||||
description: req.body.description,
|
||||
length: parseInt(req.body.length),
|
||||
hidden: req.body.hidden,
|
||||
locations: req.body.locations,
|
||||
eventName: req.body.eventName,
|
||||
customInputs: !req.body.customInputs
|
||||
? undefined
|
||||
: {
|
||||
deleteMany: {
|
||||
eventTypeId: req.body.id,
|
||||
NOT: {
|
||||
id: {in: req.body.customInputs.filter(input => !!input.id).map(e => e.id)}
|
||||
}
|
||||
},
|
||||
createMany: {
|
||||
data: req.body.customInputs.filter(input => !input.id).map(input => ({
|
||||
type: input.type,
|
||||
label: input.label,
|
||||
required: input.required
|
||||
}))
|
||||
},
|
||||
update: req.body.customInputs.filter(input => !!input.id).map(input => ({
|
||||
data: {
|
||||
type: input.type,
|
||||
label: input.label,
|
||||
required: input.required
|
||||
},
|
||||
where: {
|
||||
id: input.id
|
||||
}
|
||||
}))
|
||||
if (req.method == "PATCH" || req.method == "POST") {
|
||||
const data = {
|
||||
title: req.body.title,
|
||||
slug: req.body.slug,
|
||||
description: req.body.description,
|
||||
length: parseInt(req.body.length),
|
||||
hidden: req.body.hidden,
|
||||
locations: req.body.locations,
|
||||
eventName: req.body.eventName,
|
||||
customInputs: !req.body.customInputs
|
||||
? undefined
|
||||
: {
|
||||
deleteMany: {
|
||||
eventTypeId: req.body.id,
|
||||
NOT: {
|
||||
id: { in: req.body.customInputs.filter((input) => !!input.id).map((e) => e.id) },
|
||||
},
|
||||
};
|
||||
|
||||
if (req.method == "POST") {
|
||||
const createEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
res.status(200).json({message: 'Event created successfully'});
|
||||
}
|
||||
else if (req.method == "PATCH") {
|
||||
const updateEventType = await prisma.eventType.update({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
res.status(200).json({message: 'Event updated successfully'});
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method == "DELETE") {
|
||||
|
||||
const deleteEventType = await prisma.eventType.delete({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
createMany: {
|
||||
data: req.body.customInputs
|
||||
.filter((input) => !input.id)
|
||||
.map((input) => ({
|
||||
type: input.type,
|
||||
label: input.label,
|
||||
required: input.required,
|
||||
})),
|
||||
},
|
||||
update: req.body.customInputs
|
||||
.filter((input) => !!input.id)
|
||||
.map((input) => ({
|
||||
data: {
|
||||
type: input.type,
|
||||
label: input.label,
|
||||
required: input.required,
|
||||
},
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
res.status(200).json({message: 'Event deleted successfully'});
|
||||
if (req.method == "POST") {
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
res.status(200).json({ message: "Event created successfully" });
|
||||
} else if (req.method == "PATCH") {
|
||||
if (req.body.timeZone) {
|
||||
data.timeZone = req.body.timeZone;
|
||||
}
|
||||
|
||||
if (req.body.availability) {
|
||||
const openingHours = req.body.availability.openingHours || [];
|
||||
// const overrides = req.body.availability.dateOverrides || [];
|
||||
|
||||
await prisma.availability.deleteMany({
|
||||
where: {
|
||||
eventTypeId: +req.body.id,
|
||||
},
|
||||
});
|
||||
Promise.all(
|
||||
openingHours.map((schedule) =>
|
||||
prisma.availability.create({
|
||||
data: {
|
||||
eventTypeId: +req.body.id,
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
},
|
||||
})
|
||||
)
|
||||
).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
res.status(200).json({ message: "Event updated successfully" });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method == "DELETE") {
|
||||
await prisma.eventType.delete({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "Event deleted successfully" });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/client";
|
||||
import prisma from "../../../lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method == "PATCH") {
|
||||
const startMins = req.body.start;
|
||||
const endMins = req.body.end;
|
||||
|
||||
await prisma.schedule.update({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
startTime: startMins,
|
||||
endTime: endMins,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "Start and end times updated successfully" });
|
||||
}
|
||||
}
|
|
@ -1,18 +1,16 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import prisma from '../../lib/prisma';
|
||||
import {getSession} from "next-auth/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { getSession } from "next-auth/client";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const session = await getSession({req: req});
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({message: "Not authenticated"});
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
|
||||
// TODO: Prevent creating a team with identical names?
|
||||
|
||||
const createTeam = await prisma.team.create({
|
||||
|
@ -21,17 +19,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
},
|
||||
});
|
||||
|
||||
const createMembership = await prisma.membership.create({
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
teamId: createTeam.id,
|
||||
userId: session.user.id,
|
||||
role: 'OWNER',
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).setHeader('Location', process.env.BASE_URL + '/api/teams/1').send(null);
|
||||
return res.status(201).json({ message: "Team created" });
|
||||
}
|
||||
|
||||
res.status(404).send(null);
|
||||
res.status(404).json({ message: "Team not found" });
|
||||
}
|
||||
|
|
|
@ -1,17 +1,67 @@
|
|||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Select, { OptionBase } from "react-select";
|
||||
import prisma from "../../../lib/prisma";
|
||||
import { LocationType } from "../../../lib/location";
|
||||
import Shell from "../../../components/Shell";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import prisma from "@lib/prisma";
|
||||
import { LocationType } from "@lib/location";
|
||||
import Shell from "@components/Shell";
|
||||
import { getSession } from "next-auth/client";
|
||||
import { Scheduler } from "@components/ui/Scheduler";
|
||||
|
||||
import { LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput";
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
|
||||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
|
||||
export default function EventType(props: any): JSX.Element {
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { Availability, EventType, User } from "@prisma/client";
|
||||
import { validJson } from "@lib/jsonUtils";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
eventType: EventType;
|
||||
locationOptions: OptionBase[];
|
||||
availability: Availability[];
|
||||
};
|
||||
|
||||
type OpeningHours = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
type DateOverride = {
|
||||
date: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
type EventTypeInput = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
length: number;
|
||||
hidden: boolean;
|
||||
locations: unknown;
|
||||
eventName: string;
|
||||
customInputs: EventTypeCustomInput[];
|
||||
timeZone: string;
|
||||
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
|
||||
};
|
||||
|
||||
export default function EventTypePage({
|
||||
user,
|
||||
eventType,
|
||||
locationOptions,
|
||||
availability,
|
||||
}: Props): JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const inputOptions: OptionBase[] = [
|
||||
|
@ -21,17 +71,17 @@ export default function EventType(props: any): JSX.Element {
|
|||
{ value: EventTypeCustomInputType.Bool, label: "Checkbox" },
|
||||
];
|
||||
|
||||
const [, loading] = useSession();
|
||||
const [enteredAvailability, setEnteredAvailability] = useState();
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [showAddCustomModal, setShowAddCustomModal] = useState(false);
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionBase | undefined>(undefined);
|
||||
const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]);
|
||||
const [locations, setLocations] = useState(eventType.locations || []);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
||||
const [locations, setLocations] = useState(props.eventType.locations || []);
|
||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||
props.eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
);
|
||||
const locationOptions = props.locationOptions;
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>();
|
||||
const slugRef = useRef<HTMLInputElement>();
|
||||
|
@ -40,34 +90,41 @@ export default function EventType(props: any): JSX.Element {
|
|||
const isHiddenRef = useRef<HTMLInputElement>();
|
||||
const eventNameRef = useRef<HTMLInputElement>();
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
}
|
||||
useEffect(() => {
|
||||
setSelectedTimeZone(eventType.timeZone || user.timeZone);
|
||||
}, []);
|
||||
|
||||
async function updateEventTypeHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const enteredTitle = titleRef.current.value;
|
||||
const enteredSlug = slugRef.current.value;
|
||||
const enteredDescription = descriptionRef.current.value;
|
||||
const enteredLength = lengthRef.current.value;
|
||||
const enteredIsHidden = isHiddenRef.current.checked;
|
||||
const enteredEventName = eventNameRef.current.value;
|
||||
const enteredTitle: string = titleRef.current.value;
|
||||
const enteredSlug: string = slugRef.current.value;
|
||||
const enteredDescription: string = descriptionRef.current.value;
|
||||
const enteredLength: number = parseInt(lengthRef.current.value);
|
||||
const enteredIsHidden: boolean = isHiddenRef.current.checked;
|
||||
const enteredEventName: string = eventNameRef.current.value;
|
||||
// TODO: Add validation
|
||||
|
||||
const payload: EventTypeInput = {
|
||||
id: eventType.id,
|
||||
title: enteredTitle,
|
||||
slug: enteredSlug,
|
||||
description: enteredDescription,
|
||||
length: enteredLength,
|
||||
hidden: enteredIsHidden,
|
||||
locations,
|
||||
eventName: enteredEventName,
|
||||
customInputs,
|
||||
timeZone: selectedTimeZone,
|
||||
};
|
||||
|
||||
if (enteredAvailability) {
|
||||
payload.availability = enteredAvailability;
|
||||
}
|
||||
|
||||
await fetch("/api/availability/eventtype", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
id: props.eventType.id,
|
||||
title: enteredTitle,
|
||||
slug: enteredSlug,
|
||||
description: enteredDescription,
|
||||
length: enteredLength,
|
||||
hidden: enteredIsHidden,
|
||||
locations,
|
||||
eventName: enteredEventName,
|
||||
customInputs,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
@ -81,7 +138,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
|
||||
await fetch("/api/availability/eventtype", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.eventType.id }),
|
||||
body: JSON.stringify({ id: eventType.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
@ -106,6 +163,30 @@ export default function EventType(props: any): JSX.Element {
|
|||
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) => {
|
||||
setSelectedCustomInput(customInput);
|
||||
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
|
||||
|
@ -147,30 +228,6 @@ export default function EventType(props: any): JSX.Element {
|
|||
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) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
@ -207,13 +264,13 @@ export default function EventType(props: any): JSX.Element {
|
|||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{props.eventType.title} | Event Type | Calendso</title>
|
||||
<title>{eventType.title} | Event Type | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Shell heading={"Event Type - " + props.eventType.title}>
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<Shell heading={"Event Type - " + eventType.title}>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg mb-4">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<form onSubmit={updateEventTypeHandler}>
|
||||
<div className="mb-4">
|
||||
|
@ -229,7 +286,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
required
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="Quick Chat"
|
||||
defaultValue={props.eventType.title}
|
||||
defaultValue={eventType.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -240,7 +297,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
<div className="mt-1">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
|
||||
{location.hostname}/{props.user.username}/
|
||||
{typeof location !== "undefined" ? location.hostname : ""}/{user.username}/
|
||||
</span>
|
||||
<input
|
||||
ref={slugRef}
|
||||
|
@ -249,7 +306,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
id="slug"
|
||||
required
|
||||
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
|
||||
defaultValue={props.eventType.slug}
|
||||
defaultValue={eventType.slug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -390,7 +447,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
id="description"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="A quick video meeting."
|
||||
defaultValue={props.eventType.description}></textarea>
|
||||
defaultValue={eventType.description}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
|
@ -406,7 +463,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
required
|
||||
className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="15"
|
||||
defaultValue={props.eventType.length}
|
||||
defaultValue={eventType.length}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
|
||||
minutes
|
||||
|
@ -425,7 +482,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
id="title"
|
||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="Meeting with {USER}"
|
||||
defaultValue={props.eventType.eventName}
|
||||
defaultValue={eventType.eventName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -484,7 +541,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
name="ishidden"
|
||||
type="checkbox"
|
||||
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||
defaultChecked={props.eventType.hidden}
|
||||
defaultChecked={eventType.hidden}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
|
@ -497,12 +554,24 @@ export default function EventType(props: any): JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Update
|
||||
</button>
|
||||
<Link href="/availability">
|
||||
<a className="ml-2 btn btn-white">Cancel</a>
|
||||
</Link>
|
||||
<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">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -649,9 +718,7 @@ export default function EventType(props: any): JSX.Element {
|
|||
Is required
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="id" id="id" value={selectedCustomInput?.id} />
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
|
@ -670,32 +737,55 @@ export default function EventType(props: any): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
const validJson = (jsonString: string) => {
|
||||
try {
|
||||
const o = JSON.parse(jsonString);
|
||||
if (o && typeof o === "object") {
|
||||
return o;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Invalid JSON:", e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const session = await getSession(context);
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async ({ req, query }) => {
|
||||
const session = await getSession({ req });
|
||||
if (!session) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/auth/login",
|
||||
},
|
||||
};
|
||||
}
|
||||
const user = await prisma.user.findFirst({
|
||||
|
||||
const user: User = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
select: {
|
||||
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({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
@ -747,28 +837,28 @@ export async function getServerSideProps(context) {
|
|||
// Assuming it's Microsoft Teams.
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: parseInt(context.query.type),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
locations: true,
|
||||
eventName: true,
|
||||
customInputs: true,
|
||||
},
|
||||
});
|
||||
const getAvailability = (providesAvailability) =>
|
||||
providesAvailability.availability && providesAvailability.availability.length
|
||||
? providesAvailability.availability
|
||||
: null;
|
||||
|
||||
const availability: Availability[] = getAvailability(eventType) ||
|
||||
getAvailability(user) || [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: user.startTime,
|
||||
endTime: user.endTime,
|
||||
},
|
||||
];
|
||||
|
||||
availability.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
eventType,
|
||||
locationOptions,
|
||||
availability,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getSession, useSession } from "next-auth/client";
|
|||
import Shell from "../../components/Shell";
|
||||
|
||||
export default function Bookings({ bookings }) {
|
||||
const [session, loading] = useSession();
|
||||
const [, loading] = useSession();
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
|
@ -27,43 +27,39 @@ export default function Bookings({ bookings }) {
|
|||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Title
|
||||
Person
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
Event
|
||||
</th>
|
||||
<th
|
||||
{/* <th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
Date
|
||||
</th> */}
|
||||
<th scope="col" className="relative px-6 py-3">
|
||||
<span className="sr-only">Edit</span>
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{bookings.map((booking) => (
|
||||
<tr key={booking.uid}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{booking.title}
|
||||
<tr key={booking.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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 className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{booking.description}
|
||||
</td>
|
||||
<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 className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{booking.title}</div>
|
||||
<div className="text-sm text-gray-500">{booking.description}</div>
|
||||
</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">
|
||||
<a
|
||||
href={window.location.href + "/../reschedule/" + booking.uid}
|
||||
|
@ -115,6 +111,9 @@ export async function getServerSideProps(context) {
|
|||
description: true,
|
||||
attendees: true,
|
||||
},
|
||||
orderBy: {
|
||||
startTime: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return { props: { bookings } };
|
||||
|
|
|
@ -1,31 +1,41 @@
|
|||
import Head from 'next/head';
|
||||
import prisma from '../../lib/prisma';
|
||||
import Modal from '../../components/Modal';
|
||||
import Shell from '../../components/Shell';
|
||||
import SettingsShell from '../../components/Settings';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession, getSession } from 'next-auth/client';
|
||||
import {
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Shell from "../../components/Shell";
|
||||
import SettingsShell from "../../components/Settings";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Session } from "next-auth";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import TeamList from "../../components/team/TeamList";
|
||||
import TeamListItem from "../../components/team/TeamListItem";
|
||||
|
||||
export default function Teams(props) {
|
||||
|
||||
const [session, loading] = useSession();
|
||||
export default function Teams() {
|
||||
const [, loading] = useSession();
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [invites, setInvites] = useState([]);
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
|
||||
const loadTeams = () => fetch('/api/user/membership').then((res: any) => res.json()).then(
|
||||
(data) => {
|
||||
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
|
||||
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
|
||||
const handleErrors = async (resp) => {
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.message);
|
||||
}
|
||||
);
|
||||
return resp.json();
|
||||
};
|
||||
|
||||
useEffect(() => { loadTeams(); }, []);
|
||||
const loadData = () => {
|
||||
fetch("/api/user/membership")
|
||||
.then(handleErrors)
|
||||
.then((data) => {
|
||||
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
|
||||
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
|
||||
})
|
||||
.catch(console.log);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
|
@ -33,17 +43,18 @@ export default function Teams(props) {
|
|||
|
||||
const createTeam = (e) => {
|
||||
e.preventDefault();
|
||||
return fetch('/api/teams', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: e.target.elements['name'].value }),
|
||||
|
||||
return fetch("/api/teams", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: e.target.elements["name"].value }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(() => {
|
||||
loadTeams();
|
||||
loadData();
|
||||
setShowCreateTeamModal(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell heading="Teams">
|
||||
|
@ -60,10 +71,12 @@ export default function Teams(props) {
|
|||
<p className="mt-1 text-sm text-gray-500 mb-4">
|
||||
View, edit and create teams to organise relationships between users
|
||||
</p>
|
||||
{!(invites.length || teams.length) &&
|
||||
{!(invites.length || teams.length) && (
|
||||
<div className="bg-gray-50 sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Create a team to get started</h3>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Create a team to get started
|
||||
</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>Create your first team and invite other users to work together with you.</p>
|
||||
</div>
|
||||
|
@ -71,31 +84,35 @@ export default function Teams(props) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateTeamModal(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
className="btn btn-primary">
|
||||
Create new team
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
{!!(invites.length || teams.length) && <div>
|
||||
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>Create new team</button>
|
||||
</div>}
|
||||
{!!(invites.length || teams.length) && (
|
||||
<div>
|
||||
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>
|
||||
Create new team
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{!!teams.length &&
|
||||
<TeamList teams={teams} onChange={loadTeams}>
|
||||
</TeamList>
|
||||
}
|
||||
{!!teams.length && <TeamList teams={teams} onChange={loadData}></TeamList>}
|
||||
|
||||
{!!invites.length && <div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
|
||||
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
|
||||
{invites.map((team) => <TeamListItem onChange={loadTeams} key={team.id} team={team}></TeamListItem>)}
|
||||
</ul>
|
||||
</div>}
|
||||
{!!invites.length && (
|
||||
<div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
|
||||
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
|
||||
{invites.map((team) => (
|
||||
<TeamListItem onChange={loadData} key={team.id} team={team}></TeamListItem>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/*{teamsLoaded && <div className="flex justify-between">
|
||||
<div>
|
||||
|
@ -111,12 +128,20 @@ export default function Teams(props) {
|
|||
</div>}*/}
|
||||
</div>
|
||||
</div>
|
||||
{showCreateTeamModal &&
|
||||
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
{showCreateTeamModal && (
|
||||
<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>
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start mb-4">
|
||||
|
@ -124,33 +149,57 @@ export default function Teams(props) {
|
|||
<UsersIcon 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">Create a new team</h3>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
Create a new team
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
Create a new team to collaborate with users.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Create a new team to collaborate with users.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={createTeam}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input type="text" name="name" id="name" placeholder="Acme Inc." required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create team
|
||||
</button>
|
||||
<button onClick={() => setShowCreateTeamModal(false)} type="button" className="btn btn-white mr-2">
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateTeamModal(false)}
|
||||
type="button"
|
||||
className="btn btn-white mr-2">
|
||||
Cancel
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</SettingsShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export the `session` prop to use sessions with Server Side Rendering
|
||||
export const getServerSideProps: GetServerSideProps<{ session: Session | null }> = async (context) => {
|
||||
const session = await getSession(context);
|
||||
if (!session) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
return {
|
||||
props: { session },
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "timeZone" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Availability" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"label" TEXT,
|
||||
"userId" INTEGER,
|
||||
"eventTypeId" INTEGER,
|
||||
"days" INTEGER[],
|
||||
"startTime" INTEGER NOT NULL,
|
||||
"endTime" INTEGER NOT NULL,
|
||||
"date" DATE,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Availability" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Availability" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -21,8 +21,10 @@ model EventType {
|
|||
user User? @relation(fields: [userId], references: [id])
|
||||
userId Int?
|
||||
bookings Booking[]
|
||||
availability Availability[]
|
||||
eventName String?
|
||||
customInputs EventTypeCustomInput[]
|
||||
timeZone String?
|
||||
}
|
||||
|
||||
model Credential {
|
||||
|
@ -53,7 +55,9 @@ model User {
|
|||
credentials Credential[]
|
||||
teams Membership[]
|
||||
bookings Booking[]
|
||||
availability Availability[]
|
||||
selectedCalendars SelectedCalendar[]
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
|
@ -126,6 +130,19 @@ model Booking {
|
|||
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 {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
|
@ -150,4 +167,3 @@ model ResetPasswordRequest {
|
|||
email String
|
||||
expires DateTime
|
||||
}
|
||||
|
||||
|
|
|
@ -135,4 +135,24 @@ body {
|
|||
|
||||
#timeZone input:focus {
|
||||
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;
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
import { expect, it } from "@jest/globals";
|
||||
import { whereAndSelect } from "@lib/prisma";
|
||||
|
||||
it("can decorate using whereAndSelect", async () => {
|
||||
whereAndSelect(
|
||||
(queryObj) => {
|
||||
expect(queryObj).toStrictEqual({ where: { id: 1 }, select: { example: true } });
|
||||
},
|
||||
{ id: 1 },
|
||||
[
|
||||
"example",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("can do nested selects using . seperator", async () => {
|
||||
|
||||
whereAndSelect(
|
||||
(queryObj) => {
|
||||
expect(queryObj).toStrictEqual({
|
||||
where: {
|
||||
uid: 1,
|
||||
},
|
||||
select: {
|
||||
description: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
{ uid: 1 },
|
||||
[
|
||||
"description",
|
||||
"attendees.email",
|
||||
"attendees.name",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("can handle nesting deeply", async () => {
|
||||
whereAndSelect(
|
||||
(queryObj) => {
|
||||
expect(queryObj).toStrictEqual({
|
||||
where: {
|
||||
uid: 1,
|
||||
},
|
||||
select: {
|
||||
description: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: {
|
||||
select: {
|
||||
nested: true,
|
||||
}
|
||||
},
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
{ uid: 1 },
|
||||
[
|
||||
"description",
|
||||
"attendees.email.nested",
|
||||
"attendees.name",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("can handle nesting multiple", async () => {
|
||||
whereAndSelect(
|
||||
(queryObj) => {
|
||||
expect(queryObj).toStrictEqual({
|
||||
where: {
|
||||
uid: 1,
|
||||
},
|
||||
select: {
|
||||
description: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ uid: 1 },
|
||||
[
|
||||
"description",
|
||||
"attendees.email",
|
||||
"attendees.name",
|
||||
"bookings.id",
|
||||
"bookings.name",
|
||||
]
|
||||
);
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
import getSlots from "@lib/slots";
|
||||
import { expect, it } from "@jest/globals";
|
||||
import MockDate from "mockdate";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
MockDate.set('2021-06-20T11:59:59Z');
|
||||
|
||||
it('can fit 24 hourly slots for an empty day', async () => {
|
||||
// 24h in a day.
|
||||
expect(getSlots({
|
||||
inviteeDate: dayjs().add(1, 'day'),
|
||||
frequency: 60,
|
||||
workingHours: [
|
||||
{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }
|
||||
],
|
||||
organizerTimeZone: 'Europe/London'
|
||||
})).toHaveLength(24);
|
||||
});
|
||||
|
||||
it('only shows future booking slots on the same day', async () => {
|
||||
// The mock date is 1s to midday, so 12 slots should be open given 0 booking notice.
|
||||
expect(getSlots({
|
||||
inviteeDate: dayjs(),
|
||||
frequency: 60,
|
||||
workingHours: [
|
||||
{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }
|
||||
],
|
||||
organizerTimeZone: 'GMT'
|
||||
})).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('can cut off dates that due to invitee timezone differences fall on the next day', async () => {
|
||||
expect(getSlots({
|
||||
inviteeDate: dayjs().tz('Europe/Amsterdam').startOf('day'), // time translation +01:00
|
||||
frequency: 60,
|
||||
workingHours: [
|
||||
{ days: [0], startTime: 1380, endTime: 1440 }
|
||||
],
|
||||
organizerTimeZone: 'Europe/London'
|
||||
})).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can cut off dates that due to invitee timezone differences fall on the previous day', async () => {
|
||||
expect(getSlots({
|
||||
inviteeDate: dayjs().startOf('day'), // time translation -01:00
|
||||
frequency: 60,
|
||||
workingHours: [
|
||||
{ days: [0], startTime: 0, endTime: 60 }
|
||||
],
|
||||
organizerTimeZone: 'Europe/London'
|
||||
})).toHaveLength(0);
|
||||
});
|
|
@ -6,6 +6,11 @@
|
|||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@lib/*": ["lib/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
|
|
Loading…
Reference in New Issue