Feature/round robin (#613)

* Heavy WIP

* More WIP

* Playing with backwards compat

* Moar wip

* wip

* Email changes for group feature

* Committing in redundant migrations for reference

* Combine all WIP migrations into a single feature migration

* Make backup of current version of radio area pending refactor

* Improved accessibility through keyboard

* Cleanup in seperate commit so I can cherrypick later

* Added RadioArea component

* wip

* Ignore .yarn file

* Kinda stable

* Getting closer...

* Hide header when there are only personal events

* Added uid to event create, updated EventTypeDescription

* Delete redundant migration

* Committing new team related migrations

* Optimising & implemented backwards compatibility

* Removed now redundant pages

* Undid prototyping to calendarClient I did not end up using

* Properly typed Select & fixed lint throughout

* How'd that get here, removed.

* TODO: investigate why userData is not compatible with passed type

* This likely matches the event type that is created for a user

* Few bugfixes

* Adding datepicker optimisations

* Fixed new event type spacing, initial profile should always be there

* Gave NEXT_PUBLIC_BASE_URL a try but I think it's not the right solution

* Updated EventTypeDescription to account for long titles, added logo to team page.

* Added logo to team query

* Added cancel Cypress test because an upcoming merge contains changes

* Fix for when the event type description is long

* Turned Theme into the useTheme hook, and made it fully compatible with teams pages

* Built AvatarGroup ui component + moved Avatar to ui

* Give the avatar some space fom the description

* Fixed timeZone selector

* Disabled tooltip +1-...

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
This commit is contained in:
Alex van Andel 2021-09-14 09:45:28 +01:00 committed by GitHub
parent e9ff62109d
commit 6ab741b927
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 3980 additions and 2413 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@
/node_modules
/.pnp
.pnp.js
/.yarn
# testing
/coverage

View File

@ -1,22 +0,0 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { defaultAvatarSrc } from "@lib/profile";
export type AvatarProps = {
className?: string;
imageSrc?: string;
displayName: string;
gravatarFallbackMd5?: string;
};
export default function Avatar({ imageSrc, displayName, gravatarFallbackMd5, className = "" }: AvatarProps) {
return (
<AvatarPrimitive.Root>
<AvatarPrimitive.Image src={imageSrc} alt={displayName} className={className} />
<AvatarPrimitive.Fallback delayMs={600}>
{gravatarFallbackMd5 && (
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={displayName} className={className} />
)}
</AvatarPrimitive.Fallback>
</AvatarPrimitive.Root>
);
}

View File

@ -19,7 +19,7 @@ import {
import Logo from "./Logo";
import classNames from "@lib/classNames";
import { Toaster } from "react-hot-toast";
import Avatar from "@components/Avatar";
import Avatar from "@components/ui/Avatar";
import { User } from "@prisma/client";
import { HeadSeo } from "@components/seo/head-seo";

View File

@ -1,9 +1,10 @@
import Link from "next/link";
import { useRouter } from "next/router";
import Slots from "./Slots";
import { useSlots } from "@lib/hooks/useSlots";
import { ExclamationIcon } from "@heroicons/react/solid";
import React from "react";
import Loader from "@components/Loader";
import { SchedulingType } from "@prisma/client";
const AvailableTimes = ({
date,
@ -12,17 +13,18 @@ const AvailableTimes = ({
minimumBookingNotice,
workingHours,
timeFormat,
user,
organizerTimeZone,
users,
schedulingType,
}) => {
const router = useRouter();
const { rescheduleUid } = router.query;
const { slots, isFullyBooked, hasErrors } = Slots({
const { slots, loading, error } = useSlots({
date,
eventLength,
schedulingType,
workingHours,
organizerTimeZone,
users,
minimumBookingNotice,
});
@ -34,43 +36,52 @@ const AvailableTimes = ({
<span className="text-gray-500">{date.format(", DD MMMM")}</span>
</span>
</div>
{slots.length > 0 &&
slots.map((slot) => (
<div key={slot.format()}>
<Link
href={
`/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
}>
<a className="block font-medium mb-4 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-transparent rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black">
{slot.format(timeFormat)}
</a>
</Link>
</div>
))}
{isFullyBooked && (
{!loading &&
slots?.length > 0 &&
slots.map((slot) => {
const bookingUrl = {
pathname: "book",
query: {
...router.query,
date: slot.time.format(),
type: eventTypeId,
},
};
if (rescheduleUid) {
bookingUrl.query.rescheduleUid = rescheduleUid;
}
if (schedulingType === SchedulingType.ROUND_ROBIN) {
bookingUrl.query.user = slot.users;
}
return (
<div key={slot.time.format()}>
<Link href={bookingUrl}>
<a className="block font-medium mb-4 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-transparent rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black">
{slot.time.format(timeFormat)}
</a>
</Link>
</div>
);
})}
{!loading && !error && !slots.length && (
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
<h1 className="text-xl text-black dark:text-white">{user.name} is all booked today.</h1>
<h1 className="text-xl text-black dark:text-white">All booked today.</h1>
</div>
)}
{!isFullyBooked && slots.length === 0 && !hasErrors && <Loader />}
{loading && <Loader />}
{hasErrors && (
{error && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Could not load the available time slots.{" "}
<a
href={"mailto:" + user.email}
className="font-medium underline text-yellow-700 hover:text-yellow-600">
Contact {user.name} via e-mail
</a>
</p>
<p className="text-sm text-yellow-700">Could not load the available time slots.</p>
</div>
</div>
</div>

View File

@ -5,6 +5,7 @@ import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import getSlots from "@lib/slots";
import dayjsBusinessDays from "dayjs-business-days";
import classNames from "@lib/classNames";
dayjs.extend(dayjsBusinessDays);
dayjs.extend(utc);
@ -15,7 +16,6 @@ const DatePicker = ({
onDatePicked,
workingHours,
organizerTimeZone,
inviteeTimeZone,
eventLength,
date,
periodType = "unlimited",
@ -25,28 +25,23 @@ const DatePicker = ({
periodCountCalendarDays,
minimumBookingNotice,
}) => {
const [calendar, setCalendar] = useState([]);
const [selectedMonth, setSelectedMonth] = useState<number>();
const [selectedDate, setSelectedDate] = useState<Dayjs>();
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
const [selectedMonth, setSelectedMonth] = useState<number | null>(
date
? periodType === "range"
? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
: date.month()
: dayjs().month() /* High chance server is going to have the same month */
);
useEffect(() => {
if (date) {
setSelectedDate(dayjs(date).tz(inviteeTimeZone));
setSelectedMonth(dayjs(date).tz(inviteeTimeZone).month());
return;
}
if (periodType === "range") {
setSelectedMonth(dayjs(periodStartDate).tz(inviteeTimeZone).month());
} else {
setSelectedMonth(dayjs().tz(inviteeTimeZone).month());
if (dayjs().month() !== selectedMonth) {
setSelectedMonth(dayjs().month());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (selectedDate) onDatePicked(selectedDate);
}, [selectedDate]);
// Handle month changes
const incrementMonth = () => {
setSelectedMonth(selectedMonth + 1);
@ -56,24 +51,27 @@ const DatePicker = ({
setSelectedMonth(selectedMonth - 1);
};
const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
useEffect(() => {
if (!selectedMonth) {
// wish next had a way of dealing with this magically;
return;
// 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 inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth);
const days = Array(weekdayOfFirst).fill(null);
const isDisabled = (day: number) => {
const date: Dayjs = inviteeDate.date(day);
const date: Dayjs = inviteeDate().date(day);
switch (periodType) {
case "rolling": {
const periodRollingEndDay = periodCountCalendarDays
? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day")
: dayjs().tz(organizerTimeZone).businessDaysAdd(periodDays, "days").endOf("day");
return (
date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
date.endOf("day").isBefore(dayjs().utcOffsett(date.utcOffset())) ||
date.endOf("day").isAfter(periodRollingEndDay) ||
!getSlots({
inviteeDate: date,
@ -89,7 +87,7 @@ const DatePicker = ({
const periodRangeStartDay = dayjs(periodStartDate).tz(organizerTimeZone).endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).tz(organizerTimeZone).endOf("day");
return (
date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
date.endOf("day").isBefore(periodRangeStartDay) ||
date.endOf("day").isAfter(periodRangeEndDay) ||
!getSlots({
@ -105,7 +103,7 @@ const DatePicker = ({
case "unlimited":
default:
return (
date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) ||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
!getSlots({
inviteeDate: date,
frequency: eventLength,
@ -117,81 +115,35 @@ const DatePicker = ({
}
};
// Set up calendar
const daysInMonth = inviteeDate.daysInMonth();
const days = [];
const daysInMonth = inviteeDate().daysInMonth();
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
days.push({ disabled: isDisabled(i), date: 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>
));
setDays(days);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMonth]);
// Combine placeholder days with actual days
setCalendar([
...emptyDays,
...days.map((day) => (
<div
key={day}
style={{
paddingTop: "100%",
}}
className="w-full relative">
<button
onClick={() => setSelectedDate(inviteeDate.date(day))}
disabled={isDisabled(day)}
className={
"absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto hover:border hover:border-black dark:hover:border-white" +
(isDisabled(day)
? " text-gray-400 font-light hover:border-0 cursor-default"
: " dark:text-white text-primary-500 font-medium") +
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
? " bg-black text-white-important"
: !isDisabled(day)
? " bg-gray-100 dark:bg-gray-600"
: "")
}>
{day}
</button>
</div>
)),
]);
}, [selectedMonth, inviteeTimeZone, selectedDate]);
return selectedMonth ? (
return (
<div
className={
"mt-8 sm:mt-0 sm:min-w-[455px] " +
(selectedDate
(date
? "w-full sm:w-1/2 md:w-1/3 sm:border-r sm:dark:border-gray-800 sm:pl-4 sm:pr-6 "
: "w-full sm:pl-4")
}>
<div className="flex text-gray-600 font-light text-xl mb-4">
<span className="w-1/2 text-gray-600 dark:text-white">
<strong className="text-gray-900 dark:text-white">
{dayjs().month(selectedMonth).format("MMMM")}
</strong>
<span className="text-gray-500"> {dayjs().month(selectedMonth).format("YYYY")}</span>
<strong className="text-gray-900 dark:text-white">{inviteeDate().format("MMMM")}</strong>
<span className="text-gray-500"> {inviteeDate().format("YYYY")}</span>
</span>
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
<button
onClick={decrementMonth}
className={
"group mr-2 p-1" +
(selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400 dark:text-gray-600")
"group mr-2 p-1" + (selectedMonth <= dayjs().month() && "text-gray-400 dark:text-gray-600")
}
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
disabled={selectedMonth <= dayjs().month()}>
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
</button>
<button className="group p-1" onClick={incrementMonth}>
@ -208,9 +160,40 @@ const DatePicker = ({
</div>
))}
</div>
<div className="grid grid-cols-7 gap-2 text-center">{calendar}</div>
<div className="grid grid-cols-7 gap-2 text-center">
{days.map((day, idx) => (
<div
key={day === null ? `e-${idx}` : `day-${day.date}`}
style={{
paddingTop: "100%",
}}
className="w-full relative">
{day === null ? (
<div key={`e-${idx}`} />
) : (
<button
onClick={() => onDatePicked(inviteeDate().date(day.date))}
disabled={day.disabled}
className={classNames(
"absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto",
"hover:border hover:border-black dark:hover:border-white",
day.disabled
? "text-gray-400 font-light hover:border-0 cursor-default"
: "dark:text-white text-primary-500 font-medium",
date && date.isSame(inviteeDate().date(day.date), "day")
? "bg-black text-white-important"
: !day.disabled
? " bg-gray-100 dark:bg-gray-600"
: ""
)}>
{day.date}
</button>
)}
</div>
))}
</div>
</div>
) : null;
);
};
export default DatePicker;

View File

@ -29,9 +29,7 @@ const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organize
setIsFullyBooked(false);
setHasErrors(false);
fetch(
`/api/availability/${user}?dateFrom=${date.startOf("day").utc().startOf("day").format()}&dateTo=${date
.endOf("day")
.utc()
`/api/availability/${user}?dateFrom=${date.startOf("day").format()}&dateTo=${date
.endOf("day")
.format()}`
)

View File

@ -0,0 +1,232 @@
// Get router variables
import { useRouter } from "next/router";
import { useEffect, useState, useMemo } from "react";
import { EventType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import utc from "dayjs/plugin/utc";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
import DatePicker from "@components/booking/DatePicker";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import PoweredByCalendso from "@components/ui/PoweredByCalendso";
import { timeZone } from "@lib/clock";
import AvailableTimes from "@components/booking/AvailableTimes";
import TimeOptions from "@components/booking/TimeOptions";
import * as Collapsible from "@radix-ui/react-collapsible";
import { HeadSeo } from "@components/seo/head-seo";
import { asStringOrNull } from "@lib/asStringOrNull";
import useTheme from "@lib/hooks/useTheme";
import AvatarGroup from "@components/ui/AvatarGroup";
dayjs.extend(utc);
dayjs.extend(customParseFormat);
type AvailabilityPageProps = {
eventType: EventType;
profile: {
name: string;
image: string;
theme?: string;
};
workingHours: [];
};
const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPageProps) => {
const router = useRouter();
const { rescheduleUid } = router.query;
const themeLoaded = useTheme(profile.theme);
const selectedDate = useMemo(() => {
const dateString = asStringOrNull(router.query.date);
if (dateString) {
// todo some extra validation maybe.
const utcOffsetAsDate = dayjs(dateString.substr(11, 14), "Hmm");
const utcOffset = parseInt(
dateString.substr(10, 1) + (utcOffsetAsDate.hour() * 60 + utcOffsetAsDate.minute())
);
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffset, true);
return date.isValid() ? date : null;
}
return null;
}, [router.query.date]);
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState("h:mma");
const telemetry = useTelemetry();
useEffect(() => {
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
}, [telemetry]);
const changeDate = (newDate: Dayjs) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
router.replace(
{
query: {
...router.query,
date: newDate.format("YYYY-MM-DDZZ"),
},
},
undefined,
{
shallow: true,
}
);
};
const handleSelectTimeZone = (selectedTimeZone: string): void => {
if (selectedDate) {
changeDate(selectedDate.tz(selectedTimeZone, true));
}
timeZone(selectedTimeZone);
setIsTimeOptionsOpen(false);
};
const handleToggle24hClock = (is24hClock: boolean) => {
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
};
return (
themeLoaded && (
<>
<HeadSeo
title={`${rescheduleUid ? "Reschedule" : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? "Reschedule" : ""} ${eventType.title}`}
name={profile.name}
avatar={profile.image}
/>
<div>
<main
className={
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
}>
<div className="bg-white border-gray-200 rounded-sm sm:dark:border-gray-600 dark:bg-gray-900 md:border">
{/* mobile: details */}
<div className="block p-4 sm:p-8 md:hidden">
<div className="flex items-center">
<AvatarGroup
items={[{ image: profile.image, alt: profile.name }].concat(
eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: user.avatar,
}))
)}
size={9}
truncateAfter={5}
/>
<div className="ml-3">
<p className="text-sm font-medium text-black dark:text-gray-300">{profile.name}</p>
<div className="flex gap-2 text-xs font-medium text-gray-600">
{eventType.title}
<div>
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{eventType.length} minutes
</div>
</div>
</div>
</div>
<p className="mt-3 text-gray-600 dark:text-gray-200">{eventType.description}</p>
</div>
<div className="px-4 sm:flex sm:py-5 sm:p-4">
<div
className={
"hidden md:block pr-8 sm:border-r sm:dark:border-gray-800 " +
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}>
<AvatarGroup
items={[{ image: profile.image, alt: profile.name }].concat(
eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: user.avatar,
}))
)}
size={16}
truncateAfter={3}
/>
<h2 className="font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
{eventType.title}
</h1>
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{eventType.length} minutes
</p>
<TimezoneDropdown />
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
</div>
<DatePicker
date={selectedDate}
periodType={eventType?.periodType}
periodStartDate={eventType?.periodStartDate}
periodEndDate={eventType?.periodEndDate}
periodDays={eventType?.periodDays}
periodCountCalendarDays={eventType?.periodCountCalendarDays}
onDatePicked={changeDate}
workingHours={[
{
days: [0, 1, 2, 3, 4, 5, 6],
endTime: 1440,
startTime: 0,
},
]}
weekStart="Sunday"
eventLength={eventType.length}
minimumBookingNotice={eventType.minimumBookingNotice}
/>
<div className="block mt-4 ml-1 sm:hidden">
<TimezoneDropdown />
</div>
{selectedDate && (
<AvailableTimes
workingHours={workingHours}
timeFormat={timeFormat}
minimumBookingNotice={eventType.minimumBookingNotice}
eventTypeId={eventType.id}
eventLength={eventType.length}
date={selectedDate}
users={eventType.users}
schedulingType={eventType.schedulingType ?? null}
/>
)}
</div>
</div>
{eventType.users.length && isBrandingHidden(eventType.users[0]) && <PoweredByCalendso />}
</main>
</div>
</>
)
);
function TimezoneDropdown() {
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="px-2 py-1 mb-1 -ml-2 text-left text-gray-500 min-w-32">
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{timeZone()}
{isTimeOptionsOpen ? (
<ChevronUpIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
) : (
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
)}
</Collapsible.Trigger>
<Collapsible.Content>
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
</Collapsible.Content>
</Collapsible.Root>
);
}
};
export default AvailabilityPage;

View File

@ -0,0 +1,416 @@
import Head from "next/head";
import { useRouter } from "next/router";
import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid";
import { EventTypeCustomInputType } from "@prisma/client";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import "react-phone-number-input/style.css";
import PhoneInput from "react-phone-number-input";
import { LocationType } from "@lib/location";
import { Button } from "@components/ui/Button";
import { ReactMultiEmail } from "react-multi-email";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import useTheme from "@lib/hooks/useTheme";
import AvatarGroup from "@components/ui/AvatarGroup";
const BookingPage = (props: any): JSX.Element => {
const router = useRouter();
const { rescheduleUid } = router.query;
const themeLoaded = useTheme(props.profile.theme);
const date = asStringOrNull(router.query.date);
const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma";
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [guestToggle, setGuestToggle] = useState(false);
const [guestEmails, setGuestEmails] = useState([]);
const locations = props.eventType.locations || [];
const [selectedLocation, setSelectedLocation] = useState<LocationType>(
locations.length === 1 ? locations[0].type : ""
);
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
}, []);
function toggleGuestEmailInput() {
setGuestToggle(!guestToggle);
}
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
// TODO: Move to translations
const locationLabels = {
[LocationType.InPerson]: "In-person meeting",
[LocationType.Phone]: "Phone call",
[LocationType.GoogleMeet]: "Google Meet",
[LocationType.Zoom]: "Zoom Video",
};
const bookingHandler = (event) => {
const book = async () => {
setLoading(true);
setError(false);
let notes = "";
if (props.eventType.customInputs) {
notes = props.eventType.customInputs
.map((input) => {
const data = event.target["custom_" + input.id];
if (data) {
if (input.type === EventTypeCustomInputType.BOOL) {
return input.label + "\n" + (data.checked ? "Yes" : "No");
} else {
return input.label + "\n" + data.value;
}
}
})
.join("\n\n");
}
if (!!notes && !!event.target.notes.value) {
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
const payload = {
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
name: event.target.name.value,
email: event.target.email.value,
notes: notes,
guests: guestEmails,
eventTypeId: props.eventType.id,
rescheduleUid: rescheduleUid,
timeZone: timeZone(),
};
if (router.query.user) {
payload.user = router.query.user;
}
if (selectedLocation) {
switch (selectedLocation) {
case LocationType.Phone:
payload["location"] = event.target.phone.value;
break;
case LocationType.InPerson:
payload["location"] = locationInfo(selectedLocation).address;
break;
// Catches all other location types, such as Google Meet, Zoom etc.
default:
payload["location"] = selectedLocation;
}
}
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
/*const res = await */ fetch("/api/book/event", {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
// TODO When the endpoint is fixed, change this to await the result again
//if (res.ok) {
let successUrl = `/success?date=${encodeURIComponent(date)}&type=${props.eventType.id}&user=${
props.profile.slug
}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
if (payload["location"]) {
if (payload["location"].includes("integration")) {
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
} else {
successUrl += "&location=" + encodeURIComponent(payload["location"]);
}
}
await router.push(successUrl);
};
event.preventDefault();
book();
};
return (
themeLoaded && (
<div>
<Head>
<title>
{rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with {props.profile.name}{" "}
| Calendso
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-0 sm:my-24">
<div className="dark:bg-neutral-900 bg-white overflow-hidden border border-gray-200 dark:border-0 sm:rounded-sm">
<div className="sm:flex px-4 py-5 sm:p-4">
<div className="sm:w-1/2 sm:border-r sm:dark:border-black">
<AvatarGroup
size={16}
items={[{ image: props.profile.image, alt: props.profile.name }].concat(
props.eventType.users
.filter((user) => user.name !== props.profile.name)
.map((user) => ({
image: user.avatar,
title: user.name,
}))
)}
/>
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.profile.name}</h2>
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
{props.eventType.title}
</h1>
<p className="text-gray-500 mb-2">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
{selectedLocation === LocationType.InPerson && (
<p className="text-gray-500 mb-2">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{locationInfo(selectedLocation).address}
</p>
)}
<p className="text-green-500 mb-4">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{dayjs(date)
.tz(timeZone())
.format(timeFormat + ", dddd DD MMMM YYYY")}
</p>
<p className="dark:text-white text-gray-600 mb-8">{props.eventType.description}</p>
</div>
<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 dark:text-white text-gray-700">
Your name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="John Doe"
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
/>
</div>
</div>
<div className="mb-4">
<label
htmlFor="email"
className="block text-sm font-medium dark:text-white text-gray-700">
Email address
</label>
<div className="mt-1">
<input
type="email"
name="email"
id="email"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="you@example.com"
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
/>
</div>
</div>
{locations.length > 1 && (
<div className="mb-4">
<span className="block text-sm font-medium dark:text-white text-gray-700">
Location
</span>
{locations.map((location) => (
<label key={location.type} className="block">
<input
type="radio"
required
onChange={(e) => setSelectedLocation(e.target.value)}
className="location focus:ring-black h-4 w-4 text-black border-gray-300 mr-2"
name="location"
value={location.type}
checked={selectedLocation === location.type}
/>
<span className="text-sm ml-2 dark:text-gray-500">
{locationLabels[location.type]}
</span>
</label>
))}
</div>
)}
{selectedLocation === LocationType.Phone && (
<div className="mb-4">
<label
htmlFor="phone"
className="block text-sm font-medium dark:text-white text-gray-700">
Phone Number
</label>
<div className="mt-1">
<PhoneInput
name="phone"
placeholder="Enter phone number"
id="phone"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
onChange={() => {
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
}}
/>
</div>
</div>
)}
{props.eventType.customInputs &&
props.eventType.customInputs
.sort((a, b) => a.id - b.id)
.map((input) => (
<div className="mb-4" key={"input-" + input.label.toLowerCase}>
{input.type !== EventTypeCustomInputType.BOOL && (
<label
htmlFor={"custom_" + input.id}
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
rows={3}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
name={"custom_" + input.id}
id={"custom_" + input.id}
className="focus:ring-black h-4 w-4 text-black border-gray-300 rounded mr-2"
placeholder=""
/>
<label
htmlFor={"custom_" + input.id}
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
{input.label}
</label>
</div>
)}
</div>
))}
<div className="mb-4">
{!guestToggle && (
<label
onClick={toggleGuestEmailInput}
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-blue-500 mb-1 hover:cursor-pointer">
+ Additional Guests
</label>
)}
{guestToggle && (
<div>
<label
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
Guests
</label>
<ReactMultiEmail
placeholder="guest@example.com"
emails={guestEmails}
onChange={(_emails: string[]) => {
setGuestEmails(_emails);
}}
getLabel={(email: string, index: number, removeEmail: (index: number) => void) => {
return (
<div data-tag key={index}>
{email}
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
</div>
);
}}
/>
</div>
)}
</div>
<div className="mb-4">
<label
htmlFor="notes"
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
Additional notes
</label>
<textarea
name="notes"
id="notes"
rows={3}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="Please share anything that will help prepare for our meeting."
defaultValue={props.booking ? props.booking.description : ""}
/>
</div>
<div className="flex items-start space-x-2">
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
<Button type="submit" loading={loading}>
{rescheduleUid ? "Reschedule" : "Confirm"}
</Button>
<Button color="secondary" type="button" onClick={() => router.back()}>
Cancel
</Button>
</div>
</form>
{error && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mt-2">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Could not {rescheduleUid ? "reschedule" : "book"} the meeting.
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</main>
</div>
)
);
};
export default BookingPage;

View File

@ -0,0 +1,43 @@
import { EventType, SchedulingType } from "@prisma/client";
import { ClockIcon, InformationCircleIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
import React from "react";
import classNames from "@lib/classNames";
export type EventTypeDescriptionProps = {
eventType: EventType;
className?: string;
};
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
return (
<ul className={classNames("mt-2 space-x-4 text-neutral-500 dark:text-white flex", className)}>
<li className="flex whitespace-nowrap">
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{eventType.length}m
</li>
{eventType.schedulingType ? (
<li className="flex whitespace-nowrap">
<UsersIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && "Round Robin"}
{eventType.schedulingType === SchedulingType.COLLECTIVE && "Collective"}
</li>
) : (
<li className="flex whitespace-nowrap">
<UserIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
1-on-1
</li>
)}
{eventType.description && (
<li className="flex">
<InformationCircleIcon
className="flex-none inline mr-1.5 mt-0.5 h-4 w-4 text-neutral-400"
aria-hidden="true"
/>
<span>{eventType.description.substring(0, 100)}</span>
</li>
)}
</ul>
);
};
export default EventTypeDescription;

View File

@ -3,7 +3,7 @@ import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
import ErrorAlert from "@components/ui/alerts/Error";
import { UsernameInput } from "@components/ui/UsernameInput";
import MemberList from "./MemberList";
import Avatar from "@components/Avatar";
import Avatar from "@components/ui/Avatar";
import ImageUploader from "@components/ImageUploader";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";

View File

@ -3,7 +3,7 @@ import Dropdown from "../ui/Dropdown";
import { useState } from "react";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/Avatar";
import Avatar from "@components/ui/Avatar";
import { Member } from "@lib/member";
import Button from "@components/ui/Button";

View File

@ -11,7 +11,7 @@ import { Tooltip } from "@components/Tooltip";
import Link from "next/link";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/Avatar";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import showToast from "@lib/notification";

View File

@ -1,14 +1,12 @@
import React from "react";
import Text from "@components/ui/Text";
import Link from "next/link";
import Avatar from "@components/Avatar";
import Avatar from "@components/ui/Avatar";
import { ArrowRightIcon } from "@heroicons/react/outline";
import useTheme from "@components/Theme";
import classnames from "classnames";
import { ArrowLeftIcon } from "@heroicons/react/solid";
const Team = ({ team }) => {
useTheme();
const Member = ({ member }) => {
const classes = classnames(
"group",
@ -37,18 +35,15 @@ const Team = ({ team }) => {
)}
/>
<Avatar
displayName={member.user.name}
imageSrc={member.user.avatar}
className="w-12 h-12 rounded-full"
/>
<section className="space-y-2">
<Text variant="title">{member.user.name}</Text>
<Text variant="subtitle" className="w-6/8">
{member.user.bio}
</Text>
</section>
<div>
<Avatar displayName={member.user.name} imageSrc={member.user.avatar} className="w-12 h-12" />
<section className="space-y-2">
<Text variant="title">{member.user.name}</Text>
<Text variant="subtitle" className="w-6/8">
{member.user.bio}
</Text>
</section>
</div>
</div>
</Link>
);
@ -69,17 +64,18 @@ const Team = ({ team }) => {
};
return (
<article className="flex flex-col space-y-8 lg:space-y-12">
<div className="mb-8 text-center">
<Avatar
displayName={team.name}
imageSrc={team.logo}
className="mx-auto w-20 h-20 rounded-full mb-4"
/>
<Text variant="headline">{team.name}</Text>
</div>
<div>
<Members members={team.members} />
</article>
{team.eventTypes.length && (
<aside className="text-center dark:text-white mt-8">
<Link href={`/team/${team.slug}`} shallow={true}>
<a>
<ArrowLeftIcon className="h-6 w-6 inline text-neutral-500" /> Go back
</a>
</Link>
</aside>
)}
</div>
);
};

47
components/ui/Avatar.tsx Normal file
View File

@ -0,0 +1,47 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as Tooltip from "@radix-ui/react-tooltip";
import { defaultAvatarSrc } from "@lib/profile";
import classNames from "@lib/classNames";
export type AvatarProps = {
className?: string;
size: number;
imageSrc?: string;
title?: string;
alt: string;
gravatarFallbackMd5?: string;
};
export default function Avatar({ imageSrc, gravatarFallbackMd5, size, alt, title, ...props }: AvatarProps) {
const className = classNames(
"border-2 border-gray-300 rounded-full",
props.className,
`h-${size} w-${size}`
);
const avatar = (
<AvatarPrimitive.Root>
<AvatarPrimitive.Image
src={imageSrc}
alt={alt}
className={classNames("border-2 border-gray-300 rounded-full", `h-auto w-${size}`, props.className)}
/>
<AvatarPrimitive.Fallback delayMs={600}>
{gravatarFallbackMd5 && (
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={alt} className={className} />
)}
</AvatarPrimitive.Fallback>
</AvatarPrimitive.Root>
);
return title ? (
<Tooltip.Tooltip delayDuration="300">
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
<Tooltip.Content className="p-2 rounded-sm text-sm bg-black text-white shadow-sm">
<Tooltip.Arrow />
{title}
</Tooltip.Content>
</Tooltip.Tooltip>
) : (
<>{avatar}</>
);
}

View File

@ -0,0 +1,56 @@
import React from "react";
import Avatar from "@components/ui/Avatar";
import classNames from "@lib/classNames";
// import * as Tooltip from "@radix-ui/react-tooltip";
export type AvatarGroupProps = {
size: number;
truncateAfter?: number;
items: {
image: string;
title?: string;
alt: string;
}[];
className?: string;
};
export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
/* const truncatedAvatars: string[] =
props.items.length > props.truncateAfter
? props.items
.slice(props.truncateAfter)
.map((item) => item.title)
.filter(Boolean)
: [];*/
return (
<ul className={classNames("flex -space-x-2 overflow-hidden", props.className)}>
{props.items.slice(0, props.truncateAfter).map((item, idx) => (
<li key={idx} className="inline-block">
<Avatar imageSrc={item.image} title={item.title} alt={item.alt} size={props.size} />
</li>
))}
{/*props.items.length > props.truncateAfter && (
<li className="inline-block relative">
<Tooltip.Tooltip delayDuration="300">
<Tooltip.TooltipTrigger className="cursor-default">
<span className="w-16 absolute bottom-1.5 border-2 border-gray-300 flex-inline items-center text-white pt-4 text-2xl top-0 rounded-full block bg-neutral-600">+1</span>
</Tooltip.TooltipTrigger>
{truncatedAvatars.length !== 0 && (
<Tooltip.Content className="p-2 rounded-sm text-sm bg-black text-white shadow-sm">
<Tooltip.Arrow />
<ul>
{truncatedAvatars.map((title) => (
<li key={title}>{title}</li>
))}
</ul>
</Tooltip.Content>
)}
</Tooltip.Tooltip>
</li>
)*/}
</ul>
);
};
export default AvatarGroup;

View File

@ -15,7 +15,6 @@ type Props = {
timeZone: string;
availability: Availability[];
setTimeZone: unknown;
setAvailability: unknown;
};
export const Scheduler = ({
@ -108,7 +107,7 @@ export const Scheduler = ({
<div className="mt-1">
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
value={{ value: selectedTimeZone }}
onChange={(tz) => setTimeZone(tz.value)}
className="shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
/>

View File

@ -0,0 +1,91 @@
import Select from "@components/ui/form/Select";
import { XIcon, CheckIcon } from "@heroicons/react/outline";
import React, { ForwardedRef, useEffect, useState } from "react";
import Avatar from "@components/ui/Avatar";
import { OptionsType } from "react-select/lib/types";
export type CheckedSelectProps = {
defaultValue?: [];
placeholder?: string;
name?: string;
options: [];
onChange: (options: OptionsType) => void;
disabled: [];
};
export const CheckedSelect = React.forwardRef((props: CheckedSelectProps, ref: ForwardedRef<unknown>) => {
const [selectedOptions, setSelectedOptions] = useState<[]>(props.defaultValue || []);
useEffect(() => {
props.onChange(selectedOptions);
}, [selectedOptions]);
const formatOptionLabel = ({ label, avatar, disabled }) => (
<div className="flex">
<Avatar className="h-6 w-6 rounded-full mr-3" displayName={label} imageSrc={avatar} />
{label}
{disabled && (
<div className="flex-grow">
<CheckIcon className="text-neutral-500 w-6 h-6 float-right" />
</div>
)}
</div>
);
const options = props.options.map((option) => ({
...option,
disabled: !!selectedOptions.find((selectedOption) => selectedOption.value === option.value),
}));
const removeOption = (value) =>
setSelectedOptions(selectedOptions.filter((option) => option.value !== value));
const changeHandler = (selections) =>
selections.forEach((selected) => {
if (selectedOptions.find((option) => option.value === selected.value)) {
removeOption(selected.value);
return;
}
setSelectedOptions(selectedOptions.concat(selected));
});
return (
<>
<Select
ref={ref}
styles={{
option: (styles, { isDisabled }) => ({
...styles,
backgroundColor: isDisabled ? "#F5F5F5" : "inherit",
}),
}}
name={props.name}
placeholder={props.placeholder || "Select..."}
isSearchable={false}
formatOptionLabel={formatOptionLabel}
options={options}
isMulti
value={props.placeholder || "Select..."}
onChange={changeHandler}
/>
{selectedOptions.map((option) => (
<div key={option.value} className="border border-1 p-2 font-medium">
<Avatar
className="w-6 h-6 rounded-full inline mr-2"
imageSrc={option.avatar}
displayName={option.label}
/>
{option.label}
<XIcon
onClick={() => changeHandler([option])}
className="cursor-pointer h-5 w-5 mt-0.5 text-neutral-500 float-right"
/>
</div>
))}
</>
);
});
CheckedSelect.displayName = "CheckedSelect";
export default CheckedSelect;

View File

@ -0,0 +1,28 @@
import React, { PropsWithChildren } from "react";
import Select, { components, NamedProps } from "react-select";
import classNames from "@lib/classNames";
export const SelectComp = (props: PropsWithChildren<NamedProps>) => (
<Select
theme={(theme) => ({
...theme,
borderRadius: "2px",
colors: {
...theme.colors,
primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
primary50: "rgba(17, 17, 17, var(--tw-bg-opacity))",
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
},
})}
components={{
...components,
IndicatorSeparator: () => null,
}}
className={classNames("text-sm shadow-sm focus:border-primary-500", props.className)}
{...props}
/>
);
SelectComp.displayName = "Select";
export default SelectComp;

View File

@ -0,0 +1,76 @@
import React, { PropsWithChildren, useState } from "react";
import classNames from "@lib/classNames";
type RadioAreaProps = React.InputHTMLAttributes<HTMLInputElement> & {
onChange: (value: string) => void;
defaultChecked: boolean;
};
const RadioArea = (props: RadioAreaProps) => {
return (
<label
className={classNames(
"block border border-1 p-4 focus:outline-none focus:ring focus:ring-neutral-500",
props.checked && "border-black",
props.className
)}>
<input
onChange={(e) => props.onChange(e.target.value)}
checked={props.checked}
className="float-right text-neutral-900 focus:ring-neutral-500 ml-3"
name={props.name}
value={props.value}
type="radio"
/>
{props.children}
</label>
);
};
type RadioAreaGroupProps = {
name?: string;
onChange?: (value) => void;
};
const RadioAreaGroup = ({
children,
name,
onChange,
...passThroughProps
}: PropsWithChildren<RadioAreaGroupProps>) => {
const [checkedIdx, setCheckedIdx] = useState<number | null>(null);
const changeHandler = (value: string, idx: number) => {
if (onChange) {
onChange(value);
}
setCheckedIdx(idx);
};
return (
<div {...passThroughProps}>
{(Array.isArray(children) ? children : [children]).map(
(child: React.ReactElement<RadioAreaProps>, idx: number) => {
if (checkedIdx === null && child.props.defaultChecked) {
setCheckedIdx(idx);
}
return (
<Item
{...child.props}
key={idx}
name={name}
checked={idx === checkedIdx}
onChange={(value: string) => changeHandler(value, idx)}>
{child.props.children}
</Item>
);
}
)}
</div>
);
};
const Item = RadioArea;
const Group = RadioAreaGroup;
export { RadioArea, RadioAreaGroup, Item, Group };

View File

@ -0,0 +1,51 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { ChevronDownIcon } from "@heroicons/react/solid";
import React from "react";
import { RadioArea, RadioAreaGroup } from "@components/ui/form/radio-area/RadioAreaGroup";
import classNames from "@lib/classNames";
type OptionProps = React.OptionHTMLAttributes<HTMLOptionElement> & {
description?: string;
};
type RadioAreaSelectProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
options: OptionProps[]; // allow options to be passed programmatically, like options={}
};
export const Select = function RadioAreaSelect(props: RadioAreaSelectProps) {
const {
options,
disabled = !options.length, // if not explicitly disabled and the options length is empty, disable anyway
placeholder = "Select...",
} = props;
const getLabel = (value: string | ReadonlyArray<string> | number) =>
options.find((option: OptionProps) => option.value === value)?.label;
return (
<Collapsible className={classNames("w-full", props.className)}>
<CollapsibleTrigger
type="button"
disabled={disabled}
className={classNames(
"mb-1 cursor-pointer focus:ring-primary-500 text-left border border-1 bg-white p-2 shadow-sm block w-full sm:text-sm border-gray-300 rounded-sm",
disabled && "focus:ring-0 cursor-default bg-gray-200 "
)}>
{getLabel(props.value) ?? placeholder}
<ChevronDownIcon className="float-right h-5 w-5 text-neutral-500" />
</CollapsibleTrigger>
<CollapsibleContent>
<RadioAreaGroup className="space-y-2 text-sm" name={props.name} onChange={props.onChange}>
{options.map((option) => (
<RadioArea {...option} key={option.value} defaultChecked={props.value === option.value}>
<strong className="block mb-1">{option.label}</strong>
<p>{option.description}</p>
</RadioArea>
))}
</RadioAreaGroup>
</CollapsibleContent>
</Collapsible>
);
};
export default Select;

View File

@ -0,0 +1,2 @@
export * from "./RadioAreaGroup";
export * from "./Select";

View File

@ -0,0 +1,229 @@
import React from "react";
function UserCalendarIllustration() {
return (
<svg
className="w-1/2 md:w-32 mx-auto block mb-4"
viewBox="0 0 132 132"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect
x="1.48387"
y="1.48387"
width="129.032"
height="129.032"
rx="64.5161"
fill="white"
stroke="white"
strokeWidth="1.03226"
/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="2" y="2" width="128" height="128">
<rect x="2" y="2" width="128" height="128" rx="64" fill="white" />
</mask>
<g mask="url(#mask0)">
<rect x="56.1936" y="40.1936" width="20.129" height="2.06452" rx="0.516129" fill="#708097" />
<rect x="47.9355" y="44.8387" width="36.6452" height="2.06452" rx="0.516129" fill="#C6CCD5" />
<g filter="url(#filter0_dd)">
<rect width="115.84" height="83.2303" transform="translate(8.07983 53.52)" fill="#F7F8F9" />
<path
d="M15.7699 61.589V63.5013H16.1023V62.1847H16.1201L16.6486 63.4957H16.8969L17.4254 62.1875H17.4432V63.5013H17.7756V61.589H17.3517L16.7839 62.9747H16.7615L16.1938 61.589H15.7699ZM19.993 62.5451C19.993 61.927 19.6158 61.5628 19.1144 61.5628C18.612 61.5628 18.2357 61.927 18.2357 62.5451C18.2357 63.1623 18.612 63.5274 19.1144 63.5274C19.6158 63.5274 19.993 63.1633 19.993 62.5451ZM19.6447 62.5451C19.6447 62.9803 19.4262 63.2165 19.1144 63.2165C18.8034 63.2165 18.584 62.9803 18.584 62.5451C18.584 62.11 18.8034 61.8738 19.1144 61.8738C19.4262 61.8738 19.6447 62.11 19.6447 62.5451Z"
fill="#657388"
/>
<path
d="M32.1112 61.8794H32.7022V63.5013H33.0459V61.8794H33.6369V61.589H32.1112V61.8794ZM35.268 61.589V62.8094C35.268 63.0494 35.1008 63.2212 34.8385 63.2212C34.5751 63.2212 34.4089 63.0494 34.4089 62.8094V61.589H34.0625V62.8383C34.0625 63.2492 34.3706 63.5302 34.8385 63.5302C35.3044 63.5302 35.6144 63.2492 35.6144 62.8383V61.589H35.268Z"
fill="#657388"
/>
<path
d="M48.3554 63.5013H48.6971L49.0809 62.1595H49.0958L49.4786 63.5013H49.8204L50.3601 61.589H49.9875L49.643 62.9952H49.6262L49.2573 61.589H48.9184L48.5505 62.9943H48.5328L48.1882 61.589H47.8157L48.3554 63.5013ZM50.7318 63.5013H51.983V63.2109H51.0782V62.6889H51.9111V62.3985H51.0782V61.8794H51.9755V61.589H50.7318V63.5013Z"
fill="#657388"
/>
<path
d="M64.1925 61.8794H64.7836V63.5013H65.1272V61.8794H65.7183V61.589H64.1925V61.8794ZM66.1439 63.5013H66.4903V62.6889H67.3764V63.5013H67.7237V61.589H67.3764V62.3985H66.4903V61.589H66.1439V63.5013Z"
fill="#657388"
/>
<path
d="M80.545 63.5013H80.8914V62.6889H81.686V62.3985H80.8914V61.8794H81.7701V61.589H80.545V63.5013ZM82.2171 63.5013H82.5636V62.801H82.9165L83.2919 63.5013H83.6784L83.2648 62.7431C83.4898 62.6525 83.6084 62.4602 83.6084 62.2006C83.6084 61.8355 83.3731 61.589 82.9342 61.589H82.2171V63.5013ZM82.5636 62.5134V61.8784H82.881C83.1397 61.8784 83.2555 61.997 83.2555 62.2006C83.2555 62.4041 83.1397 62.5134 82.8829 62.5134H82.5636Z"
fill="#657388"
/>
<path
d="M97.4678 62.1147H97.8011C97.7946 61.7916 97.5191 61.5628 97.112 61.5628C96.7105 61.5628 96.4089 61.7888 96.4098 62.1268C96.4098 62.4013 96.605 62.5591 96.9197 62.6404L97.1372 62.6964C97.3436 62.7487 97.4799 62.8131 97.4808 62.9616C97.4799 63.125 97.3249 63.2342 97.0989 63.2342C96.8823 63.2342 96.7142 63.1371 96.7002 62.9364H96.3594C96.3734 63.3164 96.6563 63.5302 97.1017 63.5302C97.5602 63.5302 97.8263 63.3015 97.8273 62.9644C97.8263 62.6329 97.5527 62.4816 97.2651 62.4135L97.0859 62.3687C96.929 62.3313 96.7591 62.265 96.7609 62.1053C96.7619 61.9615 96.8907 61.856 97.1073 61.856C97.3137 61.856 97.45 61.9522 97.4678 62.1147ZM98.4823 63.5013L98.6401 63.0297H99.3591L99.5178 63.5013H99.8876L99.2134 61.589H98.7858L98.1126 63.5013H98.4823ZM98.7335 62.7515L98.9921 61.9812H99.0071L99.2657 62.7515H98.7335Z"
fill="#657388"
/>
<path
d="M113.487 62.1147H113.82C113.814 61.7916 113.538 61.5628 113.131 61.5628C112.73 61.5628 112.428 61.7888 112.429 62.1268C112.429 62.4013 112.624 62.5591 112.939 62.6404L113.157 62.6964C113.363 62.7487 113.499 62.8131 113.5 62.9616C113.499 63.125 113.344 63.2342 113.118 63.2342C112.902 63.2342 112.734 63.1371 112.72 62.9364H112.379C112.393 63.3164 112.676 63.5302 113.121 63.5302C113.58 63.5302 113.846 63.3015 113.847 62.9644C113.846 62.6329 113.572 62.4816 113.285 62.4135L113.105 62.3687C112.948 62.3313 112.778 62.265 112.78 62.1053C112.781 61.9615 112.91 61.856 113.127 61.856C113.333 61.856 113.469 61.9522 113.487 62.1147ZM115.492 61.589V62.8094C115.492 63.0494 115.325 63.2212 115.063 63.2212C114.8 63.2212 114.633 63.0494 114.633 62.8094V61.589H114.287V62.8383C114.287 63.2492 114.595 63.5302 115.063 63.5302C115.529 63.5302 115.839 63.2492 115.839 62.8383V61.589H115.492Z"
fill="#657388"
/>
<rect x="9.83276" y="70.2902" width="112.334" height="0.516129" fill="white" />
<path
d="M66.3454 77.5155H66.0366L65.3992 77.9388V78.2525L66.0217 77.8392H66.0366V80.0652H66.3454V77.5155Z"
fill="#9BA6B6"
/>
<path
d="M81.2501 80.0652H82.8586V79.7913H81.6734V79.7714L82.2461 79.1588C82.6843 78.6895 82.8138 78.4704 82.8138 78.1877C82.8138 77.7943 82.4951 77.4806 82.0469 77.4806C81.6 77.4806 81.2601 77.7844 81.2601 78.2326H81.5539C81.5539 77.9425 81.7419 77.7495 82.0369 77.7495C82.3133 77.7495 82.525 77.9188 82.525 78.1877C82.525 78.4231 82.3868 78.5973 82.0917 78.9198L81.2501 79.8411V80.0652Z"
fill="#9BA6B6"
/>
<path
d="M98.1047 80.1C98.599 80.1 98.9662 79.79 98.9662 79.373C98.9662 79.0493 98.7745 78.814 98.4533 78.7604V78.7405C98.711 78.6621 98.8716 78.4504 98.8716 78.1628C98.8716 77.8018 98.5865 77.4806 98.1147 77.4806C97.6739 77.4806 97.3079 77.752 97.293 78.1529H97.5918C97.603 77.8989 97.8445 77.7495 98.1097 77.7495C98.3911 77.7495 98.5728 77.9201 98.5728 78.1778C98.5728 78.4467 98.3624 78.621 98.0599 78.621H97.8557V78.8949H98.0599C98.4471 78.8949 98.6625 79.0916 98.6625 79.373C98.6625 79.6431 98.4272 79.8261 98.0997 79.8261C97.8047 79.8261 97.5706 79.6743 97.5519 79.4278H97.2382C97.2569 79.8286 97.6105 80.1 98.1047 80.1Z"
fill="#9BA6B6"
/>
<path
d="M113.222 79.5423H114.423V80.0652H114.716V79.5423H115.065V79.2684H114.716V77.5155H114.343L113.222 79.2883V79.5423ZM114.423 79.2684H113.556V79.2485L114.403 77.9089H114.423V79.2684Z"
fill="#9BA6B6"
/>
<path
d="M17.8508 96.1477C18.3364 96.1477 18.6925 95.7892 18.6925 95.3011C18.6925 94.8069 18.3488 94.4446 17.8807 94.4446C17.7089 94.4446 17.5421 94.5056 17.4375 94.589H17.4226L17.5122 93.837H18.5779V93.5631H17.2533L17.0989 94.8181L17.3877 94.8529C17.4935 94.777 17.6741 94.7222 17.8309 94.7235C18.1559 94.7259 18.3937 94.9724 18.3937 95.3061C18.3937 95.6335 18.1646 95.8738 17.8508 95.8738C17.5894 95.8738 17.3815 95.7057 17.3578 95.4754H17.059C17.0777 95.8639 17.4126 96.1477 17.8508 96.1477Z"
fill="#9BA6B6"
/>
<rect x="26.3188" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M33.9284 96.1478C34.4786 96.1516 34.8484 95.7731 34.8472 95.2689C34.8484 94.7871 34.5048 94.4385 34.0578 94.4385C33.7839 94.4385 33.5424 94.5717 33.4204 94.7908H33.403C33.4042 94.2542 33.6009 93.928 33.9545 93.928C34.1736 93.928 34.3218 94.055 34.3691 94.2505H34.8235C34.7687 93.8384 34.4363 93.5284 33.9545 93.5284C33.342 93.5284 32.9548 94.0388 32.9548 94.9103C32.9535 95.8453 33.4391 96.1453 33.9284 96.1478ZM33.9259 95.7743C33.6532 95.7743 33.454 95.549 33.4528 95.2826C33.4553 95.0149 33.6619 94.7908 33.9321 94.7908C34.2023 94.7908 34.4002 95.0049 34.399 95.2788C34.4002 95.5577 34.196 95.7743 33.9259 95.7743Z"
fill="#3B82F6"
/>
<circle cx="34.1386" cy="99.9637" r="0.657352" fill="#3B82F6" />
<rect x="42.3665" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M49.2434 96.113H49.7228L50.8059 93.9579V93.5632H49.0691V93.9492H50.3278V93.9666L49.2434 96.113Z"
fill="#3B82F6"
/>
<rect x="58.4143" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M66.0013 96.1478C66.5528 96.1478 66.9475 95.8441 66.9487 95.4295C66.9475 95.1108 66.7122 94.8443 66.4159 94.7945V94.7771C66.6736 94.7198 66.8529 94.4883 66.8541 94.2119C66.8529 93.8197 66.4918 93.5284 66.0013 93.5284C65.5071 93.5284 65.146 93.8185 65.1473 94.2119C65.146 94.4883 65.3228 94.7198 65.5855 94.7771V94.7945C65.2842 94.8443 65.0514 95.1108 65.0526 95.4295C65.0514 95.8441 65.4448 96.1478 66.0013 96.1478ZM66.0013 95.7918C65.7125 95.7918 65.5257 95.6324 65.5282 95.3971C65.5257 95.1531 65.7262 94.98 66.0013 94.98C66.2727 94.98 66.4719 95.1543 66.4744 95.3971C66.4719 95.6324 66.2864 95.7918 66.0013 95.7918ZM66.0013 94.6302C65.7648 94.6302 65.5955 94.4771 65.5979 94.2555C65.5955 94.0363 65.7598 93.8894 66.0013 93.8894C66.2391 93.8894 66.4022 94.0363 66.4047 94.2555C66.4022 94.4783 66.2341 94.6302 66.0013 94.6302Z"
fill="#3B82F6"
/>
<rect x="74.4617" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M82.0226 93.5284C81.4698 93.5247 81.1026 93.9044 81.1026 94.4024C81.1038 94.8829 81.4462 95.2303 81.8931 95.2303C82.1683 95.2303 82.4073 95.0971 82.5306 94.878H82.548C82.5468 95.4233 82.3488 95.7482 81.9965 95.7482C81.7761 95.7482 81.628 95.6212 81.5819 95.4183H81.1275C81.1798 95.8403 81.5134 96.1478 81.9965 96.1478C82.6078 96.1478 82.9974 95.6374 82.9962 94.7597C82.995 93.8309 82.5119 93.5309 82.0226 93.5284ZM82.0239 93.9019C82.2965 93.9019 82.497 94.1285 82.497 94.3887C82.4982 94.6526 82.2878 94.8792 82.0189 94.8792C81.7475 94.8792 81.552 94.6651 81.5508 94.3924C81.5508 94.1185 81.7537 93.9019 82.0239 93.9019Z"
fill="#3B82F6"
/>
<rect x="90.5098" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M97.3476 93.5632H96.9081L96.2744 93.9704V94.3937L96.8708 94.0127H96.8857V96.113H97.3476V93.5632ZM98.9375 96.1615C99.5525 96.1628 99.9197 95.6772 99.9197 94.8406C99.9197 94.009 99.55 93.5284 98.9375 93.5284C98.3249 93.5284 97.9564 94.0077 97.9552 94.8406C97.9552 95.676 98.3224 96.1615 98.9375 96.1615ZM98.9375 95.7719C98.62 95.7719 98.4208 95.4531 98.422 94.8406C98.4233 94.233 98.6212 93.9131 98.9375 93.9131C99.2549 93.9131 99.4529 94.233 99.4541 94.8406C99.4541 95.4531 99.2562 95.7719 98.9375 95.7719Z"
fill="#3B82F6"
/>
<path
d="M113.674 93.5632H113.365L112.727 93.9865V94.3003L113.35 93.8869H113.365V96.113H113.674V93.5632ZM115.303 93.5632H114.995L114.357 93.9865V94.3003L114.98 93.8869H114.995V96.113H115.303V93.5632Z"
fill="#9BA6B6"
/>
<rect x="10.2712" y="103.34" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M17.1895 109.611H16.7501L16.1164 110.018V110.441L16.7127 110.06H16.7276V112.161H17.1895V109.611ZM17.8369 112.161H19.5849V111.775H18.4744V111.757L18.9138 111.31C19.4093 110.835 19.5463 110.603 19.5463 110.316C19.5463 109.889 19.1989 109.576 18.686 109.576C18.1805 109.576 17.822 109.89 17.822 110.374H18.2615C18.2615 110.114 18.4258 109.951 18.6798 109.951C18.9226 109.951 19.1031 110.099 19.1031 110.339C19.1031 110.552 18.9736 110.704 18.7221 110.959L17.8369 111.827V112.161Z"
fill="#3B82F6"
/>
<rect x="26.3188" y="103.34" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M33.1843 109.611H32.7448L32.1111 110.018V110.441L32.7075 110.06H32.7224V112.161H33.1843V109.611ZM34.7468 112.196C35.2921 112.196 35.6892 111.883 35.688 111.452C35.6892 111.134 35.49 110.905 35.1327 110.853V110.834C35.4091 110.774 35.5946 110.568 35.5934 110.282C35.5946 109.894 35.2634 109.576 34.7542 109.576C34.2587 109.576 33.8753 109.871 33.8653 110.298H34.3098C34.3173 110.084 34.5165 109.951 34.7518 109.951C34.9895 109.951 35.1477 110.095 35.1464 110.309C35.1477 110.532 34.9634 110.68 34.6995 110.68H34.4741V111.036H34.6995C35.0219 111.036 35.2136 111.198 35.2124 111.429C35.2136 111.654 35.0182 111.808 34.7455 111.808C34.4891 111.808 34.2911 111.675 34.2799 111.467H33.8118C33.8242 111.898 34.2089 112.196 34.7468 112.196Z"
fill="#3B82F6"
/>
<rect x="42.3665" y="103.34" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M49.2063 109.611H48.7668L48.1331 110.018V110.441L48.7294 110.06H48.7444V112.161H49.2063V109.611ZM49.8076 111.688H51.0239V112.161H51.4647V111.688H51.7908V111.308H51.4647V109.611H50.8895L49.8076 111.32V111.688ZM51.0289 111.308H50.2807V111.288L51.009 110.134H51.0289V111.308Z"
fill="#3B82F6"
/>
<path
d="M65.2789 109.611H64.9702L64.3327 110.034V110.348L64.9552 109.935H64.9702V112.161H65.2789V109.611ZM66.809 112.196C67.2945 112.196 67.6506 111.837 67.6506 111.349C67.6506 110.855 67.307 110.492 66.8389 110.492C66.6671 110.492 66.5002 110.553 66.3957 110.637H66.3807L66.4704 109.885H67.5361V109.611H66.2114L66.057 110.866L66.3459 110.901C66.4517 110.825 66.6322 110.77 66.7891 110.771C67.114 110.774 67.3518 111.02 67.3518 111.354C67.3518 111.681 67.1227 111.922 66.809 111.922C66.5476 111.922 66.3396 111.754 66.316 111.523H66.0172C66.0359 111.912 66.3708 112.196 66.809 112.196Z"
fill="#9BA6B6"
/>
<rect x="74.4617" y="103.34" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M81.3323 109.611H80.8928L80.2591 110.018V110.441L80.8554 110.06H80.8704V112.161H81.3323V109.611ZM82.9134 112.196C83.4637 112.199 83.8335 111.821 83.8322 111.317C83.8335 110.835 83.4898 110.486 83.0429 110.486C82.769 110.486 82.5275 110.619 82.4055 110.839H82.388C82.3893 110.302 82.586 109.976 82.9396 109.976C83.1587 109.976 83.3068 110.103 83.3541 110.298H83.8086C83.7538 109.886 83.4214 109.576 82.9396 109.576C82.327 109.576 81.9398 110.087 81.9398 110.958C81.9386 111.893 82.4241 112.193 82.9134 112.196ZM82.9109 111.822C82.6383 111.822 82.4391 111.597 82.4378 111.33C82.4403 111.063 82.647 110.839 82.9171 110.839C83.1873 110.839 83.3853 111.053 83.384 111.327C83.3853 111.605 83.1811 111.822 82.9109 111.822Z"
fill="#3B82F6"
/>
<path
d="M97.4394 109.611H97.1307L96.4932 110.034V110.348L97.1157 109.935H97.1307V112.161H97.4394V109.611ZM98.2524 112.161H98.5761L99.7115 109.9V109.611H98.0781V109.885H99.3928V109.905L98.2524 112.161Z"
fill="#9BA6B6"
/>
<path
d="M113.408 109.611H113.1L112.462 110.034V110.348L113.085 109.935H113.1V112.161H113.408V109.611ZM114.958 112.196C115.467 112.196 115.822 111.898 115.825 111.483C115.822 111.161 115.607 110.887 115.332 110.836V110.821C115.571 110.759 115.728 110.525 115.73 110.253C115.728 109.865 115.402 109.576 114.958 109.576C114.51 109.576 114.184 109.865 114.186 110.253C114.184 110.525 114.341 110.759 114.585 110.821V110.836C114.305 110.887 114.089 111.161 114.092 111.483C114.089 111.898 114.444 112.196 114.958 112.196ZM114.958 111.922C114.608 111.922 114.393 111.742 114.396 111.469C114.393 111.181 114.631 110.976 114.958 110.976C115.281 110.976 115.519 111.181 115.521 111.469C115.519 111.742 115.303 111.922 114.958 111.922ZM114.958 110.712C114.679 110.712 114.483 110.537 114.485 110.273C114.483 110.014 114.672 109.845 114.958 109.845C115.24 109.845 115.429 110.014 115.431 110.273C115.429 110.537 115.232 110.712 114.958 110.712Z"
fill="#9BA6B6"
/>
<rect x="10.2712" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M17.1416 125.659H16.7021L16.0684 126.066V126.489L16.6648 126.108H16.6797V128.208H17.1416V125.659ZM18.6742 125.624C18.1214 125.62 17.7541 126 17.7541 126.498C17.7554 126.978 18.0978 127.326 18.5447 127.326C18.8198 127.326 19.0589 127.192 19.1821 126.973H19.1996C19.1983 127.519 19.0004 127.844 18.648 127.844C18.4277 127.844 18.2795 127.717 18.2335 127.514H17.779C17.8313 127.936 18.165 128.243 18.648 128.243C19.2593 128.243 19.649 127.733 19.6478 126.855C19.6465 125.926 19.1635 125.626 18.6742 125.624ZM18.6754 125.997C18.9481 125.997 19.1485 126.224 19.1485 126.484C19.1498 126.748 18.9394 126.975 18.6704 126.975C18.399 126.975 18.2036 126.76 18.2023 126.488C18.2023 126.214 18.4053 125.997 18.6754 125.997Z"
fill="#3B82F6"
/>
<rect x="26.3188" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M31.8734 128.208H33.6213V127.822H32.5108V127.805L32.9503 127.358C33.4458 126.882 33.5827 126.651 33.5827 126.363C33.5827 125.936 33.2354 125.624 32.7224 125.624C32.217 125.624 31.8584 125.937 31.8584 126.422H32.2979C32.2979 126.162 32.4622 125.998 32.7162 125.998C32.959 125.998 33.1395 126.147 33.1395 126.387C33.1395 126.6 33.01 126.752 32.7585 127.007L31.8734 127.875V128.208ZM34.9933 128.257C35.6083 128.258 35.9756 127.773 35.9756 126.936C35.9756 126.104 35.6058 125.624 34.9933 125.624C34.3808 125.624 34.0122 126.103 34.011 126.936C34.011 127.771 34.3783 128.257 34.9933 128.257ZM34.9933 127.867C34.6758 127.867 34.4766 127.548 34.4779 126.936C34.4791 126.328 34.6771 126.008 34.9933 126.008C35.3108 126.008 35.5087 126.328 35.51 126.936C35.51 127.548 35.312 127.867 34.9933 127.867Z"
fill="#3B82F6"
/>
<rect x="42.3665" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M48.2479 128.208H49.9959V127.822H48.8854V127.805L49.3248 127.358C49.8203 126.882 49.9573 126.651 49.9573 126.363C49.9573 125.936 49.6099 125.624 49.097 125.624C48.5915 125.624 48.233 125.937 48.233 126.422H48.6725C48.6725 126.162 48.8368 125.998 49.0908 125.998C49.3335 125.998 49.5141 126.147 49.5141 126.387C49.5141 126.6 49.3846 126.752 49.1331 127.007L48.2479 127.875V128.208ZM51.4625 125.659H51.023L50.3893 126.066V126.489L50.9856 126.108H51.0006V128.208H51.4625V125.659Z"
fill="#3B82F6"
/>
<rect x="58.4143" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M64.049 128.208H65.797V127.822H64.6865V127.805L65.1259 127.358C65.6214 126.882 65.7584 126.651 65.7584 126.363C65.7584 125.936 65.411 125.624 64.8981 125.624C64.3926 125.624 64.0341 125.937 64.0341 126.422H64.4736C64.4736 126.162 64.6379 125.998 64.8919 125.998C65.1347 125.998 65.3152 126.147 65.3152 126.387C65.3152 126.6 65.1857 126.752 64.9342 127.007L64.049 127.875V128.208ZM66.2265 128.208H67.9745V127.822H66.8639V127.805L67.3034 127.358C67.7989 126.882 67.9359 126.651 67.9359 126.363C67.9359 125.936 67.5885 125.624 67.0756 125.624C66.5701 125.624 66.2116 125.937 66.2116 126.422H66.651C66.651 126.162 66.8154 125.998 67.0694 125.998C67.3121 125.998 67.4927 126.147 67.4927 126.387C67.4927 126.6 67.3632 126.752 67.1117 127.007L66.2265 127.875V128.208Z"
fill="#3B82F6"
/>
<rect x="74.4617" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
<path
d="M80.0436 128.208H81.7915V127.822H80.681V127.805L81.1205 127.358C81.616 126.882 81.7529 126.651 81.7529 126.363C81.7529 125.936 81.4056 125.624 80.8926 125.624C80.3872 125.624 80.0286 125.937 80.0286 126.422H80.4681C80.4681 126.162 80.6324 125.998 80.8864 125.998C81.1292 125.998 81.3097 126.147 81.3097 126.387C81.3097 126.6 81.1802 126.752 80.9287 127.007L80.0436 127.875V128.208ZM83.1361 128.243C83.6814 128.243 84.0786 127.931 84.0773 127.5C84.0786 127.181 83.8794 126.952 83.5221 126.901V126.881C83.7984 126.821 83.9839 126.616 83.9827 126.33C83.9839 125.941 83.6528 125.624 83.1436 125.624C82.6481 125.624 82.2646 125.919 82.2547 126.346H82.6991C82.7066 126.132 82.9058 125.998 83.1411 125.998C83.3789 125.998 83.537 126.143 83.5357 126.357C83.537 126.58 83.3527 126.728 83.0888 126.728H82.8635V127.084H83.0888C83.4112 127.084 83.603 127.246 83.6017 127.476C83.603 127.702 83.4075 127.856 83.1349 127.856C82.8784 127.856 82.6804 127.723 82.6692 127.515H82.2011C82.2136 127.946 82.5983 128.243 83.1361 128.243Z"
fill="#3B82F6"
/>
<path
d="M96.2007 128.208H97.8092V127.934H96.624V127.914L97.1967 127.302C97.6349 126.833 97.7644 126.613 97.7644 126.331C97.7644 125.937 97.4456 125.624 96.9975 125.624C96.5505 125.624 96.2106 125.928 96.2106 126.376H96.5044C96.5044 126.086 96.6924 125.893 96.9875 125.893C97.2639 125.893 97.4755 126.062 97.4755 126.331C97.4755 126.566 97.3373 126.74 97.0423 127.063L96.2007 127.984V128.208ZM98.2088 127.685H99.409V128.208H99.7028V127.685H100.051V127.412H99.7028V125.659H99.3293L98.2088 127.431V127.685ZM99.409 127.412H98.5425V127.392L99.3891 126.052H99.409V127.412Z"
fill="#9BA6B6"
/>
<path
d="M112.279 128.208H113.888V127.934H112.702V127.914L113.275 127.302C113.713 126.833 113.843 126.613 113.843 126.331C113.843 125.937 113.524 125.624 113.076 125.624C112.629 125.624 112.289 125.928 112.289 126.376H112.583C112.583 126.086 112.771 125.893 113.066 125.893C113.342 125.893 113.554 126.062 113.554 126.331C113.554 126.566 113.416 126.74 113.121 127.063L112.279 127.984V128.208ZM115.199 128.243C115.684 128.243 116.04 127.885 116.04 127.397C116.04 126.902 115.697 126.54 115.228 126.54C115.057 126.54 114.89 126.601 114.785 126.684H114.77L114.86 125.932H115.926V125.659H114.601L114.447 126.914L114.735 126.948C114.841 126.872 115.022 126.818 115.179 126.819C115.504 126.821 115.741 127.068 115.741 127.402C115.741 127.729 115.512 127.969 115.199 127.969C114.937 127.969 114.729 127.801 114.706 127.571H114.407C114.425 127.959 114.76 128.243 115.199 128.243Z"
fill="#9BA6B6"
/>
</g>
</g>
<g clipPath="url(#clip0)">
<path
d="M54.1289 22.6026C54.1289 16.0129 59.4709 10.671 66.0605 10.671C72.6502 10.671 77.9922 16.0129 77.9922 22.6026C77.9922 29.1923 72.6502 34.5342 66.0605 34.5342C59.4709 34.5342 54.1289 29.1923 54.1289 22.6026Z"
fill="#F4F5F6"
/>
<path
d="M77.7885 31.2806C77.9226 31.4509 77.9922 31.6625 77.9922 31.8793V33.5352C77.9922 34.0875 77.5445 34.5352 76.9922 34.5352H55.1289C54.5766 34.5352 54.1289 34.0875 54.1289 33.5352V31.889C54.1289 31.673 54.198 31.4621 54.3312 31.2921C55.6901 29.5582 57.4178 28.1462 59.3905 27.1595C61.4626 26.1232 63.7478 25.5845 66.0645 25.5865C70.8206 25.5865 75.0583 27.8133 77.7885 31.2806ZM70.0397 19.6197C70.0397 20.6745 69.6207 21.6861 68.8748 22.432C68.129 23.1779 67.1173 23.5969 66.0625 23.5969C65.0077 23.5969 63.9961 23.1779 63.2502 22.432C62.5043 21.6861 62.0853 20.6745 62.0853 19.6197C62.0853 18.5648 62.5043 17.5532 63.2502 16.8074C63.9961 16.0615 65.0077 15.6425 66.0625 15.6425C67.1173 15.6425 68.129 16.0615 68.8748 16.8074C69.6207 17.5532 70.0397 18.5648 70.0397 19.6197Z"
fill="#708097"
/>
</g>
<defs>
<filter
id="filter0_dd"
x="5.49919"
y="53.0038"
width="121.001"
height="88.3916"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology radius="0.516129" operator="erode" in="SourceAlpha" result="effect1_dropShadow" />
<feOffset dy="1.03226" />
<feGaussianBlur stdDeviation="1.03226" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology radius="0.516129" operator="erode" in="SourceAlpha" result="effect2_dropShadow" />
<feOffset dy="2.06452" />
<feGaussianBlur stdDeviation="1.54839" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" />
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow" />
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape" />
</filter>
<clipPath id="clip0">
<path
d="M54.1289 22.6026C54.1289 16.0129 59.4709 10.671 66.0605 10.671C72.6502 10.671 77.9922 16.0129 77.9922 22.6026C77.9922 29.1923 72.6502 34.5342 66.0605 34.5342C59.4709 34.5342 54.1289 29.1923 54.1289 22.6026Z"
fill="white"
/>
</clipPath>
</defs>
</svg>
);
}
export default UserCalendarIllustration;

View File

@ -0,0 +1,24 @@
describe("cancel", () => {
describe("Admin user can cancel events", () => {
before(() => {
cy.visit("/bookings");
cy.login("pro@example.com", "pro");
});
it("can cancel bookings", () => {
cy.visit("/bookings");
cy.get("[data-testid=bookings]").children().should("have.length.at.least", 1);
cy.get("[data-testid=cancel]").click();
cy.location("pathname").should("contain", "/cancel/");
cy.get("[data-testid=cancel]").click();
cy.location("pathname").should("contain", "/cancel/success");
cy.get("[data-testid=back-to-bookings]").click();
cy.location("pathname").should("eq", "/bookings");
cy.get("[data-testid=bookings]").children().should("have.length", 0);
});
});
});

View File

@ -178,13 +178,23 @@ export default class EventOrganizerMail extends EventMail {
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Calendso <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
to: toAddresses.join(","),
subject: this.getSubject(),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),

138
lib/hooks/useSlots.ts Normal file
View File

@ -0,0 +1,138 @@
import { useState, useEffect } from "react";
import getSlots from "@lib/slots";
import { User, SchedulingType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
dayjs.extend(isBetween);
dayjs.extend(utc);
type Slot = {
time: Dayjs;
users?: string[];
};
type UseSlotsProps = {
eventLength: number;
minimumBookingNotice?: number;
date: Dayjs;
workingHours: [];
users: User[];
schedulingType: SchedulingType;
};
export const useSlots = (props: UseSlotsProps) => {
const { eventLength, minimumBookingNotice = 0, date, users } = props;
const [slots, setSlots] = useState<Slot[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setSlots([]);
setLoading(true);
setError(null);
const dateFrom = encodeURIComponent(date.startOf("day").format());
const dateTo = encodeURIComponent(date.endOf("day").format());
Promise.all(
users.map((user: User) =>
fetch(`/api/availability/${user.username}?dateFrom=${dateFrom}&dateTo=${dateTo}`)
.then(handleAvailableSlots)
.catch((e) => {
console.error(e);
setError(e);
})
)
).then((results) => {
let loadedSlots: Slot[] = results[0];
if (results.length === 1) {
setSlots(loadedSlots);
setLoading(false);
return;
}
let poolingMethod;
switch (props.schedulingType) {
// intersect by time, does not take into account eventLength (yet)
case SchedulingType.COLLECTIVE:
poolingMethod = (slots, compareWith) =>
slots.filter((slot) => compareWith.some((compare) => compare.time.isSame(slot.time)));
break;
case SchedulingType.ROUND_ROBIN:
// TODO: Create a Reservation (lock this slot for X minutes)
// this will make the following code redundant
poolingMethod = (slots, compareWith) => {
compareWith.forEach((compare) => {
const match = slots.findIndex((slot) => slot.time.isSame(compare.time));
if (match !== -1) {
slots[match].users.push(compare.users[0]);
} else {
slots.push(compare);
}
});
return slots;
};
break;
}
for (let i = 1; i < results.length; i++) {
loadedSlots = poolingMethod(loadedSlots, results[i]);
}
setSlots(loadedSlots);
setLoading(false);
});
}, [date]);
const handleAvailableSlots = async (res) => {
const responseBody = await res.json();
responseBody.workingHours.days = responseBody.workingHours.daysOfWeek;
const times = getSlots({
frequency: eventLength,
inviteeDate: date,
workingHours: [responseBody.workingHours],
minimumBookingNotice,
organizerTimeZone: responseBody.workingHours.timeZone,
});
// Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) {
responseBody.busy.every((busyTime): boolean => {
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Check if start times are the same
if (times[i].isBetween(startTime, endTime, null, "[)")) {
times.splice(i, 1);
}
// Check if slot end time is between start and end time
else if (times[i].add(eventLength, "minutes").isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if startTime is between slot
else if (startTime.isBetween(times[i], times[i].add(eventLength, "minutes"))) {
times.splice(i, 1);
} else {
return true;
}
return false;
});
}
// temporary
const user = res.url.substring(res.url.lastIndexOf("/") + 1, res.url.indexOf("?"));
return times.map((time) => ({
time,
users: [user],
}));
};
return {
slots,
loading,
error,
};
};
export default useSlots;

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
export default function Theme(theme?: string) {
// makes sure the ui doesn't flash
export default function useTheme(theme?: string) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches) {

View File

@ -1,3 +1,5 @@
import { SchedulingType } from "@prisma/client";
export type OpeningHours = {
days: number[];
startTime: number;
@ -33,6 +35,7 @@ export type CreateEventType = {
slug: string;
description: string;
length: number;
schedulingType?: SchedulingType;
};
export type EventTypeInput = AdvancedOptions & {

View File

@ -28,7 +28,8 @@
"@prisma/client": "^2.30.2",
"@radix-ui/react-avatar": "^0.0.15",
"@radix-ui/react-collapsible": "^0.0.17",
"@radix-ui/react-dialog": "^0.1.0",
"@radix-ui/react-dialog": "^0.0.20",
"@radix-ui/react-dropdown-menu": "^0.0.23",
"@radix-ui/react-slider": "^0.0.17",
"@radix-ui/react-switch": "^0.0.15",
"@radix-ui/react-tooltip": "^0.1.0",

View File

@ -1,16 +1,16 @@
import Avatar from "@components/Avatar";
import Avatar from "@components/ui/Avatar";
import { HeadSeo } from "@components/seo/head-seo";
import Theme from "@components/Theme";
import useTheme from "@lib/hooks/useTheme";
import { ArrowRightIcon } from "@heroicons/react/outline";
import { ClockIcon, InformationCircleIcon, UserIcon } from "@heroicons/react/solid";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import React from "react";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
const { isReady } = Theme(props.user.theme);
const { isReady } = useTheme(props.user.theme);
return (
<>
@ -43,29 +43,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
<Link href={`/${props.user.username}/${type.slug}`}>
<a className="block px-6 py-4">
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<div className="mt-2 flex space-x-4">
<div className="flex text-sm text-neutral-500">
<ClockIcon
className="flex-shrink-0 mt-0.5 mr-1.5 h-4 w-4 text-neutral-400 dark:text-white"
aria-hidden="true"
/>
<p className="dark:text-white">{type.length}m</p>
</div>
<div className="flex text-sm min-w-16 text-neutral-500">
<UserIcon
className="flex-shrink-0 mt-0.5 mr-1.5 h-4 w-4 text-neutral-400 dark:text-white"
aria-hidden="true"
/>
<p className="dark:text-white">1-on-1</p>
</div>
<div className="flex text-sm text-neutral-500">
<InformationCircleIcon
className="flex-shrink-0 mt-0.5 mr-1.5 h-4 w-4 text-neutral-400 dark:text-white"
aria-hidden="true"
/>
<p className="dark:text-white">{type.description}</p>
</div>
</div>
<EventTypeDescription eventType={type} />
</a>
</Link>
</div>
@ -112,7 +90,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventTypesWithHidden = await prisma.eventType.findMany({
where: {
userId: user.id,
OR: [
{
userId: user.id,
},
{
users: {
some: {
id: user.id,
},
},
},
],
},
select: {
id: true,
@ -127,8 +116,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventTypes = eventTypesWithHidden.filter((evt) => !evt.hidden);
return {
props: {
user,
eventTypes,
user,
},
};
};

View File

@ -1,210 +1,12 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import Avatar from "@components/Avatar";
import AvailableTimes from "@components/booking/AvailableTimes";
import DatePicker from "@components/booking/DatePicker";
import TimeOptions from "@components/booking/TimeOptions";
import { HeadSeo } from "@components/seo/head-seo";
import Theme from "@components/Theme";
import PoweredByCalendso from "@components/ui/PoweredByCalendso";
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
import { User } from "@prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import prisma from "@lib/prisma";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { Availability } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import dayjs, { Dayjs } from "dayjs";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
// Get router variables
const router = useRouter();
const { rescheduleUid } = router.query;
const { isReady } = Theme(props.user.theme);
const [selectedDate, setSelectedDate] = useState<Dayjs>(() => {
return props.date && dayjs(props.date).isValid() ? dayjs(props.date) : null;
});
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState("h:mma");
const telemetry = useTelemetry();
useEffect(() => {
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
}, [telemetry]);
const changeDate = (date: Dayjs) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
setSelectedDate(date);
};
useEffect(() => {
if (!selectedDate) {
return;
}
const formattedDate = selectedDate.utc().format("YYYY-MM-DD");
router.replace(
{
query: Object.assign(
{},
{
...router.query,
},
{
date: formattedDate,
}
),
},
undefined,
{
shallow: true,
}
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDate]);
const handleSelectTimeZone = (selectedTimeZone: string): void => {
if (selectedDate) {
setSelectedDate(selectedDate.tz(selectedTimeZone));
}
setIsTimeOptionsOpen(false);
};
const handleToggle24hClock = (is24hClock: boolean) => {
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
};
return (
<>
<HeadSeo
title={`${rescheduleUid ? "Reschedule" : ""} ${props.eventType.title} | ${
props.user.name || props.user.username
}`}
description={`${rescheduleUid ? "Reschedule" : ""} ${props.eventType.title}`}
name={props.user.name || props.user.username}
avatar={props.user.avatar}
/>
{isReady && (
<div>
<main
className={
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
}>
<div className="bg-white border-gray-200 rounded-sm sm:dark:border-gray-600 dark:bg-gray-900 md:border">
{/* mobile: details */}
<div className="block p-4 sm:p-8 md:hidden">
<div className="flex items-center">
<Avatar
imageSrc={props.user.avatar}
displayName={props.user.name}
className="inline-block rounded-full h-9 w-9"
/>
<div className="ml-3">
<p className="text-sm font-medium text-black dark:text-gray-300">{props.user.name}</p>
<div className="flex gap-2 text-xs font-medium text-gray-600">
{props.eventType.title}
<div>
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</div>
</div>
</div>
</div>
<p className="mt-3 text-gray-600 dark:text-gray-200">{props.eventType.description}</p>
</div>
<div className="px-4 sm:flex sm:py-5 sm:p-4">
<div
className={
"hidden md:block pr-8 sm:border-r sm:dark:border-gray-800 " +
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}>
<Avatar
imageSrc={props.user.avatar}
displayName={props.user.name}
className="w-16 h-16 mb-4 rounded-full"
/>
<h2 className="font-medium text-gray-500 dark:text-gray-300">{props.user.name}</h2>
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
{props.eventType.title}
</h1>
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
<TimezoneDropdown />
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{props.eventType.description}</p>
</div>
<DatePicker
date={selectedDate}
periodType={props.eventType?.periodType}
periodStartDate={props.eventType?.periodStartDate}
periodEndDate={props.eventType?.periodEndDate}
periodDays={props.eventType?.periodDays}
periodCountCalendarDays={props.eventType?.periodCountCalendarDays}
weekStart={props.user.weekStart}
onDatePicked={changeDate}
workingHours={props.workingHours}
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
inviteeTimeZone={timeZone()}
eventLength={props.eventType.length}
minimumBookingNotice={props.eventType.minimumBookingNotice}
/>
<div className="block mt-4 ml-1 sm:hidden">
<TimezoneDropdown />
</div>
{selectedDate && (
<AvailableTimes
workingHours={props.workingHours}
timeFormat={timeFormat}
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
minimumBookingNotice={props.eventType.minimumBookingNotice}
eventTypeId={props.eventType.id}
eventLength={props.eventType.length}
date={selectedDate}
user={props.user}
/>
)}
</div>
</div>
{!isBrandingHidden(props.user) && <PoweredByCalendso />}
</main>
</div>
)}
</>
);
function TimezoneDropdown() {
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="px-2 py-1 mb-1 -ml-2 text-left text-gray-500 min-w-32">
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{timeZone()}
{isTimeOptionsOpen ? (
<ChevronUpIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
) : (
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
)}
</Collapsible.Trigger>
<Collapsible.Content>
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
</Collapsible.Content>
</Collapsible.Root>
);
}
return <AvailabilityPage {...props} />;
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
@ -218,7 +20,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
throw new Error(`File is not named [type]/[user]`);
}
const user = await prisma.user.findFirst({
const user: User = await prisma.user.findUnique({
where: {
username: userParam.toLowerCase(),
},
@ -237,6 +39,32 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: true,
theme: true,
plan: true,
eventTypes: {
where: {
AND: [
{
slug: typeParam,
},
{
teamId: null,
},
],
},
select: {
id: true,
title: true,
availability: true,
description: true,
length: true,
users: {
select: {
avatar: true,
name: true,
username: true,
},
},
},
},
},
});
@ -245,41 +73,65 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
notFound: true,
};
}
const eventType = await prisma.eventType.findUnique({
where: {
userId_slug: {
userId: user.id,
slug: typeParam,
},
},
select: {
id: true,
title: true,
description: true,
length: true,
availability: true,
timeZone: true,
periodType: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
minimumBookingNotice: true,
hidden: true,
},
});
if (!eventType) {
return {
notFound: true,
};
if (user.eventTypes.length !== 1) {
const eventTypeBackwardsCompat = await prisma.eventType.findFirst({
where: {
AND: [
{
userId: user.id,
},
{
slug: typeParam,
},
],
},
select: {
id: true,
title: true,
availability: true,
description: true,
length: true,
users: {
select: {
avatar: true,
name: true,
username: true,
},
},
},
});
if (!eventTypeBackwardsCompat) {
return {
notFound: true,
};
}
eventTypeBackwardsCompat.users.push({
avatar: user.avatar,
name: user.name,
username: user.username,
});
user.eventTypes.push(eventTypeBackwardsCompat);
}
const eventType = user.eventTypes[0];
// check this is the first event
if (user.plan === "FREE") {
const firstEventType = await prisma.eventType.findFirst({
where: {
userId: user.id,
OR: [
{
userId: user.id,
},
{
users: {
some: {
id: user.id,
},
},
},
],
},
select: {
id: true,
@ -297,8 +149,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
: null;
const workingHours =
getWorkingHours(eventType) ||
getWorkingHours(user) ||
getWorkingHours(eventType.availability) ||
getWorkingHours(user.availability) ||
[
{
days: [0, 1, 2, 3, 4, 5, 6],
@ -316,7 +168,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
user,
profile: {
name: user.name,
image: user.avatar,
slug: user.username,
theme: user.theme,
},
date: dateParam,
eventType: eventTypeObject,
workingHours,

View File

@ -1,431 +1,30 @@
import { HeadSeo } from "@components/seo/head-seo";
import Link from "next/link";
import { useRouter } from "next/router";
import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid";
import prisma, { whereAndSelect } from "@lib/prisma";
import { EventTypeCustomInputType } from "@prisma/client";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { useEffect, useState } from "react";
import prisma from "@lib/prisma";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import PhoneInput from "@components/ui/form/PhoneInput";
import { LocationType } from "@lib/location";
import Avatar from "@components/Avatar";
import { Button } from "@components/ui/Button";
import Theme from "@components/Theme";
import { ReactMultiEmail } from "react-multi-email";
import BookingPage from "@components/booking/pages/BookingPage";
dayjs.extend(utc);
dayjs.extend(timezone);
export default function Book(props: any): JSX.Element {
const router = useRouter();
const { date, user, rescheduleUid } = router.query;
const [is24h, setIs24h] = useState(false);
const [preferredTimeZone, setPreferredTimeZone] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [guestToggle, setGuestToggle] = useState(false);
const [guestEmails, setGuestEmails] = useState([]);
const locations = props.eventType.locations || [];
const [selectedLocation, setSelectedLocation] = useState<LocationType>(
locations.length === 1 ? locations[0].type : ""
);
const { isReady } = Theme(props.user.theme);
const telemetry = useTelemetry();
useEffect(() => {
setPreferredTimeZone(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess());
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
});
function toggleGuestEmailInput() {
setGuestToggle(!guestToggle);
}
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
// TODO: Move to translations
const locationLabels = {
[LocationType.InPerson]: "In-person meeting",
[LocationType.Phone]: "Phone call",
[LocationType.GoogleMeet]: "Google Meet",
[LocationType.Zoom]: "Zoom Video",
};
const bookingHandler = (event) => {
const book = async () => {
setLoading(true);
setError(false);
let notes = "";
if (props.eventType.customInputs) {
notes = props.eventType.customInputs
.map((input) => {
const data = event.target["custom_" + input.id];
if (data) {
if (input.type === EventTypeCustomInputType.BOOL) {
return input.label + "\n" + (data.checked ? "Yes" : "No");
} else {
return input.label + "\n" + data.value;
}
}
})
.join("\n\n");
}
if (!!notes && !!event.target.notes.value) {
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
const payload = {
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
name: event.target.name.value,
email: event.target.email.value,
notes: notes,
guests: guestEmails,
timeZone: preferredTimeZone,
eventTypeId: props.eventType.id,
rescheduleUid: rescheduleUid,
};
if (selectedLocation) {
switch (selectedLocation) {
case LocationType.Phone:
payload["location"] = event.target.phone.value;
break;
case LocationType.InPerson:
payload["location"] = locationInfo(selectedLocation).address;
break;
// Catches all other location types, such as Google Meet, Zoom etc.
default:
payload["location"] = selectedLocation;
}
}
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
/*const res = await */ fetch("/api/book/" + user, {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
// TODO When the endpoint is fixed, change this to await the result again
//if (res.ok) {
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${
props.user.username
}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
if (payload["location"]) {
if (payload["location"].includes("integration")) {
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
} else {
successUrl += "&location=" + encodeURIComponent(payload["location"]);
}
}
await router.push(successUrl);
/*} else {
setLoading(false);
setError(true);
}*/
};
event.preventDefault();
book();
};
return (
isReady && (
<div>
<HeadSeo
title={`${rescheduleUid ? "Reschedule" : "Confirm"} your ${props.eventType.title} with ${
props.user.name || props.user.username
}`}
description={`${rescheduleUid ? "Reschedule" : "Confirm"} your ${props.eventType.title} with ${
props.user.name || props.user.username
}`}
/>
<main className="max-w-3xl mx-auto my-0 sm:my-24">
<div className="dark:bg-neutral-900 bg-white overflow-hidden border border-gray-200 dark:border-0 sm:rounded-sm">
<div className="sm:flex px-4 py-5 sm:p-4">
<div className="sm:w-1/2 sm:border-r sm:dark:border-black">
<Avatar
displayName={props.user.name}
imageSrc={props.user.avatar}
className="w-16 h-16 rounded-full mb-4"
/>
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.user.name}</h2>
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
{props.eventType.title}
</h1>
<p className="text-gray-500 mb-2">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
{selectedLocation === LocationType.InPerson && (
<p className="text-gray-500 mb-2 break-words">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{locationInfo(selectedLocation).address}
</p>
)}
<p className="text-green-500 mb-4">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{preferredTimeZone &&
dayjs(date)
.tz(preferredTimeZone)
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
</p>
<p className="dark:text-white text-gray-600 mb-8">{props.eventType.description}</p>
</div>
<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 dark:text-white text-gray-700">
Your name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="John Doe"
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
/>
</div>
</div>
<div className="mb-4">
<label
htmlFor="email"
className="block text-sm font-medium dark:text-white text-gray-700">
Email address
</label>
<div className="mt-1">
<input
type="email"
name="email"
id="email"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="you@example.com"
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
/>
</div>
</div>
{locations.length > 1 && (
<div className="mb-4">
<span className="block text-sm font-medium dark:text-white text-gray-700">
Location
</span>
{locations.map((location) => (
<label key={location.type} className="block">
<input
type="radio"
required
onChange={(e) => setSelectedLocation(e.target.value)}
className="location focus:ring-black h-4 w-4 text-black border-gray-300 mr-2"
name="location"
value={location.type}
checked={selectedLocation === location.type}
/>
<span className="text-sm ml-2 dark:text-gray-500">
{locationLabels[location.type]}
</span>
</label>
))}
</div>
)}
{selectedLocation === LocationType.Phone && (
<div className="mb-4">
<label
htmlFor="phone"
className="block text-sm font-medium dark:text-white text-gray-700">
Phone number
</label>
<div className="mt-1">
<PhoneInput name="phone" placeholder="Enter phone number" id="phone" required />
</div>
</div>
)}
{props.eventType.customInputs &&
props.eventType.customInputs
.sort((a, b) => a.id - b.id)
.map((input) => (
<div className="mb-4" key={"input-" + input.label.toLowerCase}>
{input.type !== EventTypeCustomInputType.BOOL && (
<label
htmlFor={"custom_" + input.id}
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
rows={3}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
name={"custom_" + input.id}
id={"custom_" + input.id}
className="focus:ring-black h-4 w-4 text-black border-gray-300 rounded mr-2"
placeholder=""
/>
<label
htmlFor={"custom_" + input.id}
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
{input.label}
</label>
</div>
)}
</div>
))}
<div className="mb-4">
{!guestToggle && (
<label
onClick={toggleGuestEmailInput}
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-blue-500 mb-1 hover:cursor-pointer">
+ Additional Guests
</label>
)}
{guestToggle && (
<div>
<label
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
Guests
</label>
<ReactMultiEmail
placeholder="guest@example.com"
emails={guestEmails}
onChange={(_emails: string[]) => {
setGuestEmails(_emails);
}}
getLabel={(email: string, index: number, removeEmail: (index: number) => void) => {
return (
<div data-tag key={index}>
{email}
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
</div>
);
}}
/>
</div>
)}
</div>
<div className="mb-4">
<label
htmlFor="notes"
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
Additional notes
</label>
<textarea
name="notes"
id="notes"
rows={3}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="Please share anything that will help prepare for our meeting."
defaultValue={props.booking ? props.booking.description : ""}
/>
</div>
<div className="flex items-start">
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
<Button type="submit" loading={loading}>
{rescheduleUid ? "Reschedule" : "Confirm"}
</Button>
<Link
href={
"/" +
props.user.username +
"/" +
props.eventType.slug +
(rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")
}>
<a className="ml-2 text-sm dark:text-white p-2">Cancel</a>
</Link>
</div>
</form>
{error && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mt-2">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Could not {rescheduleUid ? "reschedule" : "book"} the meeting. Please try again or{" "}
<a
href={"mailto:" + props.user.email}
className="font-medium underline text-yellow-700 hover:text-yellow-600">
Contact {props.user.name} via e-mail
</a>
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</main>
</div>
)
);
return <BookingPage {...props} />;
}
export async function getServerSideProps(context) {
const user = await whereAndSelect(
prisma.user.findFirst,
{
const user = await prisma.user.findUnique({
where: {
username: context.query.user,
},
["username", "name", "email", "bio", "avatar", "theme"]
);
select: {
username: true,
name: true,
email: true,
bio: true,
avatar: true,
theme: true,
},
});
const eventType = await prisma.eventType.findUnique({
where: {
@ -444,6 +43,16 @@ export async function getServerSideProps(context) {
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
users: {
select: {
username: true,
name: true,
email: true,
bio: true,
avatar: true,
theme: true,
},
},
},
});
@ -476,7 +85,12 @@ export async function getServerSideProps(context) {
return {
props: {
user,
profile: {
slug: user.username,
name: user.name,
image: user.avatar,
theme: user.theme,
},
eventType: eventTypeObject,
booking,
},

View File

@ -3,11 +3,19 @@ import prisma from "@lib/prisma";
import { getBusyCalendarTimes } from "@lib/calendarClient";
// import { getBusyVideoTimes } from "@lib/videoClient";
import dayjs from "dayjs";
import { asStringOrNull } from "@lib/asStringOrNull";
import { User } from "@prisma/client";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { user } = req.query;
const user = asStringOrNull(req.query.user);
const dateFrom = dayjs(asStringOrNull(req.query.dateFrom));
const dateTo = dayjs(asStringOrNull(req.query.dateTo));
const currentUser = await prisma.user.findFirst({
if (!dateFrom.isValid() || !dateTo.isValid()) {
return res.status(400).json({ message: "Invalid time range given." });
}
const currentUser: User = await prisma.user.findUnique({
where: {
username: user,
},
@ -15,7 +23,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
credentials: true,
timeZone: true,
bufferTime: true,
availability: true,
id: true,
startTime: true,
endTime: true,
},
});
@ -25,23 +36,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const calendarBusyTimes = await getBusyCalendarTimes(
const busyTimes = await getBusyCalendarTimes(
currentUser.credentials,
req.query.dateFrom,
req.query.dateTo,
dateFrom.format(),
dateTo.format(),
selectedCalendars
);
// const videoBusyTimes = await getBusyVideoTimes(
// currentUser.credentials,
// req.query.dateFrom,
// req.query.dateTo
// );
// calendarBusyTimes.push(...videoBusyTimes);
const bufferedBusyTimes = calendarBusyTimes.map((a) => ({
// busyTimes.push(...await getBusyVideoTimes(currentUser.credentials, dateFrom.format(), dateTo.format()));
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
res.status(200).json(bufferedBusyTimes);
res.status(200).json({
busy: bufferedBusyTimes,
workingHours: {
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
timeZone: currentUser.timeZone,
startTime: currentUser.startTime,
endTime: currentUser.endTime,
},
});
}

View File

@ -61,15 +61,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
minimumBookingNotice: req.body.minimumBookingNotice,
};
if (req.body.schedulingType) {
data.schedulingType = req.body.schedulingType;
}
if (req.method == "POST") {
if (req.body.teamId) {
data.team = {
connect: {
id: req.body.teamId,
},
};
}
const eventType = await prisma.eventType.create({
data: {
userId: session.user.id,
...data,
users: {
connect: {
id: parseInt(session.user.id),
},
},
},
});
res.status(201).json({ eventType });
} else if (req.method == "PATCH") {
if (req.body.users) {
data.users = {
set: [],
connect: req.body.users.map((id: number) => ({ id })),
};
}
if (req.body.timeZone) {
data.timeZone = req.body.timeZone;
}

View File

@ -1,330 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@lib/prisma";
import { EventType, User } from "@prisma/client";
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import { getBusyVideoTimes } from "@lib/videoClient";
import { getEventName } from "@lib/event";
import dayjs from "dayjs";
import logger from "../../../lib/logger";
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import isBetween from "dayjs/plugin/isBetween";
import dayjsBusinessDays from "dayjs-business-days";
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
dayjs.extend(dayjsBusinessDays);
dayjs.extend(utc);
dayjs.extend(isBetween);
dayjs.extend(timezone);
const translator = short();
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
function isAvailable(busyTimes, time, length) {
// Check for conflicts
let t = true;
if (Array.isArray(busyTimes) && busyTimes.length > 0) {
busyTimes.forEach((busyTime) => {
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime, null, "[)")) {
t = false;
}
// Check if slot end time is between start and end time
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
t = false;
}
// Check if startTime is between slot
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
t = false;
}
});
}
return t;
}
function isOutOfBounds(
time: dayjs.ConfigType,
{ periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, timeZone }
): boolean {
const date = dayjs(time);
switch (periodType) {
case "rolling": {
const periodRollingEndDay = periodCountCalendarDays
? dayjs().tz(timeZone).add(periodDays, "days").endOf("day")
: dayjs().tz(timeZone).businessDaysAdd(periodDays, "days").endOf("day");
return date.endOf("day").isAfter(periodRollingEndDay);
}
case "range": {
const periodRangeStartDay = dayjs(periodStartDate).tz(timeZone).endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).tz(timeZone).endOf("day");
return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
}
case "unlimited":
default:
return false;
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const { user } = req.query;
log.debug(`Booking ${user} started`);
try {
const isTimeInPast = (time) => {
return dayjs(time).isBefore(new Date(), "day");
};
if (isTimeInPast(req.body.start)) {
const error = {
errorCode: "BookingDateInPast",
message: "Attempting to create a meeting in the past.",
};
log.error(`Booking ${user} failed`, error);
return res.status(400).json(error);
}
let currentUser: User = await prisma.user.findFirst({
where: {
username: user,
},
select: {
id: true,
credentials: true,
timeZone: true,
email: true,
name: true,
},
});
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 calendarAvailability = await getBusyCalendarTimes(
currentUser.credentials,
dayjs(req.body.start).startOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
dayjs(req.body.end).endOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
selectedCalendars
);
const videoAvailability = await getBusyVideoTimes(currentUser.credentials);
let commonAvailability = [];
if (hasCalendarIntegrations && hasVideoIntegrations) {
commonAvailability = calendarAvailability.filter((availability) =>
videoAvailability.includes(availability)
);
} else if (hasVideoIntegrations) {
commonAvailability = videoAvailability;
} else if (hasCalendarIntegrations) {
commonAvailability = calendarAvailability;
}
// Now, get the newly stored credentials (new refresh token for example).
currentUser = await prisma.user.findFirst({
where: {
username: user,
},
select: {
id: true,
credentials: true,
timeZone: true,
email: true,
name: true,
},
});
// Initialize EventManager with credentials
const eventManager = new EventManager(currentUser.credentials);
const rescheduleUid = req.body.rescheduleUid;
const eventType: EventType = await prisma.eventType.findFirst({
where: {
userId: currentUser.id,
id: req.body.eventTypeId,
},
select: {
id: true,
eventName: true,
title: true,
length: true,
periodType: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
},
});
const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }];
const guests = req.body.guests.map((guest) => {
const g = {
email: guest,
name: "",
timeZone: req.body.timeZone,
};
return g;
});
const attendeesList = [...invitee, ...guests];
const evt: CalendarEvent = {
type: eventType.title,
title: getEventName(req.body.name, eventType.title, eventType.eventName),
description: req.body.notes,
startTime: req.body.start,
endTime: req.body.end,
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
attendees: attendeesList,
location: req.body.location, // Will be processed by the EventManager later.
};
let isAvailableToBeBooked = true;
try {
isAvailableToBeBooked = isAvailable(commonAvailability, req.body.start, eventType.length);
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
});
}
if (!isAvailableToBeBooked) {
const error = {
errorCode: "BookingUserUnAvailable",
message: `${currentUser.name} is unavailable at this time.`,
};
log.debug(`Booking ${user} failed`, error);
return res.status(400).json(error);
}
let timeOutOfBounds = false;
try {
timeOutOfBounds = isOutOfBounds(req.body.start, {
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodEndDate: eventType.periodEndDate,
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
timeZone: currentUser.timeZone,
});
} catch {
log.debug({
message: "Unable set timeOutOfBounds. Using false. ",
});
}
if (timeOutOfBounds) {
const error = {
errorCode: "BookingUserUnAvailable",
message: `${currentUser.name} is unavailable at this time.`,
};
log.debug(`Booking ${user} failed`, error);
return res.status(400).json(error);
}
let results: Array<EventResult> = [];
let referencesToCreate = [];
if (rescheduleUid) {
// Use EventManager to conditionally use all needed integrations.
const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
log.error(`Booking ${user} failed`, error, results);
return res.status(500).json(error);
}
// Forward results
results = updateResults.results;
referencesToCreate = updateResults.referencesToCreate;
} else if (!eventType.requiresConfirmation) {
// Use EventManager to conditionally use all needed integrations.
const createResults: CreateUpdateResult = await eventManager.create(evt);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
log.error(`Booking ${user} failed`, error, results);
return res.status(500).json(error);
}
// Forward results
results = createResults.results;
referencesToCreate = createResults.referencesToCreate;
}
const hashUID =
results.length > 0 ? results[0].uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
// UID generation should happen in the integration itself, not here.
try {
await prisma.booking.create({
data: {
uid: hashUID,
userId: currentUser.id,
references: {
create: referencesToCreate,
},
eventTypeId: eventType.id,
title: evt.title,
description: evt.description,
startTime: evt.startTime,
endTime: evt.endTime,
attendees: {
create: evt.attendees,
},
confirmed: !eventType.requiresConfirmation,
location: evt.location,
},
});
} catch (e) {
log.error(`Booking ${user} failed`, "Error when saving booking to db", e);
res.status(500).json({ message: "Booking already exists" });
return;
}
if (eventType.requiresConfirmation) {
await new EventOrganizerRequestMail(evt, hashUID).sendEmail();
}
log.debug(`Booking ${user} completed`);
return res.status(204).json({ message: "Booking completed" });
} catch (reason) {
log.error(`Booking ${user} failed`, reason);
return res.status(500).json({ message: "Booking failed for some unknown reason" });
}
}

434
pages/api/book/event.ts Normal file
View File

@ -0,0 +1,434 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@lib/prisma";
import {
EventType,
User,
SchedulingType,
Credential,
SelectedCalendar,
Booking,
Prisma,
} from "@prisma/client";
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import { getBusyVideoTimes } from "@lib/videoClient";
import { getEventName } from "@lib/event";
import dayjs from "dayjs";
import logger from "@lib/logger";
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import isBetween from "dayjs/plugin/isBetween";
import dayjsBusinessDays from "dayjs-business-days";
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
dayjs.extend(dayjsBusinessDays);
dayjs.extend(utc);
dayjs.extend(isBetween);
dayjs.extend(timezone);
const translator = short();
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
function isAvailable(busyTimes, time, length) {
// Check for conflicts
let t = true;
if (Array.isArray(busyTimes) && busyTimes.length > 0) {
busyTimes.forEach((busyTime) => {
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime, null, "[)")) {
t = false;
}
// Check if slot end time is between start and end time
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
t = false;
}
// Check if startTime is between slot
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
t = false;
}
});
}
return t;
}
function isOutOfBounds(
time: dayjs.ConfigType,
{ periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, timeZone }
): boolean {
const date = dayjs(time);
switch (periodType) {
case "rolling": {
const periodRollingEndDay = periodCountCalendarDays
? dayjs().tz(timeZone).add(periodDays, "days").endOf("day")
: dayjs().tz(timeZone).businessDaysAdd(periodDays, "days").endOf("day");
return date.endOf("day").isAfter(periodRollingEndDay);
}
case "range": {
const periodRangeStartDay = dayjs(periodStartDate).tz(timeZone).endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).tz(timeZone).endOf("day");
return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
}
case "unlimited":
default:
return false;
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const eventTypeId = parseInt(req.body.eventTypeId as string);
log.debug(`Booking eventType ${eventTypeId} started`);
const isTimeInPast = (time) => {
return dayjs(time).isBefore(new Date(), "day");
};
if (isTimeInPast(req.body.start)) {
const error = {
errorCode: "BookingDateInPast",
message: "Attempting to create a meeting in the past.",
};
log.error(`Booking ${eventTypeId} failed`, error);
return res.status(400).json(error);
}
const eventType: EventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: {
users: {
select: {
id: true,
email: true,
name: true,
username: true,
timeZone: true,
},
},
team: {
select: {
id: true,
name: true,
},
},
title: true,
length: true,
eventName: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
userId: true,
},
});
if (!eventType.users.length && eventType.userId) {
eventType.users.push(
await prisma.user.findUnique({
where: {
id: eventType.userId,
},
select: {
id: true,
email: true,
name: true,
username: true,
timeZone: true,
},
})
);
}
let users: User[] = eventType.users;
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
const selectedUsers = req.body.users || [];
// one of these things that can probably be done better
// prisma is not well documented.
users = await Promise.all(
selectedUsers.map(async (username) => {
const user = await prisma.user.findUnique({
where: {
username,
},
select: {
bookings: {
where: {
startTime: {
gt: new Date(),
},
},
select: {
id: true,
},
},
},
});
return {
username,
bookingCount: user.bookings.length,
};
})
).then((bookingCounts) => {
if (!bookingCounts.length) {
return users.slice(0, 1);
}
const sorted = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
return [users.find((user) => user.username === sorted[0].username)];
});
}
const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }];
const guests = req.body.guests.map((guest) => {
const g = {
email: guest,
name: "",
timeZone: req.body.timeZone,
};
return g;
});
const teamMembers =
eventType.schedulingType === SchedulingType.COLLECTIVE
? users.slice(1).map((user) => ({
email: user.email,
name: user.name,
timeZone: user.timeZone,
}))
: [];
const attendeesList = [...invitee, ...guests, ...teamMembers];
const seed = `${users[0].username}:${dayjs(req.body.start).utc().format()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const evt: CalendarEvent = {
type: eventType.title,
title: getEventName(req.body.name, eventType.title, eventType.eventName),
description: req.body.notes,
startTime: req.body.start,
endTime: req.body.end,
organizer: {
name: users[0].name,
email: users[0].email,
timeZone: users[0].timeZone,
},
attendees: attendeesList,
location: req.body.location, // Will be processed by the EventManager later.
};
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
evt.team = {
members: users.map((user) => user.name || user.username),
name: eventType.team.name,
}; // used for invitee emails
}
const bookingCreateInput: Prisma.BookingCreateInput = {
uid,
title: evt.title,
startTime: dayjs(evt.startTime).toDate(),
endTime: dayjs(evt.endTime).toDate(),
description: evt.description,
confirmed: !eventType.requiresConfirmation,
location: evt.location,
eventType: {
connect: {
id: eventTypeId,
},
},
attendees: {
createMany: {
data: evt.attendees,
},
},
user: {
connect: {
id: users[0].id,
},
},
};
let booking: Booking | null;
try {
booking = await prisma.booking.create({
data: bookingCreateInput,
});
} catch (e) {
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", e.message);
if (e.code === "P2002") {
return res.status(409).json({ message: "booking.conflict" });
}
return res.status(500).end();
}
let results: EventResult[] = [];
let referencesToCreate = [];
const loadUser = async (id): Promise<User> =>
await prisma.user.findUnique({
where: {
id,
},
select: {
id: true,
credentials: true,
timeZone: true,
email: true,
username: true,
name: true,
bufferTime: true,
},
});
let user: User;
for (const currentUser of await Promise.all(users.map((user) => loadUser(user.id)))) {
if (!user) {
user = currentUser;
}
const selectedCalendars: SelectedCalendar[] = await prisma.selectedCalendar.findMany({
where: {
userId: currentUser.id,
},
});
const credentials: Credential[] = currentUser.credentials;
if (credentials) {
const calendarBusyTimes = await getBusyCalendarTimes(
credentials,
req.body.start,
req.body.end,
selectedCalendars
);
const videoBusyTimes = await getBusyVideoTimes(credentials);
calendarBusyTimes.push(...videoBusyTimes);
const bufferedBusyTimes = calendarBusyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
let isAvailableToBeBooked = true;
try {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, req.body.start, eventType.length);
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
});
}
if (!isAvailableToBeBooked) {
const error = {
errorCode: "BookingUserUnAvailable",
message: `${currentUser.name} is unavailable at this time.`,
};
log.debug(`Booking ${currentUser.name} failed`, error);
}
let timeOutOfBounds = false;
try {
timeOutOfBounds = isOutOfBounds(req.body.start, {
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodEndDate: eventType.periodEndDate,
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
timeZone: currentUser.timeZone,
});
} catch {
log.debug({
message: "Unable set timeOutOfBounds. Using false. ",
});
}
if (timeOutOfBounds) {
const error = {
errorCode: "BookingUserUnAvailable",
message: `${currentUser.name} is unavailable at this time.`,
};
log.debug(`Booking ${currentUser.name} failed`, error);
res.status(400).json(error);
}
}
}
// Initialize EventManager with credentials
const eventManager = new EventManager(user.credentials);
const rescheduleUid = req.body.rescheduleUid;
if (rescheduleUid) {
// Use EventManager to conditionally use all needed integrations.
const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
results = updateResults.results;
referencesToCreate = updateResults.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
log.error(`Booking ${user.name} failed`, error, results);
}
} else if (!eventType.requiresConfirmation) {
// Use EventManager to conditionally use all needed integrations.
const createResults: CreateUpdateResult = await eventManager.create(evt, uid);
results = createResults.results;
referencesToCreate = createResults.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
log.error(`Booking ${user.username} failed`, error, results);
}
}
if (eventType.requiresConfirmation) {
await new EventOrganizerRequestMail(evt, uid).sendEmail();
}
log.debug(`Booking ${user.username} completed`);
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
references: {
createMany: {
data: referencesToCreate,
},
},
},
});
// booking succesfull
return res.status(201).json(booking);
}

