Resolved conflicts

pull/330/head
nicolas 2021-07-07 12:43:13 +02:00
commit 3c09837104
37 changed files with 3980 additions and 1192 deletions

3
.babelrc Normal file
View File

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

16
.editorconfig Normal file
View File

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

View File

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

View File

@ -30,8 +30,9 @@ Let's face it: Calendly and other scheduling tools are awesome. It made our live
### Product of the Month: April
#### 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.

View File

@ -1,112 +1,40 @@
import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
dayjs.extend(isBetween);
import { useEffect, useState, useMemo } from "react";
import getSlots from "../../lib/slots";
import Link from "next/link";
import { 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>

View File

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

View File

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

View File

@ -1,15 +1,14 @@
import { Switch } from "@headlessui/react";
import TimezoneSelect from "react-timezone-select";
import { useEffect, useState } from "react";
import {timeZone, is24h} from '../../lib/clock';
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(() => {
@ -18,22 +17,22 @@ const TimeOptions = (props) => {
}, []);
useEffect(() => {
if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
props.onSelectTimeZone(timeZone(selectedTimeZone));
}
}, [selectedTimeZone]);
useEffect(() => {
props.onToggle24hClock(is24h(is24hClock));
}, [is24hClock]);
return selectedTimeZone !== "" && (
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.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>
@ -43,8 +42,7 @@ const TimeOptions = (props) => {
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"
@ -67,7 +65,8 @@ const TimeOptions = (props) => {
className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
)
);
}
};
export default TimeOptions;

View File

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

144
components/ui/Scheduler.tsx Normal file
View File

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

View File

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

View File

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

View File

@ -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);
}

View File

@ -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);
@ -28,7 +29,7 @@ export default class EventAttendeeMail extends EventMail {
)} 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.
*

View File

@ -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.

View File

@ -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,13 +66,7 @@ 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 />
@ -86,6 +80,37 @@ export default class EventOrganizerMail extends EventMail {
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.additionInformation?.hangoutLink) {
return `<strong>Location:</strong> <a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
}
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
const locations = this.additionInformation?.entryPoints
.map((entryPoint) => {
return `
Join by ${entryPoint.entryPointType}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `<strong>Locations:</strong><br /> ${locations}`;
}
return this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "";
}
protected getAdditionalBody(): string {
return `
${this.getLocation()}
`;
}
/**
* Returns the payload object for the nodemailer.
*

11
lib/jsonUtils.ts Normal file
View File

@ -0,0 +1,11 @@
export const validJson = (jsonString: string) => {
try {
const o = JSON.parse(jsonString);
if (o && typeof o === "object") {
return o;
}
} catch (e) {
console.log("Invalid JSON:", e);
}
return false;
};

View File

@ -1,9 +1,9 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient;
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;
}
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;

View File

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

View File

@ -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"
}
}
}

View File

@ -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) {
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,
},
};
};

View File

@ -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">

View File

@ -1,8 +1,9 @@
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}>

View File

@ -1,11 +1,11 @@
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: {
@ -14,35 +14,49 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
select: {
credentials: true,
timeZone: true,
bufferTime: true
}
bufferTime: true,
id: true,
},
});
const selectedCalendars = (await prisma.selectedCalendar.findMany({
const selectedCalendars = await prisma.selectedCalendar.findMany({
where: {
userId: currentUser.id
}
}));
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 = [];
if (hasCalendarIntegrations && hasVideoIntegrations) {
commonAvailability = calendarAvailability.filter(availability => videoAvailability.includes(availability));
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);

View File

@ -1,6 +1,6 @@
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 });
@ -10,7 +10,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
if (req.method == "PATCH" || req.method == "POST") {
const data = {
title: req.body.title,
slug: req.body.slug,
@ -25,57 +24,88 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
deleteMany: {
eventTypeId: req.body.id,
NOT: {
id: {in: req.body.customInputs.filter(input => !!input.id).map(e => e.id)}
}
id: { in: req.body.customInputs.filter((input) => !!input.id).map((e) => e.id) },
},
},
createMany: {
data: req.body.customInputs.filter(input => !input.id).map(input => ({
data: req.body.customInputs
.filter((input) => !input.id)
.map((input) => ({
type: input.type,
label: input.label,
required: input.required
}))
required: input.required,
})),
},
update: req.body.customInputs.filter(input => !!input.id).map(input => ({
update: req.body.customInputs
.filter((input) => !!input.id)
.map((input) => ({
data: {
type: input.type,
label: input.label,
required: input.required
required: input.required,
},
where: {
id: input.id
}
}))
id: input.id,
},
})),
},
};
if (req.method == "POST") {
const createEventType = await prisma.eventType.create({
await prisma.eventType.create({
data: {
userId: session.user.id,
...data,
},
});
res.status(200).json({message: 'Event created successfully'});
res.status(200).json({ message: "Event created successfully" });
} else if (req.method == "PATCH") {
if (req.body.timeZone) {
data.timeZone = req.body.timeZone;
}
else if (req.method == "PATCH") {
const updateEventType = await prisma.eventType.update({
if (req.body.availability) {
const openingHours = req.body.availability.openingHours || [];
// const overrides = req.body.availability.dateOverrides || [];
await prisma.availability.deleteMany({
where: {
eventTypeId: +req.body.id,
},
});
Promise.all(
openingHours.map((schedule) =>
prisma.availability.create({
data: {
eventTypeId: +req.body.id,
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
},
})
)
).catch((error) => {
console.log(error);
});
}
await prisma.eventType.update({
where: {
id: req.body.id,
},
data,
});
res.status(200).json({message: 'Event updated successfully'});
res.status(200).json({ message: "Event updated successfully" });
}
}
if (req.method == "DELETE") {
const deleteEventType = await prisma.eventType.delete({
await prisma.eventType.delete({
where: {
id: req.body.id,
},
});
res.status(200).json({message: 'Event deleted successfully'});
res.status(200).json({ message: "Event deleted successfully" });
}
}

View File

@ -0,0 +1,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" });
}
}

View File

@ -1,9 +1,8 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from '../../lib/prisma';
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 });
if (!session) {
@ -12,7 +11,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
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" });
}

View File

@ -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,25 +90,23 @@ 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
await fetch("/api/availability/eventtype", {
method: "PATCH",
body: JSON.stringify({
id: props.eventType.id,
const payload: EventTypeInput = {
id: eventType.id,
title: enteredTitle,
slug: enteredSlug,
description: enteredDescription,
@ -67,7 +115,16 @@ export default function EventType(props: any): JSX.Element {
locations,
eventName: enteredEventName,
customInputs,
}),
timeZone: selectedTimeZone,
};
if (enteredAvailability) {
payload.availability = enteredAvailability;
}
await fetch("/api/availability/eventtype", {
method: "PATCH",
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>
<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>
<Link href="/availability">
<a className="ml-2 btn btn-white">Cancel</a>
</Link>
</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),
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,
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
hidden: true,
locations: true,
eventName: true,
customInputs: true,
},
});
];
availability.sort((a, b) => a.startTime - b.startTime);
return {
props: {
user,
eventType,
locationOptions,
availability,
},
};
}
};

View File

@ -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 } };

View File

@ -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) => {
const handleErrors = async (resp) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
return resp.json();
};
const loadData = () => {
fetch("/api/user/membership")
.then(handleErrors)
.then((data) => {
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
}
);
})
.catch(console.log);
};
useEffect(() => { loadTeams(); }, []);
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>
{!!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>)}
{invites.map((team) => (
<TeamListItem onChange={loadData} key={team.id} team={team}></TeamListItem>
))}
</ul>
</div>}
</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">&#8203;</span>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left 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,24 +149,36 @@ 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
onClick={() => setShowCreateTeamModal(false)}
type="button"
className="btn btn-white mr-2">
Cancel
</button>
</div>
@ -149,8 +186,20 @@ export default function Teams(props) {
</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 },
};
};

View File

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

View File

@ -21,8 +21,10 @@ model EventType {
user User? @relation(fields: [userId], references: [id])
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
}

View File

@ -136,3 +136,23 @@ 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;
}

108
test/lib/prisma.test.ts Normal file
View File

@ -0,0 +1,108 @@
import { expect, it } from "@jest/globals";
import { whereAndSelect } from "@lib/prisma";
it("can decorate using whereAndSelect", async () => {
whereAndSelect(
(queryObj) => {
expect(queryObj).toStrictEqual({ where: { id: 1 }, select: { example: true } });
},
{ id: 1 },
[
"example",
]
);
});
it("can do nested selects using . seperator", async () => {
whereAndSelect(
(queryObj) => {
expect(queryObj).toStrictEqual({
where: {
uid: 1,
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
},
{ uid: 1 },
[
"description",
"attendees.email",
"attendees.name",
]
);
});
it("can handle nesting deeply", async () => {
whereAndSelect(
(queryObj) => {
expect(queryObj).toStrictEqual({
where: {
uid: 1,
},
select: {
description: true,
attendees: {
select: {
email: {
select: {
nested: true,
}
},
name: true,
},
},
},
});
},
{ uid: 1 },
[
"description",
"attendees.email.nested",
"attendees.name",
]
);
});
it("can handle nesting multiple", async () => {
whereAndSelect(
(queryObj) => {
expect(queryObj).toStrictEqual({
where: {
uid: 1,
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
bookings: {
select: {
id: true,
name: true,
}
}
}
});
},
{ uid: 1 },
[
"description",
"attendees.email",
"attendees.name",
"bookings.id",
"bookings.name",
]
);
});

57
test/lib/slots.test.ts Normal file
View File

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

View File

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

2652
yarn.lock

File diff suppressed because it is too large Load Diff