View File

@ -1,79 +1,80 @@
import prisma from "@lib/prisma";
import { deleteEvent } from "@lib/calendarClient";
import async from "async";
import { deleteMeeting } from "@lib/videoClient";
import { asStringOrNull } from "@lib/asStringOrNull";
import async from "async";
import { BookingStatus } from "@prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull";
export default async function handler(req, res) {
if (req.method == "POST") {
const uid = asStringOrNull(req.body.uid);
const bookingToDelete = await prisma.booking.findUnique({
where: {
uid,
},
select: {
id: true,
user: {
select: {
credentials: true,
},
},
attendees: true,
references: {
select: {
uid: true,
type: true,
},
},
},
});
if (!bookingToDelete) {
return res.status(404).end();
}
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
// action always succeeds even if subsequent integrations fail cancellation.
await prisma.booking.update({
where: {
uid,
},
data: {
status: BookingStatus.CANCELLED,
},
});
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
if (bookingRefUid) {
if (credential.type.endsWith("_calendar")) {
return await deleteEvent(credential, bookingRefUid);
} else if (credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
}
}
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: bookingToDelete.id,
},
});
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: bookingToDelete.id,
},
});
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes]);
//TODO Perhaps send emails to user and client to tell about the cancellation
res.status(200).json({ message: "Booking successfully deleted." });
} else {
res.status(405).json({ message: "This endpoint only accepts POST requests." });
// just bail if it not a DELETE
if (req.method !== "DELETE") {
return res.status(405).end();
}
const uid = asStringOrNull(req.body.uid) || "";
const bookingToDelete = await prisma.booking.findUnique({
where: {
uid,
},
select: {
id: true,
user: {
select: {
credentials: true,
},
},
attendees: true,
references: {
select: {
uid: true,
type: true,
},
},
},
});
if (!bookingToDelete) {
return res.status(404).end();
}
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
// action always succeeds even if subsequent integrations fail cancellation.
await prisma.booking.update({
where: {
uid,
},
data: {
status: BookingStatus.CANCELLED,
},
});
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
if (bookingRefUid) {
if (credential.type.endsWith("_calendar")) {
return await deleteEvent(credential, bookingRefUid);
} else if (credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
}
}
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: bookingToDelete.id,
},
});
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: bookingToDelete.id,
},
});
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes]);
//TODO Perhaps send emails to user and client to tell about the cancellation
res.status(204).end();
}

View File

@ -10,6 +10,7 @@ import { DotsHorizontalIcon } from "@heroicons/react/solid";
import classNames from "@lib/classNames";
import { ClockIcon, XIcon } from "@heroicons/react/outline";
import Loader from "@components/Loader";
import { Button } from "@components/ui/Button";
import { getSession } from "@lib/auth";
import { BookingStatus } from "@prisma/client";
@ -44,7 +45,7 @@ export default function Bookings({ bookings }) {
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="border border-gray-200 overflow-hidden border-b rounded-sm">
<table className="min-w-full divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-gray-200" data-testid="bookings">
{bookings
.filter((booking) => booking.status !== BookingStatus.CANCELLED)
.map((booking) => (
@ -56,6 +57,7 @@ export default function Bookings({ bookings }) {
</span>
)}
<div className="text-sm text-neutral-900 font-medium truncate max-w-60 md:max-w-96">
{booking.eventType.team && <strong>{booking.eventType.team.name}: </strong>}
{booking.title}
</div>
<div className="sm:hidden">
@ -100,25 +102,20 @@ export default function Bookings({ bookings }) {
</>
)}
{booking.confirmed && !booking.rejected && (
<>
<a
href={window.location.href + "/../cancel/" + booking.uid}
className="hidden text-xs sm:text-sm lg:inline-flex items-center px-4 py-2 border-transparent font-medium rounded-sm shadow-sm text-neutral-700 bg-white hover:bg-neutral-100 border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black ml-2">
<XIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
<div className="space-x-2">
<Button
data-testid="cancel"
href={"/cancel/" + booking.uid}
StartIcon={XIcon}
color="secondary">
Cancel
</a>
<a
href={window.location.href + "/../reschedule/" + booking.uid}
className="hidden text-xs sm:text-sm lg:inline-flex items-center px-4 py-2 border-transparent font-medium rounded-sm shadow-sm text-neutral-700 bg-white hover:bg-neutral-100 border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black ml-2">
<ClockIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
</Button>
<Button
href={"reschedule/" + booking.uid}
StartIcon={ClockIcon}
color="secondary">
Reschedule
</a>
</Button>
<Menu as="div" className="inline-block lg:hidden text-left ">
{({ open }) => (
<>
@ -186,7 +183,7 @@ export default function Bookings({ bookings }) {
</>
)}
</Menu>
</>
</div>
)}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">Rejected</div>
@ -211,19 +208,20 @@ export async function getServerSideProps(context) {
if (!session) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
},
});
const b = await prisma.booking.findMany({
where: {
userId: user.id,
OR: [
{
userId: session.user.id,
},
{
attendees: {
some: {
id: session.user.id,
},
},
},
],
},
select: {
uid: true,
@ -235,6 +233,15 @@ export async function getServerSideProps(context) {
id: true,
startTime: true,
endTime: true,
eventType: {
select: {
team: {
select: {
name: true,
},
},
},
},
status: true,
},
orderBy: {

View File

@ -1,19 +1,14 @@
import { CalendarIcon, XIcon } from "@heroicons/react/solid";
import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { HeadSeo } from "@components/seo/head-seo";
import { useRouter } from "next/router";
import { useState } from "react";
import { Button } from "@components/ui/Button";
import prisma from "@lib/prisma";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(timezone);
export default function Type(props) {
// Get router variables
@ -37,16 +32,21 @@ export default function Type(props) {
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())
);
const res = await fetch("/api/cancel", {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
method: "DELETE",
});
if (res.status >= 200 && res.status < 300) {
router.push("/cancel/success?user=" + props.user.username + "&title=" + props.booking.title);
await router.push(
`/cancel/success?name=${props.profile.name}&title=${props.booking.title}&eventPage=${
props.profile.slug
}&team=${props.booking.eventType.team ? 1 : 0}`
);
} else {
setLoading(false);
setError("An error with status code " + res.status + " occurred. Please try again later.");
@ -56,10 +56,8 @@ export default function Type(props) {
return (
<div>
<HeadSeo
title={`Cancel ${props.booking && props.booking.title} | ${props.user.name || props.user.username}`}
description={`Cancel ${props.booking && props.booking.title} | ${
props.user.name || props.user.username
}`}
title={`Cancel ${props.booking && props.booking.title} | ${props.profile.name}`}
description={`Cancel ${props.booking && props.booking.title} | ${props.profile.name}`}
/>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto">
@ -102,30 +100,22 @@ export default function Type(props) {
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.booking.title}</h2>
<p className="text-gray-500">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{dayjs
.utc(props.booking.startTime)
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
{dayjs(props.booking.startTime).format(
(is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY"
)}
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button
onClick={cancellationHandler}
disabled={loading}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white">
Cancel
</button>
<button
onClick={() => router.push("/reschedule/" + uid)}
disabled={loading}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Reschedule
</button>
</div>
<div className="mt-5 sm:mt-6 text-center space-x-2">
<Button
color="secondary"
data-testid="cancel"
onClick={cancellationHandler}
loading={loading}>
Cancel
</Button>
<Button onClick={() => router.push("/reschedule/" + uid)}>Reschedule</Button>
</div>
</>
)}
@ -139,7 +129,7 @@ export default function Type(props) {
}
export async function getServerSideProps(context) {
const booking = await prisma.booking.findFirst({
const booking = await prisma.booking.findUnique({
where: {
uid: context.query.uid,
},
@ -150,33 +140,47 @@ export async function getServerSideProps(context) {
startTime: true,
endTime: true,
attendees: true,
eventType: true,
user: {
select: {
id: true,
username: true,
name: true,
},
},
eventType: {
select: {
team: {
select: {
slug: true,
name: true,
},
},
},
},
},
});
if (!booking) {
// TODO: Booking is already cancelled
return {
props: { booking: null },
};
}
// Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993)
const bookingObj = Object.assign({}, booking, {
startTime: booking.startTime.toString(),
endTime: booking.endTime.toString(),
});
const profile = booking.eventType.team
? {
name: booking.eventType.team.name,
slug: booking.eventType.team.slug,
}
: booking.user;
return {
props: {
user: booking.user,
eventType: booking.eventType,
profile,
booking: bookingObj,
},
};

View File

@ -1,28 +1,18 @@
import { HeadSeo } from "@components/seo/head-seo";
import prisma from "../../lib/prisma";
import { useRouter } from "next/router";
import dayjs from "dayjs";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { CheckIcon } from "@heroicons/react/outline";
import { useSession } from "next-auth/client";
import Button from "@components/ui/Button";
import { ArrowRightIcon } from "@heroicons/react/solid";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(timezone);
export default function Type(props) {
export default function CancelSuccess() {
// Get router variables
const router = useRouter();
const { title, name, eventPage } = router.query;
const [session, loading] = useSession();
return (
<div>
<HeadSeo
title={`Cancelled ${props.title} | ${props.user.name || props.user.username}`}
description={`Cancelled ${props.title} | ${props.user.name || props.user.username}`}
/>
<HeadSeo title={`Cancelled ${title} | ${name}`} description={`Cancelled ${title} | ${name}`} />
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
@ -43,19 +33,21 @@ export default function Type(props) {
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Cancellation successful
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">Feel free to pick another event anytime.</p>
</div>
{!loading && !session.user && (
<div className="mt-2">
<p className="text-sm text-gray-500">Feel free to pick another event anytime.</p>
</div>
)}
</div>
</div>
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button
onClick={() => router.push("/" + props.user.username)}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Pick another
</button>
{!loading && !session.user && <Button href={eventPage}>Pick another</Button>}
{!loading && session.user && (
<Button data-testid="back-to-bookings" href="/bookings" EndIcon={ArrowRightIcon}>
Back to bookings
</Button>
)}
</div>
</div>
</div>
@ -66,25 +58,3 @@ export default function Type(props) {
</div>
);
}
export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
username: true,
name: true,
bio: true,
avatar: true,
eventTypes: true,
},
});
return {
props: {
user,
title: context.query.title,
},
};
}

View File

@ -3,7 +3,7 @@ import Modal from "@components/Modal";
import React, { useEffect, useRef, useState } from "react";
import Select, { OptionTypeBase } from "react-select";
import prisma from "@lib/prisma";
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
import { EventTypeCustomInput, EventTypeCustomInputType, SchedulingType } from "@prisma/client";
import { LocationType } from "@lib/location";
import Shell from "@components/Shell";
import { getSession } from "@lib/auth";
@ -21,6 +21,8 @@ import {
ClockIcon,
TrashIcon,
ExternalLinkIcon,
UsersIcon,
UserAddIcon,
} from "@heroicons/react/solid";
import dayjs from "dayjs";
@ -28,7 +30,6 @@ import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { Availability } from "@prisma/client";
import { validJson } from "@lib/jsonUtils";
import classnames from "classnames";
import throttle from "lodash.throttle";
import "react-dates/initialize";
import "react-dates/lib/css/_datepicker.css";
@ -42,6 +43,10 @@ import { EventTypeInput } from "@lib/types/event-type";
import updateEventType from "@lib/mutations/event-types/update-event-type";
import deleteEventType from "@lib/mutations/event-types/delete-event-type";
import showToast from "@lib/notification";
import CheckedSelect from "@components/ui/form/CheckedSelect";
import { defaultAvatarSrc } from "@lib/profile";
import * as RadioArea from "@components/ui/form/radio-area";
import classNames from "@lib/classNames";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { asStringOrThrow } from "@lib/asStringOrNull";
import Button from "@components/ui/Button";
@ -65,7 +70,8 @@ const PERIOD_TYPES = [
];
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const { user, eventType, locationOptions, availability } = props;
const { eventType, locationOptions, availability, team, teamMembers } = props;
const router = useRouter();
const [successModalOpen, setSuccessModalOpen] = useState(false);
@ -131,6 +137,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
}
}, [contentSize]);
const [users, setUsers] = useState([]);
const [enteredAvailability, setEnteredAvailability] = useState();
const [showLocationModal, setShowLocationModal] = useState(false);
const [showAddCustomModal, setShowAddCustomModal] = useState(false);
@ -169,24 +176,22 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [hidden, setHidden] = useState<boolean>(eventType.hidden);
const titleRef = useRef<HTMLInputElement>(null);
const slugRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const lengthRef = useRef<HTMLInputElement>(null);
const requiresConfirmationRef = useRef<HTMLInputElement>(null);
const eventNameRef = useRef<HTMLInputElement>(null);
const periodDaysRef = useRef<HTMLInputElement>(null);
const periodDaysTypeRef = useRef<HTMLSelectElement>(null);
useEffect(() => {
setSelectedTimeZone(eventType.timeZone || user.timeZone);
setSelectedTimeZone(eventType.timeZone);
}, []);
async function updateEventTypeHandler(event) {
event.preventDefault();
const formData = Object.fromEntries(new FormData(event.target).entries());
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 advancedOptionsPayload: AdvancedOptions = {};
if (requiresConfirmationRef.current) {
@ -203,14 +208,20 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
id: eventType.id,
title: enteredTitle,
slug: enteredSlug,
description: enteredDescription,
length: enteredLength,
description: formData.description as string,
length: formData.length as number,
hidden,
locations,
customInputs,
timeZone: selectedTimeZone,
availability: enteredAvailability || null,
...advancedOptionsPayload,
...(team
? {
schedulingType: formData.schedulingType as string,
users,
}
: {}),
};
updateMutation.mutate(payload);
@ -334,6 +345,19 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
setCustomInputs([...customInputs]);
};
const schedulingTypeOptions: { value: string; label: string }[] = [
{
value: SchedulingType.COLLECTIVE,
label: "Collective",
description: "Schedule meetings when all selected team members are available.",
},
{
value: SchedulingType.ROUND_ROBIN,
label: "Round Robin",
description: "Cycle meetings between multiple team members.",
},
];
return (
<div>
<Shell
@ -353,73 +377,72 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
subtitle={eventType.description}>
<div className="block sm:flex">
<div className="w-full mr-2 sm:w-10/12">
<div className="p-4 -mx-4 bg-white border rounded-sm border-neutral-200 sm:mx-0 sm:p-8">
<form onSubmit={updateEventTypeHandler} className="space-y-4">
<div className="items-center block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label htmlFor="slug" className="flex mt-0 text-sm font-medium text-neutral-700">
<LinkIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
URL
</label>
</div>
<div className="w-full">
<div className="flex rounded-sm shadow-sm">
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{typeof location !== "undefined" ? location.hostname : ""}/{user.username}/
</span>
<input
ref={slugRef}
type="text"
name="slug"
id="slug"
required
className="flex-1 block w-full min-w-0 border-gray-300 rounded-none rounded-r-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
defaultValue={eventType.slug}
/>
<div className="p-4 py-6 -mx-4 bg-white border rounded-sm border-neutral-200 sm:mx-0 sm:px-8">
<form onSubmit={updateEventTypeHandler} className="space-y-6">
<div className="space-y-3">
<div className="items-center block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label htmlFor="slug" className="flex mt-0 text-sm font-medium text-neutral-700">
<LinkIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
URL
</label>
</div>
<div className="w-full">
<div className="flex rounded-sm shadow-sm">
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{typeof location !== "undefined" ? location.hostname : ""}/
{team ? "team/" + team.slug : eventType.users[0].username}/
</span>
<input
ref={slugRef}
type="text"
name="slug"
id="slug"
required
className="flex-1 block w-full min-w-0 border-gray-300 rounded-none rounded-r-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
defaultValue={eventType.slug}
/>
</div>
</div>
</div>
</div>
<div className="items-center block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label htmlFor="length" className="flex mt-0 text-sm font-medium text-neutral-700">
<ClockIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Duration
</label>
</div>
<div className="w-full">
<div className="relative mt-1 rounded-sm shadow-sm">
<input
ref={lengthRef}
type="number"
name="length"
id="length"
required
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="15"
defaultValue={eventType.length}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<span className="text-gray-500 sm:text-sm" id="duration">
mins
</span>
<div className="items-center block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label htmlFor="length" className="flex mt-0 text-sm font-medium text-neutral-700">
<ClockIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Duration
</label>
</div>
<div className="w-full">
<div className="relative mt-1 rounded-sm shadow-sm">
<input
type="number"
name="length"
id="length"
required
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="15"
defaultValue={eventType.length}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<span className="text-gray-500 sm:text-sm" id="duration">
mins
</span>
</div>
</div>
</div>
</div>
</div>
<hr />
<div className="items-center block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label htmlFor="location" className="flex mt-0 text-sm font-medium text-neutral-700">
<LocationMarkerIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Location
</label>
</div>
<div className="w-full">
{locations.length === 0 && (
<div className="mt-1 mb-2">
<div className="space-y-3">
<div className="items-center block sm:flex">
<div className="min-w-44 sm:mb-0">
<label htmlFor="location" className="flex mt-0 text-sm font-medium text-neutral-700">
<LocationMarkerIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Location
</label>
</div>
<div className="w-full">
{locations.length === 0 && (
<div className="flex">
<Select
name="location"
@ -431,136 +454,180 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
onChange={(e) => openLocationModal(e.value)}
/>
</div>
</div>
)}
{locations.length > 0 && (
<ul className="mt-1">
{locations.map((location) => (
<li
key={location.type}
className="p-2 mb-2 border rounded-sm shadow-sm border-neutral-300">
<div className="flex justify-between">
{location.type === LocationType.InPerson && (
<div className="flex items-center flex-grow">
<LocationMarkerIcon className="w-6 h-6" />
<span className="ml-2 text-sm">{location.address}</span>
</div>
)}
{location.type === LocationType.Phone && (
<div className="flex items-center flex-grow">
<PhoneIcon className="w-6 h-6" />
<span className="ml-2 text-sm">Phone call</span>
</div>
)}
{location.type === LocationType.GoogleMeet && (
<div className="flex items-center flex-grow">
<svg
className="w-6 h-6"
viewBox="0 0 64 54"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M16 0V16H0" fill="#EA4335" />
<path
d="M16 0V16H37.3333V27.0222L53.3333 14.0444V5.33332C53.3333 1.77777 51.5555 0 47.9999 0"
fill="#FFBA00"
/>
<path
d="M15.6438 53.3341V37.3341H37.3326V26.6675L53.3326 39.2897V48.0008C53.3326 51.5563 51.5548 53.3341 47.9993 53.3341"
fill="#00AC47"
/>
<path d="M37.3335 26.6662L53.3335 13.6885V39.644" fill="#00832D" />
<path
d="M53.3335 13.6892L60.8001 7.64481C62.4001 6.40037 64.0001 6.40037 64.0001 8.88925V44.4447C64.0001 46.9336 62.4001 46.9336 60.8001 45.6892L53.3335 39.6447"
fill="#00AC47"
/>
<path
d="M0 36.9785V48.0007C0 51.5563 1.77777 53.334 5.33332 53.334H16V36.9785"
fill="#0066DA"
/>
<path d="M0 16H16V37.3333H0" fill="#2684FC" />
</svg>
)}
{locations.length > 0 && (
<ul>
{locations.map((location) => (
<li
key={location.type}
className="p-2 mb-2 border rounded-sm shadow-sm border-neutral-300">
<div className="flex justify-between">
{location.type === LocationType.InPerson && (
<div className="flex items-center flex-grow">
<LocationMarkerIcon className="w-6 h-6" />
<span className="ml-2 text-sm">{location.address}</span>
</div>
)}
{location.type === LocationType.Phone && (
<div className="flex items-center flex-grow">
<PhoneIcon className="w-6 h-6" />
<span className="ml-2 text-sm">Phone call</span>
</div>
)}
{location.type === LocationType.GoogleMeet && (
<div className="flex items-center flex-grow">
<svg
className="w-6 h-6"
viewBox="0 0 64 54"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M16 0V16H0" fill="#EA4335" />
<path
d="M16 0V16H37.3333V27.0222L53.3333 14.0444V5.33332C53.3333 1.77777 51.5555 0 47.9999 0"
fill="#FFBA00"
/>
<path
d="M15.6438 53.3341V37.3341H37.3326V26.6675L53.3326 39.2897V48.0008C53.3326 51.5563 51.5548 53.3341 47.9993 53.3341"
fill="#00AC47"
/>
<path d="M37.3335 26.6662L53.3335 13.6885V39.644" fill="#00832D" />
<path
d="M53.3335 13.6892L60.8001 7.64481C62.4001 6.40037 64.0001 6.40037 64.0001 8.88925V44.4447C64.0001 46.9336 62.4001 46.9336 60.8001 45.6892L53.3335 39.6447"
fill="#00AC47"
/>
<path
d="M0 36.9785V48.0007C0 51.5563 1.77777 53.334 5.33332 53.334H16V36.9785"
fill="#0066DA"
/>
<path d="M0 16H16V37.3333H0" fill="#2684FC" />
</svg>
<span className="ml-2 text-sm">Google Meet</span>
<span className="ml-2 text-sm">Google Meet</span>
</div>
)}
{location.type === LocationType.Zoom && (
<div className="flex items-center flex-grow">
<svg
className="w-6 h-6"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M32 0C49.6733 0 64 14.3267 64 32C64 49.6733 49.6733 64 32 64C14.3267 64 0 49.6733 0 32C0 14.3267 14.3267 0 32 0Z"
fill="#E5E5E4"
/>
<path
d="M32.0002 0.623047C49.3292 0.623047 63.3771 14.6709 63.3771 31.9999C63.3771 49.329 49.3292 63.3768 32.0002 63.3768C14.6711 63.3768 0.623291 49.329 0.623291 31.9999C0.623291 14.6709 14.6716 0.623047 32.0002 0.623047Z"
fill="white"
/>
<path
d="M31.9998 3.14014C47.9386 3.14014 60.8597 16.0612 60.8597 32C60.8597 47.9389 47.9386 60.8599 31.9998 60.8599C16.0609 60.8599 3.13989 47.9389 3.13989 32C3.13989 16.0612 16.0609 3.14014 31.9998 3.14014Z"
fill="#4A8CFF"
/>
<path
d="M13.1711 22.9581V36.5206C13.1832 39.5875 15.6881 42.0558 18.743 42.0433H38.5125C39.0744 42.0433 39.5266 41.5911 39.5266 41.0412V27.4788C39.5145 24.4119 37.0096 21.9435 33.9552 21.956H14.1857C13.6238 21.956 13.1716 22.4082 13.1716 22.9581H13.1711ZM40.7848 28.2487L48.9469 22.2864C49.6557 21.6998 50.2051 21.8462 50.2051 22.9095V41.0903C50.2051 42.2999 49.5329 42.1536 48.9469 41.7134L40.7848 35.7631V28.2487Z"
fill="white"
/>
</svg>
<span className="ml-2 text-sm">Zoom Video</span>
</div>
)}
<div className="flex">
<button
type="button"
onClick={() => openLocationModal(location.type)}
className="mr-2 text-sm text-primary-600">
Edit
</button>
<button onClick={() => removeLocation(location)}>
<XIcon className="w-6 h-6 pl-1 border-l-2 hover:text-red-500 " />
</button>
</div>
)}
{location.type === LocationType.Zoom && (
<div className="flex items-center flex-grow">
<svg
className="w-6 h-6"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M32 0C49.6733 0 64 14.3267 64 32C64 49.6733 49.6733 64 32 64C14.3267 64 0 49.6733 0 32C0 14.3267 14.3267 0 32 0Z"
fill="#E5E5E4"
/>
<path
d="M32.0002 0.623047C49.3292 0.623047 63.3771 14.6709 63.3771 31.9999C63.3771 49.329 49.3292 63.3768 32.0002 63.3768C14.6711 63.3768 0.623291 49.329 0.623291 31.9999C0.623291 14.6709 14.6716 0.623047 32.0002 0.623047Z"
fill="white"
/>
<path
d="M31.9998 3.14014C47.9386 3.14014 60.8597 16.0612 60.8597 32C60.8597 47.9389 47.9386 60.8599 31.9998 60.8599C16.0609 60.8599 3.13989 47.9389 3.13989 32C3.13989 16.0612 16.0609 3.14014 31.9998 3.14014Z"
fill="#4A8CFF"
/>
<path
d="M13.1711 22.9581V36.5206C13.1832 39.5875 15.6881 42.0558 18.743 42.0433H38.5125C39.0744 42.0433 39.5266 41.5911 39.5266 41.0412V27.4788C39.5145 24.4119 37.0096 21.9435 33.9552 21.956H14.1857C13.6238 21.956 13.1716 22.4082 13.1716 22.9581H13.1711ZM40.7848 28.2487L48.9469 22.2864C49.6557 21.6998 50.2051 21.8462 50.2051 22.9095V41.0903C50.2051 42.2999 49.5329 42.1536 48.9469 41.7134L40.7848 35.7631V28.2487Z"
fill="white"
/>
</svg>
<span className="ml-2 text-sm">Zoom Video</span>
</div>
)}
<div className="flex">
<button
type="button"
onClick={() => openLocationModal(location.type)}
className="mr-2 text-sm text-primary-600">
Edit
</button>
<button onClick={() => removeLocation(location)}>
<XIcon className="w-6 h-6 pl-1 border-l-2 hover:text-red-500 " />
</button>
</div>
</div>
</li>
))}
{locations.length > 0 && locations.length !== locationOptions.length && (
<li>
<button
type="button"
className="flex px-3 py-2 rounded-sm bg-neutral-100"
onClick={() => setShowLocationModal(true)}>
<PlusIcon className="h-4 w-4 mt-0.5 text-neutral-900" />
<span className="ml-1 text-sm font-medium text-neutral-700">
Add another location
</span>
</button>
</li>
)}
</ul>
)}
</li>
))}
{locations.length > 0 && locations.length !== locationOptions.length && (
<li>
<button
type="button"
className="flex px-3 py-2 rounded-sm bg-neutral-100"
onClick={() => setShowLocationModal(true)}>
<PlusIcon className="h-4 w-4 mt-0.5 text-neutral-900" />
<span className="ml-1 text-sm font-medium text-neutral-700">
Add another location
</span>
</button>
</li>
)}
</ul>
)}
</div>
</div>
</div>
<hr className="border-neutral-200" />
<div className="items-center block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label htmlFor="description" className="flex mt-0 text-sm font-medium text-neutral-700">
<DocumentIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Description
</label>
</div>
<div className="w-full">
<textarea
ref={descriptionRef}
name="description"
id="description"
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="A quick video meeting."
defaultValue={eventType.description}></textarea>
<div className="space-y-3">
<div className="items-center block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label htmlFor="description" className="flex mt-0 text-sm font-medium text-neutral-700">
<DocumentIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Description
</label>
</div>
<div className="w-full">
<textarea
name="description"
id="description"
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="A quick video meeting."
defaultValue={eventType.description}></textarea>
</div>
</div>
</div>
{team && <hr className="border-neutral-200" />}
{team && (
<div className="space-y-3">
<div className="block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label
htmlFor="schedulingType"
className="flex mt-2 text-sm font-medium text-neutral-700">
<UsersIcon className="text-neutral-500 h-5 w-5 mr-2" /> Scheduling Type
</label>
</div>
<RadioArea.Select
name="schedulingType"
value={eventType.schedulingType}
options={schedulingTypeOptions}
/>
</div>
<div className="block sm:flex">
<div className="mb-4 min-w-44 sm:mb-0">
<label htmlFor="users" className="flex mt-2 text-sm font-medium text-neutral-700">
<UserAddIcon className="text-neutral-500 h-5 w-5 mr-2" /> Attendees
</label>
</div>
<div className="w-full space-y-2">
<CheckedSelect
onChange={(options: unknown) => setUsers(options.map((option) => option.value))}
defaultValue={eventType.users.map((user: User) => ({
value: user.id,
label: user.name,
avatar: user.avatar,
}))}
options={teamMembers.map((user: User) => ({
value: user.id,
label: user.name,
avatar: user.avatar,
}))}
id="users"
placeholder="Add attendees"
/>
</div>
</div>
</div>
)}
<Disclosure>
{({ open }) => (
<>
@ -703,7 +770,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
key={period.type}
value={period}
className={({ checked }) =>
classnames(
classNames(
checked ? "border-secondary-200 z-10" : "border-gray-200",
"relative min-h-14 flex items-center cursor-pointer focus:outline-none"
)
@ -711,7 +778,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
{({ active, checked }) => (
<>
<div
className={classnames(
className={classNames(
checked
? "bg-primary-600 border-transparent"
: "bg-white border-gray-300",
@ -724,7 +791,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="flex flex-col lg:ml-3">
<RadioGroup.Label
as="span"
className={classnames(
className={classNames(
checked ? "text-secondary-900" : "text-gray-900",
"block text-sm space-y-2 lg:space-y-0 lg:space-x-2"
)}>
@ -833,7 +900,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
label="Hide event type"
/>
<a
href={"/" + user.username + "/" + eventType.slug}
href={"/" + (team ? "team/" + team.slug : eventType.users[0].username) + "/" + eventType.slug}
target="_blank"
rel="noreferrer"
className="flex font-medium text-md text-neutral-700">
@ -843,7 +910,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<button
onClick={() => {
navigator.clipboard.writeText(
window.location.hostname + "/" + user.username + "/" + eventType.slug
window.location.hostname +
"/" +
(team ? "team/" + team.slug : eventType.users[0].username) +
"/" +
eventType.slug
);
showToast("Link copied!", "success");
}}
@ -1032,7 +1103,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, query } = context;
const session = await getSession({ req });
const typeParam = asStringOrThrow(query.type);
const typeParam = parseInt(asStringOrThrow(query.type));
if (!session?.user?.id) {
return {
@ -1043,30 +1114,27 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
id: true,
username: true,
timeZone: true,
startTime: true,
endTime: true,
availability: true,
plan: true,
},
});
if (!user) {
return {
notFound: true,
} as const;
}
const eventType = await prisma.eventType.findFirst({
where: {
AND: [{ userId: user.id }, { slug: typeParam }],
AND: [
{
OR: [
{
users: {
some: {
id: session.user.id,
},
},
},
{
userId: session.user.id,
},
],
},
{
id: typeParam,
},
],
},
select: {
id: true,
@ -1086,6 +1154,36 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
team: {
select: {
slug: true,
members: {
where: {
accepted: true,
},
select: {
user: {
select: {
name: true,
id: true,
avatar: true,
email: true,
},
},
},
},
},
},
users: {
select: {
name: true,
id: true,
avatar: true,
username: true,
},
},
schedulingType: true,
userId: true,
},
});
@ -1095,9 +1193,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
// backwards compat
if (eventType.users.length === 0) {
eventType.users.push(
await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
username: true,
},
})
);
}
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
userId: session.user.id,
},
select: {
id: true,
@ -1151,15 +1263,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
? providesAvailability.availability
: null;
const availability: Availability[] = getAvailability(eventType) ||
getAvailability(user) || [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: user.startTime,
endTime: user.endTime,
},
];
const availability: Availability[] = getAvailability(eventType) || [];
availability.sort((a, b) => a.startTime - b.startTime);
const eventTypeObject = Object.assign({}, eventType, {
@ -1167,12 +1271,21 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
const teamMembers = eventTypeObject.team
? eventTypeObject.team.members.map((member) => {
const user = member.user;
user.avatar = user.avatar || defaultAvatarSrc({ email: user.email });
return user;
})
: [];
return {
props: {
user,
eventType: eventTypeObject,
locationOptions,
availability,
team: eventTypeObject.team || null,
teamMembers,
},
};
};

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import { GetServerSidePropsContext } from "next";
import prisma from "../../lib/prisma";
import prisma from "@lib/prisma";
import { asStringOrNull } from "@lib/asStringOrNull";
export default function Type() {
// Just redirect to the schedule page to reschedule it.
@ -7,14 +8,28 @@ export default function Type() {
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const booking = await prisma.booking.findFirst({
const booking = await prisma.booking.findUnique({
where: {
uid: context.query.uid as string,
uid: asStringOrNull(context.query.uid),
},
select: {
id: true,
user: { select: { username: true } },
eventType: { select: { slug: true } },
eventType: {
select: {
users: {
select: {
username: true,
},
},
slug: true,
team: {
select: {
slug: true,
},
},
},
},
user: true,
title: true,
description: true,
startTime: true,
@ -22,16 +37,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
attendees: true,
},
});
if (!booking?.user || !booking.eventType) {
if (!booking.eventType) {
return {
notFound: true,
};
}
const eventType = booking.eventType;
const eventPage =
(eventType.team ? "team/" + eventType.team.slug : booking.user.username) + "/" + booking.eventType.slug;
return {
redirect: {
destination:
"/" + booking.user.username + "/" + booking.eventType.slug + "?rescheduleUid=" + context.query.uid,
destination: "/" + eventPage + "?rescheduleUid=" + context.query.uid,
permanent: false,
},
};

103
pages/sandbox/RadioArea.tsx Normal file
View File

@ -0,0 +1,103 @@
import * as RadioArea from "@components/ui/form/radio-area";
import Head from "next/head";
import React, { useState } from "react";
const selectOptions = [
{
value: "rabbit",
label: "Rabbit",
description: "Fast and hard.",
},
{
value: "turtle",
label: "Turtle",
description: "Slow and steady.",
},
];
export default function RadioAreaPage() {
const [formData, setFormData] = useState({});
const onSubmit = (e) => {
e.preventDefault();
};
return (
<>
<Head>
<meta name="googlebot" content="noindex" />
</Head>
<div className="w-full p-4">
<h1 className="text-4xl mb-4">RadioArea component</h1>
<form onSubmit={onSubmit} className="space-y-4 mb-2">
<RadioArea.Group
onChange={(radioGroup_1: string) => setFormData({ ...formData, radioGroup_1 })}
className="flex space-x-4 max-w-screen-md"
name="radioGroup_1">
<RadioArea.Item value="radioGroup_1_radio_1" className="flex-grow bg-white">
<strong className="mb-1">radioGroup_1_radio_1</strong>
<p>Description #1</p>
</RadioArea.Item>
<RadioArea.Item value="radioGroup_1_radio_2" className="flex-grow bg-white">
<strong className="mb-1">radioGroup_1_radio_2</strong>
<p>Description #2</p>
</RadioArea.Item>
<RadioArea.Item value="radioGroup_1_radio_3" className="flex-grow bg-white">
<strong className="mb-1">radioGroup_1_radio_3</strong>
<p>Description #3</p>
</RadioArea.Item>
</RadioArea.Group>
<RadioArea.Group
onChange={(radioGroup_2: string) => setFormData({ ...formData, radioGroup_2 })}
className="flex space-x-4 max-w-screen-md"
name="radioGroup_2">
<RadioArea.Item value="radioGroup_2_radio_1" className="flex-grow bg-white">
<strong className="mb-1">radioGroup_1_radio_1</strong>
<p>Description #1</p>
</RadioArea.Item>
<RadioArea.Item value="radioGroup_2_radio_2" className="flex-grow bg-white" defaultChecked={true}>
<strong className="mb-1">radioGroup_1_radio_2</strong>
<p>Description #2</p>
</RadioArea.Item>
<RadioArea.Item value="radioGroup_2_radio_3" className="flex-grow bg-white">
<strong className="mb-1">radioGroup_1_radio_3</strong>
<p>Description #3</p>
</RadioArea.Item>
</RadioArea.Group>
<div>
<p className="text-lg">Disabled RadioAreaSelect</p>
<RadioArea.Select options={[]} className="max-w-screen-md" />
</div>
<div>
<p className="text-lg">RadioArea disabled with custom placeholder</p>
<RadioArea.Select
className="max-w-screen-md"
options={[]}
placeholder="Does the rabbit or the turtle win the race?"></RadioArea.Select>
</div>
<div>
<p className="text-lg">RadioArea with options</p>
<RadioArea.Select
className="max-w-screen-md"
name="turtleOrRabbitWinsTheRace"
onChange={(turtleOrRabbitWinsTheRace: string) =>
setFormData({ ...formData, turtleOrRabbitWinsTheRace })
}
options={selectOptions}
placeholder="Does the rabbit or the turtle win the race?"></RadioArea.Select>
</div>
<div>
<p className="text-lg">RadioArea with default selected (disabled for clarity)</p>
<RadioArea.Select
disabled={true}
className="max-w-screen-md"
value="turtle"
options={selectOptions}></RadioArea.Select>
</div>
</form>
<pre>{JSON.stringify(formData)}</pre>
</div>
</>
);
}

View File

@ -4,7 +4,7 @@ import prisma from "@lib/prisma";
import Modal from "@components/Modal";
import Shell from "@components/Shell";
import SettingsShell from "@components/Settings";
import Avatar from "@components/Avatar";
import Avatar from "@components/ui/Avatar";
import { getSession } from "@lib/auth";
import Select from "react-select";
import TimezoneSelect from "react-timezone-select";

View File

@ -11,11 +11,11 @@ import toArray from "dayjs/plugin/toArray";
import timezone from "dayjs/plugin/timezone";
import { createEvent } from "ics";
import { getEventName } from "@lib/event";
import Theme from "@components/Theme";
import { GetServerSidePropsContext } from "next";
import { asStringOrNull } from "../lib/asStringOrNull";
import useTheme from "@lib/hooks/useTheme";
import { asStringOrNull } from "@lib/asStringOrNull";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { EventType } from "@prisma/client";
dayjs.extend(utc);
dayjs.extend(toArray);
@ -26,8 +26,8 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
const { location, name } = router.query;
const [is24h, setIs24h] = useState(false);
const [date, setDate] = useState(dayjs.utc(router.query.date));
const { isReady } = Theme(props.user.theme);
const [date, setDate] = useState(dayjs.utc(asStringOrNull(router.query.date)));
const { isReady } = useTheme(props.profile.theme);
useEffect(() => {
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
@ -99,9 +99,9 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
<div className="mt-3">
<p className="text-sm text-neutral-600 dark:text-gray-300">
{props.eventType.requiresConfirmation
? `${
props.user.name || props.user.username
} still needs to confirm or reject the booking.`
? props.profile.name !== null
? `${props.profile.name} still needs to confirm or reject the booking.`
: "Your booking still needs to be confirmed or rejected."
: `We emailed you and the other attendees a calendar invitation with all the details.`}
</p>
</div>
@ -224,7 +224,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</div>
</div>
)}
{!isBrandingHidden(props.user) && (
{!props.hideBranding && (
<div className="mt-4 pt-4 border-t dark:border-gray-900 text-gray-400 text-center text-xs dark:text-white">
<a href="https://checkout.calendso.com">Create your own booking link with Calendso</a>
</div>
@ -239,35 +239,14 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const username = asStringOrNull(context.query.user);
export async function getServerSideProps(context) {
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
if (!username || isNaN(typeId)) {
if (isNaN(typeId)) {
return {
notFound: true,
};
}
const user = await prisma.user.findUnique({
where: {
username,
},
select: {
username: true,
name: true,
bio: true,
avatar: true,
hideBranding: true,
theme: true,
plan: true,
},
});
if (!user) {
return {
notFound: true,
};
}
const eventType = await prisma.eventType.findUnique({
const eventType: EventType = await prisma.eventType.findUnique({
where: {
id: typeId,
},
@ -278,17 +257,61 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
length: true,
eventName: true,
requiresConfirmation: true,
userId: true,
users: {
select: {
name: true,
hideBranding: true,
plan: true,
theme: true,
},
},
team: {
select: {
name: true,
hideBranding: true,
},
},
},
});
if (!eventType) {
return {
notFound: true,
};
}
if (!eventType.users.length && eventType.userId) {
eventType.users.push(
await prisma.user.findUnique({
where: {
id: eventType.userId,
},
select: {
theme: true,
hideBranding: true,
name: true,
plan: true,
},
})
);
}
if (!eventType.users.length) {
return {
notFound: true,
};
}
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
};
return {
props: {
user,
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
profile,
eventType,
},
};

View File

@ -1,49 +0,0 @@
import { GetServerSideProps } from "next";
import { HeadSeo } from "@components/seo/head-seo";
import Theme from "@components/Theme";
import { getTeam } from "@lib/teams/getTeam";
import Team from "@components/team/screens/Team";
export default function Page(props) {
const { isReady } = Theme();
return (
isReady && (
<div>
<HeadSeo title={props.team.name} description={props.team.name} />
<main className="mx-auto py-24 px-4">
<Team team={props.team} />
</main>
</div>
)
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const teamIdOrSlug = Array.isArray(context.query?.idOrSlug)
? context.query.idOrSlug.pop()
: context.query.idOrSlug;
const team = await getTeam(teamIdOrSlug);
if (!team) {
return {
notFound: true,
};
}
return {
props: {
team,
},
};
};
// Auxiliary methods
export function getRandomColorCode(): string {
let color = "#";
for (let idx = 0; idx < 6; idx++) {
color += Math.floor(Math.random() * 10);
}
return color;
}

153
pages/team/[slug].tsx Normal file
View File

@ -0,0 +1,153 @@
import { InferGetServerSidePropsType } from "next";
import Link from "next/link";
import { HeadSeo } from "@components/seo/head-seo";
import useTheme from "@lib/hooks/useTheme";
import { ArrowRightIcon } from "@heroicons/react/solid";
import prisma from "@lib/prisma";
import Avatar from "@components/ui/Avatar";
import Text from "@components/ui/Text";
import React from "react";
import { defaultAvatarSrc } from "@lib/profile";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import Team from "@components/team/screens/Team";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import AvatarGroup from "@components/ui/AvatarGroup";
function TeamPage({ team }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { isReady } = useTheme();
const showMembers = useToggleQuery("members");
const eventTypes = (
<ul className="space-y-3">
{team.eventTypes.map((type) => (
<li
key={type.id}
className="group relative dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 bg-white hover:bg-gray-50 border border-neutral-200 hover:border-black rounded-sm">
<ArrowRightIcon className="absolute transition-opacity h-4 w-4 right-3 top-3 text-black dark:text-white opacity-0 group-hover:opacity-100" />
<Link href={`${team.slug}/${type.slug}`}>
<a className="block px-6 py-4 flex space-x-2 items-center">
<div className="flex-shrink">
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<AvatarGroup
truncateAfter={4}
className="flex-shrink-0"
size={10}
items={type.users.map((user) => ({
alt: user.name,
image: user.avatar,
}))}
/>
</a>
</Link>
</li>
))}
</ul>
);
return (
isReady && (
<div>
<HeadSeo title={team.name} description={team.name} />
<div className="pt-24 pb-12 px-4">
<div className="mb-8 text-center">
<Avatar
displayName={team.name}
imageSrc={team.logo}
className="mx-auto w-20 h-20 rounded-full mb-4"
/>
<Text variant="headline">{team.name}</Text>
</div>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length && (
<div className="mx-auto max-w-3xl">
{eventTypes}
<aside className="text-center dark:text-white mt-8">
<Link href={`/team/${team.slug}?members=1`} shallow={true}>
<a>
Book a team member <ArrowRightIcon className="h-6 w-6 inline text-neutral-500" />
</a>
</Link>
</aside>
</div>
)}
</div>
</div>
)
);
}
export const getServerSideProps = async (context) => {
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const teamSelectInput = {
id: true,
name: true,
slug: true,
logo: true,
members: {
select: {
user: {
select: {
username: true,
avatar: true,
name: true,
id: true,
bio: true,
},
},
},
},
eventTypes: {
where: {
hidden: false,
},
select: {
id: true,
title: true,
description: true,
length: true,
slug: true,
schedulingType: true,
users: {
select: {
id: true,
name: true,
avatar: true,
email: true,
},
},
},
},
};
const team = await prisma.team.findUnique({
where: {
slug,
},
select: teamSelectInput,
});
if (!team) {
return {
notFound: true,
};
}
team.eventTypes = team.eventTypes.map((type) => ({
...type,
users: type.users.map((user) => ({
...user,
avatar: user.avatar || defaultAvatarSrc({ email: user.email }),
})),
}));
return {
props: {
team,
},
};
};
export default TeamPage;

View File

@ -0,0 +1,90 @@
import { Availability, EventType } from "@prisma/client";
import prisma from "@lib/prisma";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { asStringOrNull } from "@lib/asStringOrNull";
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
export default function TeamType(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
return <AvailabilityPage {...props} />;
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
// get query params and typecast them to string
// (would be even better to assert them instead of typecasting)
const slugParam = asStringOrNull(context.query.slug);
const typeParam = asStringOrNull(context.query.type);
if (!slugParam || !typeParam) {
throw new Error(`File is not named [idOrSlug]/[user]`);
}
const team = await prisma.team.findFirst({
where: {
slug: slugParam,
},
select: {
id: true,
name: true,
slug: true,
logo: true,
eventTypes: {
where: {
slug: typeParam,
},
select: {
id: true,
users: {
select: {
id: true,
name: true,
avatar: true,
username: true,
timeZone: true,
},
},
title: true,
availability: true,
description: true,
length: true,
schedulingType: true,
},
},
},
});
if (!team || team.eventTypes.length != 1) {
return {
notFound: true,
} as const;
}
const profile = {
name: team.name,
slug: team.slug,
image: team.logo || null,
};
const eventType: EventType = team.eventTypes[0];
const getWorkingHours = (providesAvailability: { availability: Availability[] }) =>
providesAvailability.availability && providesAvailability.availability.length
? providesAvailability.availability
: null;
const workingHours = getWorkingHours(eventType) || [];
workingHours.sort((a, b) => a.startTime - b.startTime);
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
return {
props: {
profile,
team,
eventType: eventTypeObject,
workingHours,
},
};
};

View File

@ -0,0 +1,90 @@
import prisma from "@lib/prisma";
import { EventType } from "@prisma/client";
import "react-phone-number-input/style.css";
import BookingPage from "@components/booking/pages/BookingPage";
import { InferGetServerSidePropsType } from "next";
export default function TeamBookingPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
return <BookingPage {...props} />;
}
export async function getServerSideProps(context) {
const eventTypeId = parseInt(context.query.type);
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
return {
notFound: true,
} as const;
}
const eventType: EventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
locations: true,
customInputs: true,
periodType: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
team: {
select: {
slug: true,
name: true,
logo: true,
},
},
users: {
select: {
avatar: true,
name: true,
},
},
},
});
const eventTypeObject = [eventType].map((e) => {
return {
...e,
periodStartDate: e.periodStartDate?.toString() ?? null,
periodEndDate: e.periodEndDate?.toString() ?? null,
};
})[0];
let booking = null;
if (context.query.rescheduleUid) {
booking = await prisma.booking.findFirst({
where: {
uid: context.query.rescheduleUid,
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
}
return {
props: {
profile: {
...eventTypeObject.team,
slug: "team/" + eventTypeObject.slug,
image: eventTypeObject.team.logo,
},
eventType: eventTypeObject,
booking,
},
};
}

View File

@ -0,0 +1,30 @@
-- CreateEnum
CREATE TYPE "SchedulingType" AS ENUM ('roundRobin', 'collective');
-- DropForeignKey
ALTER TABLE "EventType" DROP CONSTRAINT "EventType_userId_fkey";
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "schedulingType" "SchedulingType",
ADD COLUMN "teamId" INTEGER;
-- CreateTable
CREATE TABLE "_user_eventtype" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_user_eventtype_AB_unique" ON "_user_eventtype"("A", "B");
-- CreateIndex
CREATE INDEX "_user_eventtype_B_index" ON "_user_eventtype"("B");
-- AddForeignKey
ALTER TABLE "EventType" ADD FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_user_eventtype" ADD FOREIGN KEY ("A") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_user_eventtype" ADD FOREIGN KEY ("B") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "EventType.userId_slug_unique";

View File

@ -10,6 +10,11 @@ generator client {
provider = "prisma-client-js"
}
enum SchedulingType {
ROUND_ROBIN @map("roundRobin")
COLLECTIVE @map("collective")
}
model EventType {
id Int @id @default(autoincrement())
title String
@ -18,8 +23,10 @@ model EventType {
locations Json?
length Int
hidden Boolean @default(false)
user User? @relation(fields: [userId], references: [id])
users User[] @relation("user_eventtype")
userId Int?
team Team? @relation(fields: [teamId], references: [id])
teamId Int?
bookings Booking[]
availability Availability[]
eventName String?
@ -32,9 +39,8 @@ model EventType {
periodCountCalendarDays Boolean?
requiresConfirmation Boolean @default(false)
minimumBookingNotice Int @default(120)
schedulingType SchedulingType?
Schedule Schedule[]
@@unique([userId, slug])
}
model Credential {
@ -68,7 +74,7 @@ model User {
hideBranding Boolean @default(false)
theme String?
createdDate DateTime @default(now()) @map(name: "created")
eventTypes EventType[]
eventTypes EventType[] @relation("user_eventtype")
credentials Credential[]
teams Membership[]
bookings Booking[]
@ -79,17 +85,19 @@ model User {
plan UserPlan @default(PRO)
Schedule Schedule[]
@@map(name: "users")
}
model Team {
id Int @default(autoincrement()) @id
id Int @id @default(autoincrement())
name String?
slug String? @unique
slug String? @unique
logo String?
bio String?
hideBranding Boolean @default(false)
hideBranding Boolean @default(false)
members Membership[]
eventTypes EventType[]
}
enum MembershipRole {

View File

@ -1,9 +1,59 @@
import { hashPassword } from "../lib/auth";
import { Prisma, PrismaClient } from "@prisma/client";
import { Prisma, PrismaClient, UserPlan } from "@prisma/client";
import dayjs from "dayjs";
import { uuid } from "short-uuid";
const prisma = new PrismaClient();
async function createBookingForEventType(opts: {
uid: string;
title: string;
slug: string;
startTime: Date | string;
endTime: Date | string;
userEmail: string;
}) {
const eventType = await prisma.eventType.findFirst({
where: {
slug: opts.slug,
},
});
if (!eventType) {
// should not happen
throw new Error("Eventtype missing");
}
const bookingData: Prisma.BookingCreateArgs["data"] = {
uid: opts.uid,
title: opts.title,
startTime: opts.startTime,
endTime: opts.endTime,
user: {
connect: {
email: opts.userEmail,
},
},
attendees: {
create: {
email: opts.userEmail,
name: "Some name",
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id: eventType.id,
},
},
};
await prisma.booking.create({
data: bookingData,
});
}
async function createUserAndEventType(opts: {
user: Omit<Prisma.UserCreateArgs["data"], "password" | "email"> & { password: string; email: string };
user: { email: string; password: string; username: string; plan: UserPlan };
eventTypes: Array<Prisma.EventTypeCreateArgs["data"]>;
}) {
const userData: Prisma.UserCreateArgs["data"] = {
@ -24,16 +74,34 @@ async function createUserAndEventType(opts: {
for (const rawData of opts.eventTypes) {
const eventTypeData: Prisma.EventTypeCreateArgs["data"] = { ...rawData };
eventTypeData.userId = user.id;
await prisma.eventType.upsert({
const eventType = await prisma.eventType.findFirst({
where: {
userId_slug: {
slug: eventTypeData.slug,
userId: user.id,
slug: eventTypeData.slug,
users: {
some: {
id: eventTypeData.userId,
},
},
},
update: eventTypeData,
create: eventTypeData,
select: {
id: true,
},
});
if (eventType) {
await prisma.eventType.update({
where: {
id: eventType.id,
},
data: eventTypeData,
});
} else {
await prisma.eventType.create({
data: eventTypeData,
});
}
console.log(
`\t📆 Event type ${eventTypeData.slug}, length ${eventTypeData.length}: http://localhost:3000/${user.username}/${eventTypeData.slug}`
);
@ -104,6 +172,16 @@ async function main() {
},
],
});
await createBookingForEventType({
title: "30min",
slug: "30min",
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(60, "minutes").toDate(),
uid: uuid(),
userEmail: "pro@example.com",
});
await createUserAndEventType({
user: {
email: "trial@example.com",

243
yarn.lock
View File

@ -177,9 +177,9 @@
js-tokens "^4.0.0"
"@babel/parser@^7.1.0", "@babel/parser@^7.15.4", "@babel/parser@^7.15.5", "@babel/parser@^7.7.2":
version "7.15.5"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.5.tgz#d33a58ca69facc05b26adfe4abebfed56c1c2dac"
integrity sha512-2hQstc6I7T6tQsWzlboMh3SgMRPaS4H6H7cPQsJkdzTzEGqQrpLDsE2BGASU5sBPoEQyHzeqU6C8uKbFeEk6sg==
version "7.15.6"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.6.tgz#043b9aa3c303c0722e5377fef9197f4cf1796549"
integrity sha512-S/TSCcsRuCkmpUuoWijua0Snt+f3ewU/8spLo+4AXJCZfT0bVCzLD5MuOKdrx0mlAptbKzn5AdgEIIKXxXkz9Q==
"@babel/plugin-syntax-async-generators@^7.8.4":
version "7.8.4"
@ -326,9 +326,9 @@
to-fast-properties "^2.0.0"
"@babel/types@^7.0.0", "@babel/types@^7.15.4", "@babel/types@^7.3.0", "@babel/types@^7.3.3":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.4.tgz#74eeb86dbd6748d2741396557b9860e57fce0a0d"
integrity sha512-0f1HJFuGmmbrKTCZtbm3cU+b/AqdEYk5toj5iQur58xkVMlS0JWaKxTBSmCXd47uiN7vbcozAupm6Mvs80GNhw==
version "7.15.6"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.6.tgz#99abdc48218b2881c058dd0a7ab05b99c9be758f"
integrity sha512-BPU+7QhqNjmWyDO0/vitH/CuhpV8ZmK1wpKva8nuyNF5MJfuRNWMc+hc14+u9xT93kvykMdncrJT19h74uB1Ig==
dependencies:
"@babel/helper-validator-identifier" "^7.14.9"
to-fast-properties "^2.0.0"
@ -835,13 +835,6 @@
"@babel/runtime" "^7.13.10"
csstype "^3.0.4"
"@radix-ui/primitive@0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-0.0.5.tgz#8464fb4db04401bde72d36e27e05714080668d40"
integrity sha512-VeL6A5LpKYRJhDDj5tCTnzP3zm+FnvybsAkgBHQ4LUPPBnqRdWLoyKpZhlwFze/z22QHINaTIcE9Z/fTcrUR1g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-0.1.0.tgz#6206b97d379994f0d1929809db035733b337e543"
@ -901,13 +894,6 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz#cff6e780a0f73778b976acff2c2a5b6551caab95"
integrity sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-context@0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-0.0.5.tgz#7c15f46795d7765dabfaf6f9c53791ad28c521c2"
@ -915,61 +901,73 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-context@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-0.1.0.tgz#670a7a2a63f8380a7cb5ff0bce87d51bdb065c5c"
integrity sha512-o8h7SP6ePEBLC33BsHiuFqW898c+wiyBiY2ZC2xFJUUnHj1Z6XrQdZCNjm3/VuhljMkPrIA5xC4swVWBo/gzOA==
"@radix-ui/react-dialog@^0.0.20":
version "0.0.20"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.0.20.tgz#b26607bea68fc20067d06fab996bac7f1acf68c1"
integrity sha512-fXgWxWyvmNiimxrFGdvUNve0tyQEFyPwrNgkSi6Xiha9cX8sqWdiYWq500zhzUQQFJVS7No73ylx8kgrI7SoLw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dialog@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.1.0.tgz#1e0471e03abc9012f2a2dc1644f7e844ccf44c94"
integrity sha512-yy833v6mSkqlhdDR7R0+sZJZd5OyEzsjeJfAuJoWRMSW2/2S78vTUgk1sRTXzT+6unoQOQ9teevURNjwAfX0Ug==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.1.0"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-context" "0.1.0"
"@radix-ui/react-dismissable-layer" "0.1.0"
"@radix-ui/react-focus-guards" "0.1.0"
"@radix-ui/react-focus-scope" "0.1.0"
"@radix-ui/react-id" "0.1.0"
"@radix-ui/react-portal" "0.1.0"
"@radix-ui/react-presence" "0.1.0"
"@radix-ui/react-primitive" "0.1.0"
"@radix-ui/react-use-controllable-state" "0.1.0"
"@radix-ui/primitive" "0.0.5"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-context" "0.0.5"
"@radix-ui/react-dismissable-layer" "0.0.15"
"@radix-ui/react-focus-guards" "0.0.7"
"@radix-ui/react-focus-scope" "0.0.15"
"@radix-ui/react-id" "0.0.6"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-portal" "0.0.15"
"@radix-ui/react-presence" "0.0.15"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-slot" "0.0.12"
"@radix-ui/react-use-controllable-state" "0.0.6"
aria-hidden "^1.1.1"
react-remove-scroll "^2.4.0"
"@radix-ui/react-dismissable-layer@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.0.tgz#ab2ec7490a56f7b46afa8dea08d109b9e4643c3b"
integrity sha512-xQSXEP7rHkAe0sY1Ggd9CS0IuYXhjks0e+mtPu6LgJBXhlOTDVj4MeJC8fKAP+H1sKMygcrEEagb6M5GXEDvzg==
"@radix-ui/react-dismissable-layer@0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.0.15.tgz#02c0e68684d60933c82b5af6793c87a5f9ee0750"
integrity sha512-2zABi8rh/t6liFfRLBw6h+B7MNNFxVQrgYfWRMs1elNX41z3G2vLoBlWdqGzAlYrtqEr/6CL4pQfhwVtd7rNGw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.1.0"
"@radix-ui/react-primitive" "0.1.0"
"@radix-ui/react-use-body-pointer-events" "0.1.0"
"@radix-ui/react-use-callback-ref" "0.1.0"
"@radix-ui/react-use-escape-keydown" "0.1.0"
"@radix-ui/primitive" "0.0.5"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-use-body-pointer-events" "0.0.7"
"@radix-ui/react-use-callback-ref" "0.0.5"
"@radix-ui/react-use-escape-keydown" "0.0.6"
"@radix-ui/react-focus-guards@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz#ba3b6f902cba7826569f8edc21ff8223dece7def"
integrity sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==
"@radix-ui/react-dropdown-menu@^0.0.23":
version "0.0.23"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-0.0.23.tgz#cd171b96750b4f26e3a481a8ecfb622b797d1e1f"
integrity sha512-zb/eavkvQpRYXfInh20q84b/1zEitlJlbdIHqVCNxMhhRDxa4wCyhLUlx400jR0s6Hl7EmU6WaNY4VYfdskrUQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.0.5"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-context" "0.0.5"
"@radix-ui/react-id" "0.0.6"
"@radix-ui/react-menu" "0.0.22"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-use-controllable-state" "0.0.6"
"@radix-ui/react-focus-guards@0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-0.0.7.tgz#285ed081c877587acd4ee7e6d8260bdf9044e922"
integrity sha512-enAsmrUunptHVzPLTuZqwTP/X3WFBnyJ/jP9W+0g+bRvI3o7V1kxNc+T2Rp1eRTFBW+lUNnt08qkugPytyTRog==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-focus-scope@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.0.tgz#24cb6433b4b5c733cdadc34cf36f9cd01ab9beb1"
integrity sha512-lquiYfEnkpqLDR9oO/h78OAY73jedZHVlBHJi2RZeSg3YM1UyyyGx+adZD+VWNphA/oEQG/RE5b7DteF4hhG8Q==
"@radix-ui/react-focus-scope@0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-0.0.15.tgz#60917075e53ee72d2a473fba88eb31e7aaf7d841"
integrity sha512-zNgEe1lyLPfxa003VD8lCXaadGqCYhboA3X1WDNGes74lzJgLOPJgzLI0F/ksSokkx/yDDdReyOWui3/LCTqTw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-primitive" "0.1.0"
"@radix-ui/react-use-callback-ref" "0.1.0"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-use-callback-ref" "0.0.5"
"@radix-ui/react-id@0.0.6":
version "0.0.6"
@ -978,13 +976,6 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-id@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-0.1.0.tgz#d01067520fb8f4b09da3f914bfe6cb0f88c26721"
integrity sha512-SubMSz7rAtl6w8qZ9YBRbDe9GjW36JugBsc6aYqng8tFydvNtkuBMj86zN/x5QiomMo+r8ylBVvuWzRkS0WbBA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-label@0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-label/-/react-label-0.0.15.tgz#ab70d7cd93d6ebaf2e1007cca70e9b1858bcb932"
@ -996,6 +987,32 @@
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-menu@0.0.22":
version "0.0.22"
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-0.0.22.tgz#5414b9be618a6f82bfeea80bac522854cfdd94f3"
integrity sha512-+aejYCzIKMbzk0MZYis0xXEpeLvAIf2/cpAgyzw7Fh+vEzRA4g4eKLLY/1yAxvyModnXBygaNKDQx1V0OlTIng==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.0.5"
"@radix-ui/react-collection" "0.0.15"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-context" "0.0.5"
"@radix-ui/react-dismissable-layer" "0.0.15"
"@radix-ui/react-focus-guards" "0.0.7"
"@radix-ui/react-focus-scope" "0.0.15"
"@radix-ui/react-id" "0.0.6"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-popper" "0.0.18"
"@radix-ui/react-portal" "0.0.15"
"@radix-ui/react-presence" "0.0.15"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-roving-focus" "0.0.16"
"@radix-ui/react-slot" "0.0.12"
"@radix-ui/react-use-callback-ref" "0.0.5"
"@radix-ui/react-use-direction" "0.0.1"
aria-hidden "^1.1.1"
react-remove-scroll "^2.4.0"
"@radix-ui/react-polymorphic@0.0.13":
version "0.0.13"
resolved "https://registry.yarnpkg.com/@radix-ui/react-polymorphic/-/react-polymorphic-0.0.13.tgz#d010d48281626191c9513f11db5d82b37662418a"
@ -1016,15 +1033,6 @@
"@radix-ui/react-use-size" "0.1.0"
"@radix-ui/rect" "0.1.0"
"@radix-ui/react-portal@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-0.1.0.tgz#5f72fa2f9837df9a5e27ca9ff7a63393ff8e1f0b"
integrity sha512-HiSDaQVlhoZWvp5Wy0JPPojqo31Z3efs890oyYkpKgRDWDdMYHzEWYZxC7pB60a6c6yM5JzjJc0bP7o6bye+/Q==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "0.1.0"
"@radix-ui/react-use-layout-effect" "0.1.0"
"@radix-ui/react-presence@0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-0.0.15.tgz#4ff12feb436f1499148feb11c3a63a5d8fab568a"
@ -1034,15 +1042,6 @@
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-use-layout-effect" "0.0.5"
"@radix-ui/react-presence@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-0.1.0.tgz#e7931009cbaa383f17be7d9863da9f0424efae7b"
integrity sha512-MIj5dywsSB1mWi7f9EqsxNoR5hfIScquYANbMdRmzxqNQzq2UrO8GEhOMXPo99YssdfpK9d0Pc9nLNwsFyq5Eg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-use-layout-effect" "0.1.0"
"@radix-ui/react-primitive@0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-0.0.15.tgz#c0cf609ee565a32969d20943e2697b42a04fbdf3"
@ -1051,13 +1050,21 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-0.1.0.tgz#4e6fb04ede36845cf3a061311a4f879c2051c1c5"
integrity sha512-ydO24k5Cvry5RCMfm5OJNnIwvxSIUuUZ3Kf6bu1GaSsDfBKiv5JeuQkipURW28KlX7I85Jr/I02JlE+Ec4HmWA==
"@radix-ui/react-roving-focus@0.0.16":
version "0.0.16"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-0.0.16.tgz#79c7ee71cf9a3c7d55eefa562189c8de80252066"
integrity sha512-9kYHWfxMM7RreNiT8kxS/ivv077Nc9N3od8slJpBvfNuybLxLlHB0QdWbwaceM6hBm2MmRdfL5VlUndDRE9S7g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "0.1.0"
"@radix-ui/primitive" "0.0.5"
"@radix-ui/react-collection" "0.0.15"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-context" "0.0.5"
"@radix-ui/react-id" "0.0.6"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-use-callback-ref" "0.0.5"
"@radix-ui/react-use-controllable-state" "0.0.6"
"@radix-ui/react-slider@^0.0.17":
version "0.0.17"
@ -1086,14 +1093,6 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-slot@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-0.1.0.tgz#56965f2af80576f9e3fcdbba839ef7fccbd3b577"
integrity sha512-ZuvAUhSK9EAE42b3+K7k7w4nF1uF+Wd4bFj2OCE1aSiW3tgiu7ZU+J61m2+RIDps0WLu95PUx6eZrnpfqBXFRg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-switch@^0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-0.0.15.tgz#675e0abd509ac211f6c9193fab786f17bd335de3"
@ -1132,13 +1131,13 @@
"@radix-ui/react-use-rect" "0.1.0"
"@radix-ui/react-visually-hidden" "0.1.0"
"@radix-ui/react-use-body-pointer-events@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.0.tgz#29b211464493f8ca5149ce34b96b95abbc97d741"
integrity sha512-svPyoHCcwOq/vpWNEvdH/yD91vN9p8BtiozNQbjVmJRxQ/vS12zqk70AxTGWe+2ZKHq2sggpEQNTv1JHyVFlnQ==
"@radix-ui/react-use-body-pointer-events@0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.0.7.tgz#e4249690ca0db85c969400e867476206feda4d1e"
integrity sha512-mXAGyb8mhVjRqtpKPeZePuvee40bgsWpt378oQrIcLU1uZNbNX9eyrIPnnL9OMLAvxqloAOClVj0PZ1bMQmfDw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "0.1.0"
"@radix-ui/react-use-layout-effect" "0.0.5"
"@radix-ui/react-use-callback-ref@0.0.5":
version "0.0.5"
@ -1147,13 +1146,6 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz#934b6e123330f5b3a6b116460e6662cbc663493f"
integrity sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-controllable-state@0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.0.6.tgz#c4b16bc911a25889333388a684a04df937e5fec7"
@ -1162,14 +1154,6 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "0.0.5"
"@radix-ui/react-use-controllable-state@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz#4fced164acfc69a4e34fb9d193afdab973a55de1"
integrity sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "0.1.0"
"@radix-ui/react-use-direction@0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-direction/-/react-use-direction-0.0.1.tgz#9ac72eb6d9902ed505c8a34048981d94f9433e14"
@ -1192,13 +1176,6 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz#ebf71bd6d2825de8f1fbb984abf2293823f0f223"
integrity sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-previous@0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-0.0.5.tgz#75191d1fa0ac24c560fe8cfbaa2f1174858cbb2f"
@ -1384,9 +1361,9 @@
integrity sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw==
"@types/node@*", "@types/node@^16.6.1":
version "16.9.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.0.tgz#d9512fe037472dcb58931ce19f837348db828a62"
integrity sha512-nmP+VR4oT0pJUPFbKE4SXj3Yb4Q/kz3M9dSAO1GGMebRKWHQxLfDNmU/yh3xxCJha3N60nQ/JwXWwOE/ZSEVag==
version "16.9.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==
"@types/node@^14.14.31":
version "14.17.15"
@ -3023,9 +3000,9 @@ efrt-unpack@2.2.0:
integrity sha512-9xUSSj7qcUxz+0r4X3+bwUNttEfGfK5AH+LVa1aTpqdAfrN5VhROYCfcF+up4hp5OL7IUKcZJJrzAGipQRDoiQ==
electron-to-chromium@^1.3.723, electron-to-chromium@^1.3.830:
version "1.3.833"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.833.tgz#e1394eb32ab8a9430ffd7d5adf632ce6c3b05e18"
integrity sha512-h+9aVaUHjyunLqtCjJF2jrJ73tYcJqo2cCGKtVAXH9WmnBsb8hiChRQ0P1uXjdxR6Wcfxibephy41c1YlZA/pA==
version "1.3.834"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.834.tgz#57a94f4a6529c926a8c332b184b291230a3f2140"
integrity sha512-9hnYJOlj2zbVn59Oy1R2mW/jntsRG7Gy56/aAOq8s29DzDYW/kOrq/ryJXGAQRRMg4MreHjI63XavGZTsnPubg==
elliptic@^6.5.3:
version "6.5.4"