Merge branch 'main' into bugfix/eventType-merge-artifacts

This commit is contained in:
Alex van Andel 2021-08-14 14:56:34 +00:00
commit 92d3f08f70
71 changed files with 3079 additions and 1983 deletions

View file

@ -229,16 +229,13 @@ Contributions are what make the open source community such an amazing place to b
2. On the upper right, click "Develop" => "Build App".
3. On "OAuth", select "Create".
4. Name your App.
5. Choose "Account-level app" as the app type.
5. Choose "User-managed app" as the app type.
6. De-select the option to publish the app on the Zoom App Marketplace.
7. Click "Create".
8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
9. Set the Redirect URL for OAuth `<CALENDSO URL>/api/integrations/zoomvideo/callback` replacing CALENDSO URL with the URI at which your application runs.
10. Also add the redirect URL given above as a whitelist URL and enable "Subdomain check". Make sure, it says "saved" below the form.
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". Search for and check the following scopes:
1. account:write:admin
2. meeting:write:admin
3. user:write:admin
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
12. Click "Done".
13. You're good to go. Now you can easily add your Zoom integration in the Calendso settings.

View file

@ -8,7 +8,7 @@ info:
license:
name: MIT License
url: https://opensource.org/licenses/MIT
version: 0.1.0
version: 1.0.0
server:
url: http://localhost:{port}
description: Local Development Server
@ -28,6 +28,8 @@ tags:
description: Manage integrations
- name: User
description: Manage the user's profile and settings
- name: Teams
description: Manage teams
paths:
/api/auth/signin:
get:
@ -58,6 +60,12 @@ paths:
summary: Handles signing out
tags:
- Authentication
/api/auth/signup:
post:
description: Creates a new user from an invitation.
summary: Create a new user
tags:
- Authentication
/api/auth/session:
get:
description: Returns client-safe session object - or an empty object if there is no session. The contents of the session object that is returned are configurable with the session callback.
@ -82,12 +90,40 @@ paths:
summary: Changes the password for the currently logged in account
tags:
- Authentication
/api/auth/forgot-password:
post:
description: Send a password reset email.
summary: Send a password reset email
tags:
- Authentication
/api/auth/reset-password:
post:
description: Reset a user's password with their password reset token.
summary: Reset a user's password
tags:
- Authentication
/api/availability/:user:
get:
description: Gets the busy times for a particular user, by username.
summary: Gets the busy times for a user
tags:
- Availability
/api/availability/calendar:
get:
description: Gets the user's selected calendars.
summary: Gets the user's selected calendars
tags:
- Availability
post:
description: Adds a selected calendar for the user.
summary: Adds a selected calendar for the user
tags:
- Availability
delete:
description: Removes a selected calendar for the user.
summary: Removes a selected calendar for the user
tags:
- Availability
/api/availability/day:
patch:
description: Updates the start and end times for a user's availability.
@ -127,6 +163,12 @@ paths:
summary: Creates a booking for a user
tags:
- Booking
/api/book/confirm:
post:
description: Accepts an opt-in booking.
summary: Accepts an opt-in booking
tags:
- Booking
/api/integrations:
get:
description: Gets a list of the user's integrations.
@ -150,9 +192,72 @@ paths:
summary: Gets and stores the OAuth token
tags:
- Integrations
/api/integrations/office365calendar/add:
get:
description: Gets the OAuth URL for a Microsoft 365/Outlook integration.
summary: Gets the OAuth URL
tags:
- Integrations
/api/integrations/office365calendar/callback:
post:
description: Gets and stores the OAuth token for a Microsoft 365/Outlook integration.
summary: Gets and stores the OAuth token
tags:
- Integrations
/api/integrations/zoomvideo/add:
get:
description: Gets the OAuth URL for a Zoom integration.
summary: Gets the OAuth URL
tags:
- Integrations
/api/integrations/zoomvideo/callback:
post:
description: Gets and stores the OAuth token for a Zoom integration.
summary: Gets and stores the OAuth token
tags:
- Integrations
/api/user/profile:
patch:
description: Updates a user's profile.
summary: Updates a user's profile
tags:
- User
/api/user/membership:
get:
description: Get a list of the teams the user has joined.
summary: Get the teams a user is joined to
tags:
- User
patch:
description: Accept team invitation
summary: Accept team invitation.
tags:
- User
delete:
description: Leave team or decline membership invite of current user
summary: Leave team or decline team invite.
tags:
- User
/api/:team:
delete:
description: Deletes a team
summary: Deletes a team
tags:
- Teams
/api/:team/invite:
post:
description: Invites someone to a team.
summary: Invites someone to a team
tags:
- Teams
/api/:team/membership:
get:
description: Lists the members of a team.
summary: Lists members of a team
tags:
- Teams
delete:
description: Cancels a membership (invite) to a team
summary: Cancels a membership
tags:
- Teams

View file

@ -13,12 +13,25 @@ export function Dialog({ children, ...props }) {
export const DialogContent = React.forwardRef(({ children, ...props }, forwardedRef) => (
<DialogPrimitive.Content
{...props}
className="fixed bg-white rounded top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-left overflow-hidden shadow-xl sm:align-middle sm:max-w-lg sm:w-full p-6"
className="fixed bg-white min-w-[360px] rounded top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-left overflow-hidden shadow-xl sm:align-middle sm:max-w-lg sm:w-full p-6"
ref={forwardedRef}>
{children}
</DialogPrimitive.Content>
));
export function DialogHeader({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div className="mb-8">
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
{title}
</h3>
<div>
<p className="text-sm text-gray-400">{subtitle}</p>
</div>
</div>
);
}
DialogContent.displayName = "DialogContent";
export const DialogTrigger = DialogPrimitive.Trigger;

View file

@ -1,9 +1,8 @@
import { GiftIcon } from "@heroicons/react/outline";
export default function DonateBanner() {
if (location.hostname.endsWith(".calendso.com")) {
return null;
}
if (location.hostname.endsWith(".calendso.com")) {
return null;
}
return (
<>
@ -17,21 +16,19 @@ return null;
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
</span>
<p className="ml-3 font-medium text-white truncate">
<span className="md:hidden">
Support the ongoing development
</span>
<span className="md:hidden">Support the ongoing development</span>
<span className="hidden md:inline">
You&apos;re using the free self-hosted version. Support the
ongoing development by making a donation.
You&apos;re using the free self-hosted version. Support the ongoing development by making
a donation.
</span>
</p>
</div>
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
<a
target="_blank"
rel="noreferrer"
href="https://calendso.com/donate"
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50"
>
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50">
Donate
</a>
</div>

View file

@ -1,3 +1,7 @@
export default function Loader(){
return <div className="loader"><span className="loader-inner"></span></div>
export default function Loader() {
return (
<div className="loader border-black dark:border-white">
<span className="loader-inner bg-black dark:bg-white"></span>
</div>
);
}

View file

@ -1,7 +1,14 @@
export default function Logo({small} : {small?: boolean}){
return <h1 className="brand-logo inline">
export default function Logo({ small }: { small?: boolean }) {
return (
<h1 className="brand-logo inline">
<strong>
<img className={small ? "h-4 w-auto" : "h-5 w-auto"} alt="Calendso" title="Calendso" src="/calendso-logo-white-word.svg" />
<img
className={small ? "h-4 w-auto" : "h-5 w-auto"}
alt="Calendso"
title="Calendso"
src="/calendso-logo-white-word.svg"
/>
</strong>
</h1>;
</h1>
);
}

View file

@ -1,11 +1,16 @@
import { Fragment } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { CheckIcon } from '@heroicons/react/outline'
import { Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { CheckIcon } from "@heroicons/react/outline";
export default function Modal(props) {
return (
<Transition.Root show={props.open} as={Fragment}>
<Dialog as="div" static className="fixed z-50 inset-0 overflow-y-auto" open={props.open} onClose={props.handleClose}>
<Dialog
as="div"
static
className="fixed z-50 inset-0 overflow-y-auto"
open={props.open}
onClose={props.handleClose}>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
@ -14,8 +19,7 @@ export default function Modal(props) {
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
leaveTo="opacity-0">
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
</Transition.Child>
@ -30,8 +34,7 @@ export default function Modal(props) {
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
@ -42,18 +45,12 @@ export default function Modal(props) {
{props.heading}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
{props.description}
</p>
<p className="text-sm text-gray-500">{props.description}</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<button
type="button"
className="btn-wide btn-primary"
onClick={() => props.handleClose()}
>
<button type="button" className="btn-wide btn-primary" onClick={() => props.handleClose()}>
Dismiss
</button>
</div>
@ -62,5 +59,5 @@ export default function Modal(props) {
</div>
</Dialog>
</Transition.Root>
)
);
}

View file

@ -1,5 +1,5 @@
import Link from "next/link";
import { CreditCardIcon, UserIcon, CodeIcon, KeyIcon, UserGroupIcon } from "@heroicons/react/solid";
import { CodeIcon, CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import classNames from "@lib/classNames";
@ -35,7 +35,7 @@ export default function SettingsShell(props) {
];
return (
<div className="max-w-6xl">
<div>
<div className="sm:mx-auto">
<nav className="-mb-px flex space-x-2 sm:space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
@ -60,8 +60,9 @@ export default function SettingsShell(props) {
</Link>
))}
</nav>
<hr />
</div>
<main>{props.children}</main>
<main className="max-w-4xl">{props.children}</main>
</div>
);
}

View file

@ -7,19 +7,20 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib
import { SelectorIcon } from "@heroicons/react/outline";
import {
CalendarIcon,
ClockIcon,
PuzzleIcon,
CogIcon,
ChatAltIcon,
LogoutIcon,
ClockIcon,
CogIcon,
ExternalLinkIcon,
LinkIcon,
LogoutIcon,
PuzzleIcon,
} from "@heroicons/react/solid";
import Logo from "./Logo";
import classNames from "@lib/classNames";
export default function Shell(props) {
const router = useRouter();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
const telemetry = useTelemetry();
@ -43,7 +44,7 @@ export default function Shell(props) {
current: router.pathname.startsWith("/availability"),
},
{
name: "Integrations",
name: "App Store",
href: "/integrations",
icon: PuzzleIcon,
current: router.pathname.startsWith("/integrations"),
@ -71,7 +72,7 @@ export default function Shell(props) {
<div className="h-screen flex overflow-hidden bg-gray-100">
{/* Static sidebar for desktop */}
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col w-56">
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
@ -105,7 +106,7 @@ export default function Shell(props) {
))}
</nav>
</div>
<div className="flex-shrink-0 flex border-t border-gray-200 p-4">
<div className="flex-shrink-0 flex p-4">
<UserDropdown session={session} />
</div>
</div>
@ -135,10 +136,10 @@ export default function Shell(props) {
</div>
</div>
</nav>
<div className="py-6">
<div className="py-8">
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">{props.heading}</h1>
<div className="mb-8">
<h1 className="text-xl font-bold text-gray-900">{props.heading}</h1>
<p className="text-sm text-neutral-500 mr-4">{props.subtitle}</p>
</div>
<div className="mb-4 flex-shrink-0">{props.CTA}</div>
@ -146,7 +147,7 @@ export default function Shell(props) {
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
{/* show bottom navigation for md and smaller (tablet and phones) */}
<nav className="md:hidden flex fixed bottom-0 bg-white w-full rounded-lg shadow">
<nav className="bottom-nav md:hidden flex fixed bottom-0 bg-white w-full shadow">
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
{navigation.flatMap((item, itemIdx) =>
item.name === "Settings" ? (

36
components/Tooltip.tsx Normal file
View file

@ -0,0 +1,36 @@
import React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Slot } from "@radix-ui/react-slot";
export function Tooltip({
children,
content,
open,
defaultOpen,
onOpenChange,
...props
}: {
[x: string]: any;
children: React.ReactNode;
content: React.ReactNode;
open: boolean;
defaultOpen: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<TooltipPrimitive.Root
delayDuration={150}
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}>
<TooltipPrimitive.Trigger as={Slot}>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Content
className="bg-black text-xs -mt-2 text-white px-1 py-0.5 shadow-lg rounded-sm"
side="top"
align="center"
{...props}>
{content}
</TooltipPrimitive.Content>
</TooltipPrimitive.Root>
);
}

View file

@ -2,6 +2,8 @@ import Link from "next/link";
import { useRouter } from "next/router";
import Slots from "./Slots";
import { ExclamationIcon } from "@heroicons/react/solid";
import React from "react";
import Loader from "@components/Loader";
const AvailableTimes = ({
date,
@ -26,8 +28,11 @@ const AvailableTimes = ({
return (
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
<div className="text-gray-600 font-light text-xl mb-4 text-left">
<span className="w-1/2 dark:text-white text-gray-600">{date.format("dddd DD MMMM YYYY")}</span>
<div className="text-gray-600 font-light text-lg mb-4 text-left">
<span className="w-1/2 dark:text-white text-gray-600">
<strong>{date.format("dddd")}</strong>
<span className="text-gray-500">{date.format(", DD MMMM")}</span>
</span>
</div>
{slots.length > 0 &&
slots.map((slot) => (
@ -37,7 +42,7 @@ const AvailableTimes = ({
`/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
}>
<a className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
<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>
@ -49,7 +54,7 @@ const AvailableTimes = ({
</div>
)}
{!isFullyBooked && slots.length === 0 && !hasErrors && <div className="loader" />}
{!isFullyBooked && slots.length === 0 && !hasErrors && <Loader />}
{hasErrors && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">

View file

@ -147,12 +147,14 @@ const DatePicker = ({
onClick={() => setSelectedDate(inviteeDate.date(day))}
disabled={isDisabled(day)}
className={
"text-center w-10 h-10 rounded-full mx-auto" +
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
"text-center w-14 h-14 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-blue-600 text-white-important"
? " bg-black text-white-important"
: !isDisabled(day)
? " bg-blue-50 dark:bg-gray-900 dark:bg-opacity-30"
? " bg-gray-100 dark:bg-gray-600"
: "")
}>
{day}
@ -164,38 +166,43 @@ const DatePicker = ({
return selectedMonth ? (
<div
className={
"mt-8 sm:mt-0 " +
(selectedDate ? "sm:w-1/3 sm:border-r sm:dark:border-gray-900 sm:px-4" : "sm:w-1/2 sm:pl-4")
"mt-8 sm:mt-0 sm:min-w-[455px] " +
(selectedDate
? "w-full sm:w-1/2 md:w-1/3 sm:border-r sm:dark:border-gray-800 sm:pl-4 sm:pr-6 "
: "sm:w-1/2 sm:pl-4")
}>
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
<div className="flex text-gray-600 font-light text-xl mb-4">
<span className="w-1/2 text-gray-600 dark:text-white">
{dayjs().month(selectedMonth).format("MMMM YYYY")}
<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>
</span>
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
<button
onClick={decrementMonth}
className={
"mr-4 " +
"group mr-2 p-1" +
(selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400 dark:text-gray-600")
}
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
<ChevronLeftIcon className="w-5 h-5" />
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
</button>
<button onClick={incrementMonth}>
<ChevronRightIcon className="w-5 h-5" />
<button className="group p-1" onClick={incrementMonth}>
<ChevronRightIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-y-4 text-center">
<div className="grid grid-cols-7 gap-4 text-center border-b border-t sm:border-0">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
.map((weekDay) => (
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
<div key={weekDay} className="uppercase text-gray-500 text-xs tracking-widest my-4">
{weekDay}
</div>
))}
{calendar}
</div>
<div className="grid grid-cols-7 gap-y-2 gap-x-4 text-center">{calendar}</div>
</div>
) : null;
};

View file

@ -25,7 +25,7 @@ const TimeOptions = (props) => {
return (
selectedTimeZone !== "" && (
<div className="w-full rounded shadow border dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
<div className="flex mb-4">
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
<div className="w-1/2">
@ -37,7 +37,7 @@ const TimeOptions = (props) => {
checked={is24hClock}
onChange={setIs24hClock}
className={classNames(
is24hClock ? "bg-blue-600" : "dark:bg-gray-600 bg-gray-200",
is24hClock ? "bg-black" : "dark:bg-gray-600 bg-gray-200",
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
)}>
<span className="sr-only">Use setting</span>

View file

@ -0,0 +1,42 @@
import { DialogClose, DialogContent } from "@components/Dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ExclamationIcon } from "@heroicons/react/outline";
import React from "react";
export default function ConfirmationDialogContent({
title,
alert,
confirmBtnText,
cancelBtnText,
onConfirm,
children,
}) {
confirmBtnText = confirmBtnText || "Confirm";
cancelBtnText = cancelBtnText || "Cancel";
return (
<DialogContent>
<div className="flex">
{alert && (
<div className="mr-3 mt-0.5">
{alert === "danger" && (
<div className="text-center p-2 rounded-full mx-auto bg-red-100">
<ExclamationIcon className="w-5 h-5 text-red-600" />
</div>
)}
</div>
)}
<div>
<DialogPrimitive.Title className="text-xl font-bold text-gray-900">{title}</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-neutral-500">{children}</DialogPrimitive.Description>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose onClick={onConfirm} className="btn btn-primary">
{confirmBtnText}
</DialogClose>
<DialogClose className="btn btn-white mx-2">{cancelBtnText}</DialogClose>
</div>
</DialogContent>
);
}

View file

@ -1,42 +1,52 @@
import {useEffect, useState} from "react";
import {UsersIcon,UserRemoveIcon} from "@heroicons/react/outline";
import {useSession} from "next-auth/client";
import { useEffect, useState } from "react";
import { UserRemoveIcon, UsersIcon } from "@heroicons/react/outline";
import { useSession } from "next-auth/client";
export default function EditTeamModal(props) {
const [session] = useSession();
const [members, setMembers] = useState([]);
const [checkedDisbandTeam, setCheckedDisbandTeam] = useState(false);
const [ session, loading ] = useSession();
const [ members, setMembers ] = useState([]);
const [ checkedDisbandTeam, setCheckedDisbandTeam ] = useState(false);
const loadMembers = () =>
fetch("/api/teams/" + props.team.id + "/membership")
.then((res: any) => res.json())
.then((data) => setMembers(data.members));
const loadMembers = () => fetch('/api/teams/' + props.team.id + '/membership')
.then( (res: any) => res.json() ).then( (data) => setMembers(data.members) );
useEffect( () => {
useEffect(() => {
loadMembers();
}, []);
const deleteTeam = (e) => {
e.preventDefault();
return fetch('/api/teams/' + props.team.id, {
method: 'DELETE',
return fetch("/api/teams/" + props.team.id, {
method: "DELETE",
}).then(props.onExit);
}
};
const removeMember = (member) => {
return fetch('/api/teams/' + props.team.id + '/membership', {
method: 'DELETE',
return fetch("/api/teams/" + props.team.id + "/membership", {
method: "DELETE",
body: JSON.stringify({ userId: member.id }),
headers: {
'Content-Type': 'application/json'
}
"Content-Type": "application/json",
},
}).then(loadMembers);
}
};
return (<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
return (
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
@ -44,45 +54,56 @@ export default function EditTeamModal(props) {
<UsersIcon className="h-6 w-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit the {props.team.name} team</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Edit the {props.team.name} team
</h3>
<div>
<p className="text-sm text-gray-400">
Manage and delete your team.
</p>
<p className="text-sm text-gray-400">Manage and delete your team.</p>
</div>
</div>
</div>
<form>
<div>
<div className="mb-4">
{members.length > 0 && <div>
{members.length > 0 && (
<div>
<div className="flex justify-between mb-2">
<h2 className="text-lg font-medium text-gray-900">Members</h2>
</div>
<table className="table-auto mb-2 w-full text-sm">
<tbody>
{members.map( (member) => <tr key={member.email}>
<td className="p-1">{member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}</td>
{members.map((member) => (
<tr key={member.email}>
<td className="p-1">
{member.name} {member.name && "(" + member.email + ")"}
{!member.name && member.email}
</td>
<td className="capitalize">{member.role.toLowerCase()}</td>
<td className="text-right py-2 px-1">
{member.email !== session.user.email &&
{member.email !== session.user.email && (
<button
type="button"
onClick={(e) => removeMember(member)}
onClick={() => removeMember(member)}
className="btn-sm text-xs bg-transparent px-3 py-1 rounded ml-2">
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline" />
</button>
}
)}
</td>
</tr>)}
</tr>
))}
</tbody>
</table>
</div>}
</div>
)}
</div>
<div className="mb-4 border border-red-400 rounded p-2 px-4">
<p className="block text-sm font-medium text-gray-700">Tick the box to disband this team.</p>
<label className="mt-1">
<input type="checkbox" onChange={(e) => setCheckedDisbandTeam(e.target.checked)} className="shadow-sm mr-2 focus:ring-black focus:border-black sm:text-sm border-gray-300 rounded-md" />
<input
type="checkbox"
onChange={(e) => setCheckedDisbandTeam(e.target.checked)}
className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md"
/>
Disband this team
</label>
</div>
@ -91,9 +112,13 @@ export default function EditTeamModal(props) {
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
Update
</button>*/}
{checkedDisbandTeam && <button onClick={deleteTeam} className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
{checkedDisbandTeam && (
<button
onClick={deleteTeam}
className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
Disband Team
</button>}
</button>
)}
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
Close
</button>
@ -101,5 +126,6 @@ export default function EditTeamModal(props) {
</form>
</div>
</div>
</div>);
</div>
);
}

View file

@ -2,11 +2,9 @@ import { UsersIcon } from "@heroicons/react/outline";
import { useState } from "react";
export default function MemberInvitationModal(props) {
const [ errorMessage, setErrorMessage ] = useState('');
const [errorMessage, setErrorMessage] = useState("");
const handleError = async (res) => {
const responseData = await res.json();
if (res.ok === false) {
@ -18,22 +16,24 @@ export default function MemberInvitationModal(props) {
};
const inviteMember = (e) => {
e.preventDefault();
const payload = {
role: e.target.elements['role'].value,
usernameOrEmail: e.target.elements['inviteUser'].value,
sendEmailInvitation: e.target.elements['sendInviteEmail'].checked,
}
role: e.target.elements["role"].value,
usernameOrEmail: e.target.elements["inviteUser"].value,
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
};
return fetch('/api/teams/' + props.team.id + '/invite', {
method: 'POST',
return fetch("/api/teams/" + props.team.id + "/invite", {
method: "POST",
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
}
}).then(handleError).then(props.onExit).catch( (e) => {
"Content-Type": "application/json",
},
})
.then(handleError)
.then(props.onExit)
.catch(() => {
// do nothing.
});
};
@ -45,7 +45,9 @@ export default function MemberInvitationModal(props) {
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;

View file

@ -1,42 +1,49 @@
import {useEffect, useState} from "react";
import { useState } from "react";
import TeamListItem from "./TeamListItem";
import EditTeamModal from "./EditTeamModal";
import MemberInvitationModal from "./MemberInvitationModal";
export default function TeamList(props) {
const [ showMemberInvitationModal, setShowMemberInvitationModal ] = useState(false);
const [ showEditTeamModal, setShowEditTeamModal ] = useState(false);
const [ team, setTeam ] = useState(null);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
const [team, setTeam] = useState(null);
const selectAction = (action: string, team: any) => {
setTeam(team);
switch (action) {
case 'edit':
case "edit":
setShowEditTeamModal(true);
break;
case 'invite':
case "invite":
setShowMemberInvitationModal(true);
break;
}
};
return (<div>
return (
<div>
<ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200">
{props.teams.map(
(team: any) => <TeamListItem onChange={props.onChange} key={team.id} team={team} onActionSelect={
(action: string) => selectAction(action, team)
}></TeamListItem>
)}
{props.teams.map((team: any) => (
<TeamListItem
onChange={props.onChange}
key={team.id}
team={team}
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
))}
</ul>
{showEditTeamModal && <EditTeamModal team={team} onExit={() => {
{showEditTeamModal && (
<EditTeamModal
team={team}
onExit={() => {
props.onChange();
setShowEditTeamModal(false);
}}></EditTeamModal>}
{showMemberInvitationModal &&
}}></EditTeamModal>
)}
{showMemberInvitationModal && (
<MemberInvitationModal
team={team}
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
}
</div>);
)}
</div>
);
}

View file

@ -1,59 +1,87 @@
import {CogIcon, TrashIcon, UserAddIcon, UsersIcon} from "@heroicons/react/outline";
import { CogIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline";
import Dropdown from "../ui/Dropdown";
import {useState} from "react";
import { useState } from "react";
export default function TeamListItem(props) {
const [ team, setTeam ] = useState(props.team);
const [team, setTeam] = useState(props.team);
const acceptInvite = () => invitationResponse(true);
const declineInvite = () => invitationResponse(false);
const invitationResponse = (accept: boolean) => fetch('/api/user/membership', {
method: accept ? 'PATCH' : 'DELETE',
const invitationResponse = (accept: boolean) =>
fetch("/api/user/membership", {
method: accept ? "PATCH" : "DELETE",
body: JSON.stringify({ teamId: props.team.id }),
headers: {
'Content-Type': 'application/json'
}
}).then( () => {
"Content-Type": "application/json",
},
}).then(() => {
// success
setTeam(null);
props.onChange();
});
return (team && <li className="mb-2 mt-2 divide-y">
return (
team && (
<li className="mb-2 mt-2 divide-y">
<div className="flex justify-between mb-2 mt-2">
<div>
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline"/>
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline" />
<div className="inline-block -mt-1">
<span className="font-bold text-neutral-700 text-sm">{props.team.name}</span>
<span className="text-xs text-gray-400 -mt-1 block capitalize">{props.team.role.toLowerCase()}</span>
<span className="text-xs text-gray-400 -mt-1 block capitalize">
{props.team.role.toLowerCase()}
</span>
</div>
</div>
{props.team.role === 'INVITEE' && <div>
<button className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2" onClick={acceptInvite}>Accept invitation</button>
{props.team.role === "INVITEE" && (
<div>
<button
className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2"
onClick={acceptInvite}>
Accept invitation
</button>
<button className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" onClick={declineInvite} />
</button>
</div>}
{props.team.role === 'MEMBER' && <div>
<button onClick={declineInvite} className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm ml-2">Leave</button>
</div>}
{props.team.role === 'OWNER' && <div>
</div>
)}
{props.team.role === "MEMBER" && (
<div>
<button
onClick={declineInvite}
className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm ml-2">
Leave
</button>
</div>
)}
{props.team.role === "OWNER" && (
<div>
<Dropdown className="relative inline-block text-left">
<button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded-sm ml-2">
<CogIcon className="h-6 w-6 inline text-gray-400" />
</button>
<ul role="menu" className="z-10 origin-top-right absolute right-0 w-36 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
<button className="block px-4 py-2" onClick={() => props.onActionSelect('invite')}>Invite members</button>
<ul
role="menu"
className="z-10 origin-top-right absolute right-0 w-36 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<li
className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
role="menuitem">
<button className="block px-4 py-2" onClick={() => props.onActionSelect("invite")}>
Invite members
</button>
</li>
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
<button className="block px-4 py-2" onClick={() => props.onActionSelect('edit')}>Manage team</button>
<li
className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
role="menuitem">
<button className="block px-4 py-2" onClick={() => props.onActionSelect("edit")}>
Manage team
</button>
</li>
</ul>
</Dropdown>
</div>}
</div>
)}
</div>
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
{props.team.members.length > 0 && <div>
@ -73,5 +101,7 @@ export default function TeamListItem(props) {
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm"><UserAddIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 h-6 w-6 inline"/> Invite member</button>
<button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2">Disband</button>
</div>}*/}
</li>);
</li>
)
);
}

View file

@ -1,15 +1,26 @@
import { useState } from 'react';
export default function Button(props) {
return(
<button type="submit" className="btn btn-primary">
return (
<button type="submit" className="btn btn-primary dark:btn-white">
{!props.loading && props.children}
{props.loading &&
<svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
{props.loading && (
<svg
className="animate-spin mx-4 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
)}
</button>
);
}

View file

@ -1,7 +1,7 @@
import Link from "next/link";
const PoweredByCalendso = () => (
<div className="text-xs text-center sm:text-right pt-1">
<div className="text-xs text-center sm:text-right p-1">
<Link href={`https://calendso.com?utm_source=embed&utm_medium=powered-by-button`}>
<a target="_blank" className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
powered by{" "}

View file

@ -91,7 +91,7 @@ export const Scheduler = ({
type="button"
onClick={() => removeScheduleAt(idx)}
className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
<TrashIcon className="h-5 w-5 inline text-gray-400 -mt-1" />
</button>
</li>
);

41
components/ui/Switch.tsx Normal file
View file

@ -0,0 +1,41 @@
import { useState } from "react";
import * as PrimitiveSwitch from "@radix-ui/react-switch";
import * as Label from "@radix-ui/react-label";
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
export default function Switch(props) {
const { label, onCheckedChange, ...primitiveProps } = props;
const [checked, setChecked] = useState(props.defaultChecked || false);
const onPrimitiveCheckedChange = (change: boolean) => {
if (onCheckedChange) {
onCheckedChange(change);
}
setChecked(change);
};
return (
<div className="flex items-center h-[20px]">
<PrimitiveSwitch.Root
className={classNames(checked ? "bg-gray-900" : "bg-gray-400", "rounded-sm w-[36px] p-0.5 h-[20px]")}
checked={checked}
onCheckedChange={onPrimitiveCheckedChange}
{...primitiveProps}>
<PrimitiveSwitch.Thumb
className={classNames(
"bg-white w-[16px] h-[16px] block transition-transform",
checked ? "translate-x-[16px]" : "translate-x-0"
)}
/>
</PrimitiveSwitch.Root>
{label && (
<Label.Root className="text-neutral-700 align-text-top ml-3 font-medium cursor-pointer">
{label}
</Label.Root>
)}
</div>
);
}

View file

@ -33,7 +33,9 @@ export default function SetTimesModal(props) {
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;

View file

@ -6,10 +6,12 @@ import { stripHtml } from "./emails/helpers";
const translator = short();
export default class CalEventParser {
calEvent: CalendarEvent;
protected calEvent: CalendarEvent;
protected maybeUid: string;
constructor(calEvent: CalendarEvent) {
constructor(calEvent: CalendarEvent, maybeUid: string = null) {
this.calEvent = calEvent;
this.maybeUid = maybeUid;
}
/**
@ -30,14 +32,14 @@ export default class CalEventParser {
* Returns a unique identifier for the given calendar event.
*/
public getUid(): string {
return translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
return this.maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
}
/**
* Returns a footer section with links to change the event (as HTML).
*/
public getChangeEventFooterHtml(): string {
return `<p style="color: #4b5563; margin-top: 20px;">Need to make a change? <a href="${this.getCancelLink()}" style="color: #161e2e;">Cancel</a> or <a href="${this.getRescheduleLink()}" style="color: #161e2e;">reschedule</a>.</p>`;
return `<p style="color: #4b5563; margin-top: 20px;">Need to make a change? <a href="${this.getCancelLink()}" style="color: #161e2e;">Cancel</a> or <a href="${this.getRescheduleLink()}" style="color: #161e2e;">reschedule</a></p>`;
}
/**

View file

@ -5,6 +5,10 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"
import prisma from "./prisma";
import { Credential } from "@prisma/client";
import CalEventParser from "./CalEventParser";
import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { google } = require("googleapis");
@ -206,7 +210,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'";
const filter = "?startdatetime=" + dateFrom + "&enddatetime=" + dateTo;
return auth
.getToken()
.then((accessToken) => {
@ -229,7 +233,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
headers: {
Prefer: 'outlook.timezone="Etc/GMT"',
},
url: `/me/calendars/${calendarId}/events${filter}`,
url: `/me/calendars/${calendarId}/calendarView${filter}`,
}));
return fetch("https://graph.microsoft.com/v1.0/$batch", {
@ -309,7 +313,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId);
@ -375,7 +382,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
payload["conferenceData"] = event.conferenceData;
}
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.insert(
{
auth: myGoogleAuth,
@ -418,7 +428,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
payload["location"] = event.location;
}
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.update(
{
auth: myGoogleAuth,
@ -441,7 +454,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
deleteEvent: (uid: string) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.delete(
{
auth: myGoogleAuth,
@ -463,7 +479,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
listCalendars: () =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList
.list()
.then((cals) => {
@ -515,8 +534,13 @@ const listCalendars = (withCredentials) =>
results.reduce((acc, calendars) => acc.concat(calendars), [])
);
const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<unknown> => {
const parser: CalEventParser = new CalEventParser(calEvent);
const createEvent = async (
credential: Credential,
calEvent: CalendarEvent,
noMail = false,
maybeUid: string = null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid();
/*
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
@ -525,12 +549,22 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro
*/
const richEvent: CalendarEvent = parser.asRichEventPlain();
const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null;
let success = true;
const creationResult = credential
? await calendars([credential])[0]
.createEvent(richEvent)
.catch((e) => {
log.error("createEvent failed", e, calEvent);
success = false;
})
: null;
const maybeHangoutLink = creationResult?.hangoutLink;
const maybeEntryPoints = creationResult?.entryPoints;
const maybeConferenceData = creationResult?.conferenceData;
if (!noMail) {
const organizerMail = new EventOrganizerMail(calEvent, uid, {
hangoutLink: maybeHangoutLink,
conferenceData: maybeConferenceData,
@ -556,26 +590,39 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro
console.error("attendeeMail.sendEmail failed", e);
}
}
}
return {
type: credential.type,
success,
uid,
createdEvent: creationResult,
originalEvent: calEvent,
};
};
const updateEvent = async (
credential: Credential,
uidToUpdate: string,
calEvent: CalendarEvent
): Promise<unknown> => {
calEvent: CalendarEvent,
noMail = false
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent);
const newUid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEventPlain();
let success = true;
const updateResult = credential
? await calendars([credential])[0].updateEvent(uidToUpdate, richEvent)
? await calendars([credential])[0]
.updateEvent(uidToUpdate, richEvent)
.catch((e) => {
log.error("updateEvent failed", e, calEvent);
success = false;
})
: null;
if (!noMail) {
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
try {
@ -591,10 +638,14 @@ const updateEvent = async (
console.error("attendeeMail.sendEmail failed", e);
}
}
}
return {
type: credential.type,
success,
uid: newUid,
updatedEvent: updateResult,
originalEvent: calEvent,
};
};
@ -606,12 +657,4 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
return Promise.resolve({});
};
export {
getBusyCalendarTimes,
createEvent,
updateEvent,
deleteEvent,
CalendarEvent,
listCalendars,
IntegrationCalendar,
};
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };

View file

@ -4,7 +4,7 @@ import { CalendarEvent, ConferenceData } from "../calendarClient";
import { serverConfig } from "../serverConfig";
import nodemailer from "nodemailer";
interface EntryPoint {
export interface EntryPoint {
entryPointType?: string;
uri?: string;
label?: string;
@ -15,7 +15,7 @@ interface EntryPoint {
password?: string;
}
interface AdditionInformation {
export interface AdditionInformation {
conferenceData?: ConferenceData;
entryPoints?: EntryPoint[];
hangoutLink?: string;
@ -34,11 +34,12 @@ export default abstract class EventMail {
*
* @param calEvent
* @param uid
* @param additionInformation
*/
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
this.calEvent = calEvent;
this.uid = uid;
this.parser = new CalEventParser(calEvent);
this.parser = new CalEventParser(calEvent, uid);
this.additionInformation = additionInformation;
}

View file

@ -1,14 +1,21 @@
import {CalendarEvent} from "../calendarClient";
import { CalendarEvent } from "../calendarClient";
import EventAttendeeMail from "./EventAttendeeMail";
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
import {VideoCallData} from "../videoClient";
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
import { VideoCallData } from "../videoClient";
import { AdditionInformation } from "@lib/emails/EventMail";
export default class VideoEventAttendeeMail extends EventAttendeeMail {
videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
constructor(
calEvent: CalendarEvent,
uid: string,
videoCallData: VideoCallData,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid);
this.videoCallData = videoCallData;
this.additionInformation = additionInformation;
}
/**

View file

@ -2,13 +2,20 @@ import { CalendarEvent } from "../calendarClient";
import EventOrganizerMail from "./EventOrganizerMail";
import { VideoCallData } from "../videoClient";
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
import { AdditionInformation } from "@lib/emails/EventMail";
export default class VideoEventOrganizerMail extends EventOrganizerMail {
videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
constructor(
calEvent: CalendarEvent,
uid: string,
videoCallData: VideoCallData,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid);
this.videoCallData = videoCallData;
this.additionInformation = additionInformation;
}
/**

306
lib/events/EventManager.ts Normal file
View file

@ -0,0 +1,306 @@
import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";
import { Credential } from "@prisma/client";
import async from "async";
import { createMeeting, updateMeeting } from "@lib/videoClient";
import prisma from "@lib/prisma";
import { LocationType } from "@lib/location";
import { v5 as uuidv5 } from "uuid";
import merge from "lodash.merge";
export interface EventResult {
type: string;
success: boolean;
uid: string;
createdEvent?: unknown;
updatedEvent?: unknown;
originalEvent: CalendarEvent;
}
export interface CreateUpdateResult {
results: Array<EventResult>;
referencesToCreate: Array<PartialReference>;
}
export interface PartialBooking {
id: number;
references: Array<PartialReference>;
}
export interface PartialReference {
id?: number;
type: string;
uid: string;
}
interface GetLocationRequestFromIntegrationRequest {
location: string;
}
export default class EventManager {
calendarCredentials: Array<Credential>;
videoCredentials: Array<Credential>;
/**
* Takes an array of credentials and initializes a new instance of the EventManager.
*
* @param credentials
*/
constructor(credentials: Array<Credential>) {
this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar"));
this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video"));
}
/**
* Takes a CalendarEvent and creates all necessary integration entries for it.
* When a video integration is chosen as the event's location, a video integration
* event will be scheduled for it as well.
* An optional uid can be set to override the auto-generated uid.
*
* @param event
* @param maybeUid
*/
public async create(event: CalendarEvent, maybeUid: string = null): Promise<CreateUpdateResult> {
event = EventManager.processLocation(event);
const isDedicated = EventManager.isDedicatedIntegration(event.location);
// First, create all calendar events. If this is a dedicated integration event, don't send a mail right here.
const results: Array<EventResult> = await this.createAllCalendarEvents(event, isDedicated, maybeUid);
// If and only if event type is a dedicated meeting, create a dedicated video meeting as well.
if (isDedicated) {
results.push(await this.createVideoEvent(event, maybeUid));
}
const referencesToCreate: Array<PartialReference> = results.map((result) => {
return {
type: result.type,
uid: result.createdEvent.id.toString(),
};
});
return {
results,
referencesToCreate,
};
}
/**
* Takes a calendarEvent and a rescheduleUid and updates the event that has the
* given uid using the data delivered in the given CalendarEvent.
*
* @param event
* @param rescheduleUid
*/
public async update(event: CalendarEvent, rescheduleUid: string): Promise<CreateUpdateResult> {
event = EventManager.processLocation(event);
// Get details of existing booking.
const booking = await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
},
select: {
id: true,
references: {
select: {
id: true,
type: true,
uid: true,
},
},
},
});
const isDedicated = EventManager.isDedicatedIntegration(event.location);
// First, update all calendar events. If this is a dedicated event, don't send a mail right here.
const results: Array<EventResult> = await this.updateAllCalendarEvents(event, booking, isDedicated);
// If and only if event type is a dedicated meeting, update the dedicated video meeting as well.
if (isDedicated) {
results.push(await this.updateVideoEvent(event, booking));
}
// Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: booking.id,
},
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: booking.id,
},
});
const bookingDeletes = prisma.booking.delete({
where: {
uid: rescheduleUid,
},
});
// Wait for all deletions to be applied.
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
return {
results,
referencesToCreate: [...booking.references],
};
}
/**
* Creates event entries for all calendar integrations given in the credentials.
* When noMail is true, no mails will be sent. This is used when the event is
* a video meeting because then the mail containing the video credentials will be
* more important than the mails created for these bare calendar events.
*
* When the optional uid is set, it will be used instead of the auto generated uid.
*
* @param event
* @param noMail
* @param maybeUid
* @private
*/
private createAllCalendarEvents(
event: CalendarEvent,
noMail: boolean,
maybeUid: string = null
): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
return createEvent(credential, event, noMail, maybeUid);
});
}
/**
* Checks which video integration is needed for the event's location and returns
* credentials for that - if existing.
* @param event
* @private
*/
private getVideoCredential(event: CalendarEvent): Credential | undefined {
const integrationName = event.location.replace("integrations:", "");
return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName));
}
/**
* Creates a video event entry for the selected integration location.
*
* When optional uid is set, it will be used instead of the auto generated uid.
*
* @param event
* @param maybeUid
* @private
*/
private createVideoEvent(event: CalendarEvent, maybeUid: string = null): Promise<EventResult> {
const credential = this.getVideoCredential(event);
if (credential) {
return createMeeting(credential, event, maybeUid);
} else {
return Promise.reject("No suitable credentials given for the requested integration name.");
}
}
/**
* Updates the event entries for all calendar integrations given in the credentials.
* When noMail is true, no mails will be sent. This is used when the event is
* a video meeting because then the mail containing the video credentials will be
* more important than the mails created for these bare calendar events.
*
* @param event
* @param booking
* @param noMail
* @private
*/
private updateAllCalendarEvents(
event: CalendarEvent,
booking: PartialBooking,
noMail: boolean
): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0]?.uid;
return updateEvent(credential, bookingRefUid, event, noMail);
});
}
/**
* Updates a single video event.
*
* @param event
* @param booking
* @private
*/
private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
const credential = this.getVideoCredential(event);
if (credential) {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return updateMeeting(credential, bookingRefUid, event);
} else {
return Promise.reject("No suitable credentials given for the requested integration name.");
}
}
/**
* Returns true if the given location describes a dedicated integration that
* delivers meeting credentials. Zoom, for example, is dedicated, because it
* needs to be called independently from any calendar APIs to receive meeting
* credentials. Google Meetings, in contrast, are not dedicated, because they
* are created while scheduling a regular calendar event by simply adding some
* attributes to the payload JSON.
*
* @param location
* @private
*/
private static isDedicatedIntegration(location: string): boolean {
// Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't.
return location === "integrations:zoom";
}
/**
* Helper function for processLocation: Returns the conferenceData object to be merged
* with the CalendarEvent.
*
* @param locationObj
* @private
*/
private static getLocationRequestFromIntegration(locationObj: GetLocationRequestFromIntegrationRequest) {
const location = locationObj.location;
if (location === LocationType.GoogleMeet.valueOf() || location === LocationType.Zoom.valueOf()) {
const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
requestId: requestId,
},
},
location,
};
}
return null;
}
/**
* Takes a CalendarEvent and adds a ConferenceData object to the event
* if the event has an integration-related location.
*
* @param event
* @private
*/
private static processLocation(event: CalendarEvent): CalendarEvent {
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
if (event.location?.includes("integration")) {
const maybeLocationRequestObject = EventManager.getLocationRequestFromIntegration({
location: event.location,
});
event = merge(event, maybeLocationRequestObject);
}
return event;
}
}

View file

@ -1,11 +1,19 @@
import prisma from "./prisma";
import {CalendarEvent} from "./calendarClient";
import { CalendarEvent } from "./calendarClient";
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid';
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger";
import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail";
import { getIntegrationName } from "@lib/emails/helpers";
import CalEventParser from "@lib/CalEventParser";
import { Credential } from "@prisma/client";
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
const translator = short();
@ -33,54 +41,58 @@ function handleErrorsRaw(response) {
}
const zoomAuth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +new Date();
const authHeader =
"Basic " +
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
const isExpired = (expiryDate) => expiryDate < +(new Date());
const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64');
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', {
method: 'POST',
const refreshAccessToken = (refreshToken) =>
fetch("https://zoom.us/oauth/token", {
method: "POST",
headers: {
'Authorization': authHeader,
'Content-Type': 'application/x-www-form-urlencoded'
Authorization: authHeader,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
})
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
})
.then(handleErrorsJson)
.then(async (responseBody) => {
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id
id: credential.id,
},
data: {
key: responseBody
}
key: responseBody,
},
});
credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
return credential.key.access_token;
})
});
return {
getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
getToken: () =>
!isExpired(credential.key.expires_in)
? Promise.resolve(credential.key.access_token)
: refreshAccessToken(credential.key.refresh_token),
};
};
interface VideoApiAdapter {
createMeeting(event: CalendarEvent): Promise<any>;
updateMeeting(uid: String, event: CalendarEvent);
updateMeeting(uid: string, event: CalendarEvent);
deleteMeeting(uid: String);
deleteMeeting(uid: string): Promise<unknown>;
getAvailability(dateFrom, dateTo): Promise<any>;
}
const ZoomVideo = (credential): VideoApiAdapter => {
const auth = zoomAuth(credential);
const translateEvent = (event: CalendarEvent) => {
@ -89,7 +101,7 @@ const ZoomVideo = (credential): VideoApiAdapter => {
topic: event.title,
type: 2, // Means that this is a scheduled meeting
start_time: event.startTime,
duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000,
duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000,
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.attendees[0].timeZone,
//password: "string", TODO: Should we use a password? Maybe generate a random one?
@ -107,82 +119,112 @@ const ZoomVideo = (credential): VideoApiAdapter => {
audio: "both",
auto_recording: "none",
enforce_login: false,
registrants_email_notification: true
}
registrants_email_notification: true,
},
};
};
return {
getAvailability: (dateFrom, dateTo) => {
return auth.getToken().then(
getAvailability: () => {
return auth
.getToken()
.then(
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
(accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', {
method: 'get',
(accessToken) =>
fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", {
method: "get",
headers: {
'Authorization': 'Bearer ' + accessToken
}
Authorization: "Bearer " + accessToken,
},
})
.then(handleErrorsJson)
.then(responseBody => {
.then((responseBody) => {
return responseBody.meetings.map((meeting) => ({
start: meeting.start_time,
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString()
}))
end: new Date(
new Date(meeting.start_time).getTime() + meeting.duration * 60000
).toISOString(),
}));
})
).catch((err) => {
)
.catch((err) => {
console.log(err);
});
},
createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', {
method: 'POST',
createMeeting: (event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/users/me/meetings", {
method: "POST",
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json',
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event))
}).then(handleErrorsJson)),
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
method: 'DELETE',
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsJson)
),
deleteMeeting: (uid: string) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "DELETE",
headers: {
'Authorization': 'Bearer ' + accessToken
}
}).then(handleErrorsRaw)),
updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
Authorization: "Bearer " + accessToken,
},
body: JSON.stringify(translateEvent(event))
}).then(handleErrorsRaw)),
}
}).then(handleErrorsRaw)
),
updateMeeting: (uid: string, event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw)
),
};
};
// factory
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => {
const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
withCredentials
.map((cred) => {
switch (cred.type) {
case 'zoom_video':
case "zoom_video":
return ZoomVideo(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
}).filter(Boolean);
})
.filter(Boolean);
const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredentials) =>
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [])
);
const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
).then(
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
);
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
const createMeeting = async (
credential: Credential,
calEvent: CalendarEvent,
maybeUid: string = null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid();
if (!credential) {
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
}
const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent);
let success = true;
const creationResult = await videoIntegrations([credential])[0]
.createMeeting(calEvent)
.catch((e) => {
log.error("createMeeting failed", e, calEvent);
success = false;
});
const videoCallData: VideoCallData = {
type: credential.type,
@ -191,60 +233,92 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any>
url: creationResult.join_url,
};
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
const entryPoint: EntryPoint = {
entryPointType: getIntegrationName(videoCallData),
uri: videoCallData.url,
label: "Enter Meeting",
pin: videoCallData.password,
};
const additionInformation: AdditionInformation = {
entryPoints: [entryPoint],
};
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation);
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation);
try {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e)
console.error("organizerMail.sendEmail failed", e);
}
if (!creationResult || !creationResult.disableConfirmationEmail) {
try {
await attendeeMail.sendEmail();
} catch (e) {
console.error("attendeeMail.sendEmail failed", e)
console.error("attendeeMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
uid,
createdEvent: creationResult
createdEvent: creationResult,
originalEvent: calEvent,
};
};
const updateMeeting = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => {
const updateMeeting = async (
credential: Credential,
uidToUpdate: string,
calEvent: CalendarEvent
): Promise<EventResult> => {
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) {
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
}
const updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null;
let success = true;
const updateResult = credential
? await videoIntegrations([credential])[0]
.updateMeeting(uidToUpdate, calEvent)
.catch((e) => {
log.error("updateMeeting failed", e, calEvent);
success = false;
})
: null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
try {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e)
console.error("organizerMail.sendEmail failed", e);
}
if (!updateResult || !updateResult.disableConfirmationEmail) {
try {
await attendeeMail.sendEmail();
} catch (e) {
console.error("attendeeMail.sendEmail failed", e)
console.error("attendeeMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
uid: newUid,
updatedEvent: updateResult
updatedEvent: updateResult,
originalEvent: calEvent,
};
};
const deleteMeeting = (credential, uid: String): Promise<any> => {
const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
if (credential) {
return videoIntegrations([credential])[0].deleteMeeting(uid);
}
@ -252,4 +326,4 @@ const deleteMeeting = (credential, uid: String): Promise<any> => {
return Promise.resolve({});
};
export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting};
export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };

View file

@ -1,16 +1,20 @@
const withTM = require('next-transpile-modules')(['react-timezone-select']);
/* eslint-disable @typescript-eslint/no-var-requires */
const withTM = require("next-transpile-modules")(["react-timezone-select"]);
// TODO: Revisit this later with getStaticProps in App
if (process.env.NEXTAUTH_URL) {
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace('/api/auth', '');
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace("/api/auth", "");
}
if ( ! process.env.EMAIL_FROM ) {
console.warn('\x1b[33mwarn', '\x1b[0m', 'EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file.');
if (!process.env.EMAIL_FROM) {
console.warn(
"\x1b[33mwarn",
"\x1b[0m",
"EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file."
);
}
if (process.env.BASE_URL) {
process.env.NEXTAUTH_URL = process.env.BASE_URL + '/api/auth';
process.env.NEXTAUTH_URL = process.env.BASE_URL + "/api/auth";
}
const validJson = (jsonString) => {
@ -19,13 +23,18 @@ const validJson = (jsonString) => {
if (o && typeof o === "object") {
return o;
}
} catch (e) {
console.error(e);
}
catch (e) { console.error(e); }
return false;
}
};
if (process.env.GOOGLE_API_CREDENTIALS && ! validJson(process.env.GOOGLE_API_CREDENTIALS)) {
console.warn('\x1b[33mwarn', '\x1b[0m', "- Disabled 'Google Calendar' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {\"web\":{\"client_id\":\"<clid>\",\"client_secret\":\"<secret>\",\"redirect_uris\":[\"<yourhost>/api/integrations/googlecalendar/callback>\"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials.");
if (process.env.GOOGLE_API_CREDENTIALS && !validJson(process.env.GOOGLE_API_CREDENTIALS)) {
console.warn(
"\x1b[33mwarn",
"\x1b[0m",
'- Disabled \'Google Calendar\' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {"web":{"client_id":"<clid>","client_secret":"<secret>","redirect_uris":["<yourhost>/api/integrations/googlecalendar/callback>"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials.'
);
}
module.exports = withTM({
@ -42,10 +51,10 @@ module.exports = withTM({
async redirects() {
return [
{
source: '/settings',
destination: '/settings/profile',
source: "/settings",
destination: "/settings/profile",
permanent: true,
}
]
}
},
];
},
});

View file

@ -19,6 +19,9 @@
"@prisma/client": "^2.23.0",
"@radix-ui/react-collapsible": "^0.0.16",
"@radix-ui/react-dialog": "^0.0.19",
"@radix-ui/react-slot": "^0.0.12",
"@radix-ui/react-switch": "^0.0.15",
"@radix-ui/react-tooltip": "^0.0.21",
"@tailwindcss/forms": "^0.2.1",
"async": "^3.2.0",
"bcryptjs": "^2.4.3",
@ -38,6 +41,7 @@
"react": "17.0.1",
"react-dates": "^21.8.0",
"react-dom": "17.0.1",
"react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.1.21",
"react-select": "^4.3.0",
"react-timezone-select": "^1.0.2",

154
pages/404.tsx Normal file
View file

@ -0,0 +1,154 @@
import { ChevronRightIcon } from "@heroicons/react/solid";
import { BookOpenIcon, CheckIcon, CodeIcon, DocumentTextIcon } from "@heroicons/react/outline";
import { useRouter } from "next/router";
import React from "react";
import Link from "next/link";
import Head from "next/head";
const links = [
{
title: "Documentation",
description: "Learn how to integrate our tools with your app",
icon: DocumentTextIcon,
href: "https://docs.calendso.com",
},
{
title: "API Reference",
description: "A complete API reference for our libraries",
icon: CodeIcon,
href: "https://api.docs.calendso.com",
},
{
title: "Blog",
description: "Read our latest news and articles",
icon: BookOpenIcon,
href: "https://calendso.com/blog",
},
];
export default function Custom404() {
const router = useRouter();
const username = router.asPath.replace("%20", "-");
return (
<>
<Head>
<title>404: This page could not be found.</title>
</Head>
<div className="bg-white min-h-screen px-4">
<main className="max-w-xl mx-auto pb-6 pt-16 sm:pt-24">
<div className="text-center">
<p className="text-sm font-semibold text-black uppercase tracking-wide">404 error</p>
<h1 className="mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl">
This page does not exist.
</h1>
<a href="https://checkout.calendso.com" className="inline-block mt-2 text-lg ">
The username <strong className="text-blue-500">calendso.com{username}</strong> is still
available. <span className="text-blue-500">Register now</span>.
</a>
</div>
<div className="mt-12">
<h2 className="text-sm font-semibold text-gray-500 tracking-wide uppercase">Popular pages</h2>
<ul role="list" className="mt-4">
<li className="border-2 border-green-500 px-4 py-2">
<a href="https://checkout.calendso.com" className="relative py-6 flex items-start space-x-4">
<div className="flex-shrink-0">
<span className="flex items-center justify-center h-12 w-12 rounded-lg bg-green-50">
<CheckIcon className="h-6 w-6 text-green-500" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-medium text-gray-900">
<span className="rounded-sm focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-gray-500">
<span className="focus:outline-none">
<span className="absolute inset-0" aria-hidden="true" />
Register <strong className="text-green-500">{username}</strong>
</span>
</span>
</h3>
<p className="text-base text-gray-500">Claim your username and schedule events</p>
</div>
<div className="flex-shrink-0 self-center">
<ChevronRightIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
</a>
</li>
</ul>
<ul role="list" className="mt-4 border-gray-200 divide-y divide-gray-200">
{links.map((link, linkIdx) => (
<li key={linkIdx} className="px-4 py-2">
<Link href={link.href}>
<a className="relative py-6 flex items-start space-x-4">
<div className="flex-shrink-0">
<span className="flex items-center justify-center h-12 w-12 rounded-lg bg-gray-50">
<link.icon className="h-6 w-6 text-gray-700" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-medium text-gray-900">
<span className="rounded-sm focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-gray-500">
<span className="absolute inset-0" aria-hidden="true" />
{link.title}
</span>
</h3>
<p className="text-base text-gray-500">{link.description}</p>
</div>
<div className="flex-shrink-0 self-center">
<ChevronRightIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
</a>
</Link>
</li>
))}
<li className="px-4 py-2">
<a href="https://calendso.com/slack" className="relative py-6 flex items-start space-x-4">
<div className="flex-shrink-0">
<span className="flex items-center justify-center h-12 w-12 rounded-lg bg-gray-50">
<svg viewBox="0 0 2447.6 2452.5" className="h-6 w-6" xmlns="http://www.w3.org/2000/svg">
<g clipRule="evenodd" fillRule="evenodd">
<path
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
fill="rgba(55, 65, 81)"></path>
<path
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
fill="rgba(55, 65, 81)"></path>
<path
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
fill="rgba(55, 65, 81)"></path>
<path
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
fill="rgba(55, 65, 81)"></path>
</g>
</svg>
</span>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-medium text-gray-900">
<span className="rounded-sm focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-gray-500">
<span className="absolute inset-0" aria-hidden="true" />
Slack
</span>
</h3>
<p className="text-base text-gray-500">Join our community</p>
</div>
<div className="flex-shrink-0 self-center">
<ChevronRightIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
</a>
</li>
</ul>
<div className="mt-8">
<Link href="/">
<a className="text-base font-medium text-black hover:text-gray-500">
Or go back home<span aria-hidden="true"> &rarr;</span>
</a>
</Link>
</div>
</div>
</main>
</div>
</>
);
}

View file

@ -4,53 +4,126 @@ import Link from "next/link";
import prisma, { whereAndSelect } from "@lib/prisma";
import Avatar from "../components/Avatar";
import Theme from "@components/Theme";
import { ClockIcon, InformationCircleIcon, UserIcon } from "@heroicons/react/solid";
import React from "react";
import { ArrowRightIcon } from "@heroicons/react/outline";
export default function User(props): User {
const { isReady } = Theme(props.user.theme);
const eventTypes = props.eventTypes.map((type) => (
<li
<div
key={type.id}
className="dark:bg-gray-800 dark:opacity-90 dark:hover:opacity-100 dark:hover:bg-gray-800 bg-white hover:bg-gray-50 ">
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={`/${props.user.username}/${type.slug}`}>
<a className="block px-6 py-4">
<div
className="inline-block w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: getRandomColorCode() }}></div>
<h2 className="inline-block font-medium dark:text-white">{type.title}</h2>
<p className="inline-block text-gray-400 dark:text-gray-100 ml-2">{type.description}</p>
<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>
</a>
</Link>
</li>
</div>
));
return (
isReady && (
<div>
<>
<Head>
<title>{props.user.name || props.user.username} | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-2xl mx-auto my-24">
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
<meta name="description" content={"Book a time with " + (props.user.name || props.user.username)} />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://calendso/" />
<meta
property="og:title"
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
/>
<meta
property="og:description"
content={"Book a time with " + (props.user.name || props.user.username)}
/>
<meta
property="og:image"
content={
"https://og-image-one-pi.vercel.app/" +
encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>").replace(
/'/g,
"%27"
) +
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
encodeURIComponent(props.user.avatar)
}
/>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://calendso/" />
<meta
property="twitter:title"
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
/>
<meta
property="twitter:description"
content={"Book a time with " + (props.user.name || props.user.username)}
/>
<meta
property="twitter:image"
content={
"https://og-image-one-pi.vercel.app/" +
encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>").replace(
/'/g,
"%27"
) +
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
encodeURIComponent(props.user.avatar)
}
/>
</Head>
{isReady && (
<div className="bg-neutral-50 dark:bg-black h-screen">
<main className="max-w-3xl mx-auto py-24 px-4">
<div className="mb-8 text-center">
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
<h1 className="text-3xl font-semibold text-gray-800 dark:text-white mb-1">
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-1">
{props.user.name || props.user.username}
</h1>
<p className="text-gray-600 dark:text-white">{props.user.bio}</p>
<p className="text-neutral-500 dark:text-white">{props.user.bio}</p>
</div>
<div className="shadow overflow-hidden rounded-md">
<ul className="divide-y divide-gray-200 dark:divide-gray-900">{eventTypes}</ul>
<div className="space-y-6">{eventTypes}</div>
{eventTypes.length == 0 && (
<div className="shadow overflow-hidden rounded-sm">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-semibold text-3xl text-gray-600">Uh oh!</h2>
<p className="max-w-md mx-auto">This user hasn&apos;t set up any event types yet.</p>
</div>
)}
</div>
)}
</main>
</div>
)
)}
</>
);
}
@ -76,6 +149,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
select: {
slug: true,
title: true,
length: true,
description: true,
},
});

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { GetServerSideProps, GetServerSidePropsContext } from "next";
import Head from "next/head";
import { ChevronDownIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import dayjs, { Dayjs } from "dayjs";
import * as Collapsible from "@radix-ui/react-collapsible";
@ -49,9 +49,15 @@ export default function Type(props): Type {
router.replace(
{
query: {
date: formattedDate,
query: Object.assign(
{},
{
...router.query,
},
{
date: formattedDate,
}
),
},
undefined,
{
@ -72,12 +78,11 @@ export default function Type(props): Type {
};
return (
isReady && (
<div>
<>
<Head>
<title>
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username}{" "}
| Calendso
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
Calendso
</title>
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
<meta name="description" content={props.eventType.description} />
@ -120,16 +125,38 @@ export default function Type(props): Type {
}
/>
</Head>
{isReady && (
<div>
<main
className={
"mx-auto my-0 sm:my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-6xl" : "max-w-3xl")
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
}>
<div className="dark:bg-gray-800 bg-white sm:shadow sm:rounded-lg">
<div className="sm:flex px-4 py-5 sm:p-4">
<div className="sm:dark:border-gray-600 dark:bg-gray-900 bg-white md:border border-gray-200 rounded-sm">
{/* mobile: details */}
<div className="p-4 sm:p-8 block md:hidden">
<div className="flex items-center">
<Avatar user={props.user} className="inline-block h-9 w-9 rounded-full" />
<div className="ml-3">
<p className="text-sm font-medium dark:text-gray-300 text-black">{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="dark:text-gray-200 text-gray-600 mt-3">{props.eventType.description}</p>
</div>
<div className="sm:flex px-4 sm:py-5 sm:p-4">
<div
className={
"pr-8 sm:border-r sm:dark:border-gray-900 " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
"hidden md:block pr-8 sm:border-r sm:dark:border-gray-800 " +
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}>
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.user.name}</h2>
@ -141,19 +168,7 @@ export default function Type(props): Type {
{props.eventType.length} minutes
</p>
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="text-gray-500 mb-1 px-2 py-1 -ml-2">
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{timeZone()}
<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>
<TimezoneDropdown />
<p className="dark:text-gray-200 text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
</div>
@ -172,6 +187,11 @@ export default function Type(props): Type {
eventLength={props.eventType.length}
minimumBookingNotice={props.eventType.minimumBookingNotice}
/>
<div className="ml-1 mt-4 block sm:hidden">
<TimezoneDropdown />
</div>
{selectedDate && (
<AvailableTimes
workingHours={props.workingHours}
@ -189,8 +209,28 @@ export default function Type(props): Type {
{!props.user.hideBranding && <PoweredByCalendso />}
</main>
</div>
)
)}
</>
);
function TimezoneDropdown() {
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="text-gray-500 mb-1 px-2 py-1 -ml-2 text-left 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 const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => {

View file

@ -15,6 +15,8 @@ import Avatar from "../../components/Avatar";
import Button from "../../components/ui/Button";
import { EventTypeCustomInputType } from "../../lib/eventTypeInput";
import Theme from "@components/Theme";
import { ReactMultiEmail } from "react-multi-email";
import "react-multi-email/style.css";
dayjs.extend(utc);
dayjs.extend(timezone);
@ -27,7 +29,8 @@ export default function Book(props: any): JSX.Element {
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>(
@ -44,6 +47,10 @@ export default function Book(props: any): JSX.Element {
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
@ -85,6 +92,7 @@ export default function Book(props: any): JSX.Element {
name: event.target.name.value,
email: event.target.email.value,
notes: notes,
guests: guestEmails,
timeZone: preferredTimeZone,
eventTypeId: props.eventType.id,
rescheduleUid: rescheduleUid,
@ -153,9 +161,9 @@ export default function Book(props: any): JSX.Element {
</Head>
<main className="max-w-3xl mx-auto my-0 sm:my-24">
<div className="dark:bg-gray-800 bg-white overflow-hidden sm:shadow sm:rounded-lg">
<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-gray-900">
<div className="sm:w-1/2 sm:border-r sm:dark:border-black">
<Avatar user={props.user} 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">
@ -171,7 +179,7 @@ export default function Book(props: any): JSX.Element {
{locationInfo(selectedLocation).address}
</p>
)}
<p className="text-blue-600 mb-4">
<p className="text-green-500 mb-4">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{preferredTimeZone &&
dayjs(date)
@ -192,7 +200,7 @@ export default function Book(props: any): JSX.Element {
name="name"
id="name"
required
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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 : ""}
/>
@ -210,7 +218,7 @@ export default function Book(props: any): JSX.Element {
name="email"
id="email"
required
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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 : ""}
/>
@ -252,7 +260,7 @@ export default function Book(props: any): JSX.Element {
placeholder="Enter phone number"
id="phone"
required
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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 */
}}
@ -278,7 +286,7 @@ export default function Book(props: any): JSX.Element {
id={"custom_" + input.id}
required={input.required}
rows={3}
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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=""
/>
)}
@ -288,7 +296,7 @@ export default function Book(props: any): JSX.Element {
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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=""
/>
)}
@ -298,7 +306,7 @@ export default function Book(props: any): JSX.Element {
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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=""
/>
)}
@ -308,7 +316,7 @@ export default function Book(props: any): JSX.Element {
type="checkbox"
name={"custom_" + input.id}
id={"custom_" + input.id}
className="focus:ring-black h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
className="focus:ring-black h-4 w-4 text-black border-gray-300 rounded mr-2"
placeholder=""
/>
<label
@ -320,6 +328,42 @@ export default function Book(props: any): JSX.Element {
)}
</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"
@ -330,13 +374,14 @@ export default function Book(props: any): JSX.Element {
name="notes"
id="notes"
rows={3}
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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">
<Button type="submit" loading={loading} className="btn btn-primary">
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
<Button type="submit" loading={loading}>
{rescheduleUid ? "Reschedule" : "Confirm"}
</Button>
<Link
@ -347,7 +392,7 @@ export default function Book(props: any): JSX.Element {
props.eventType.slug +
(rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")
}>
<a className="ml-2 btn btn-white">Cancel</a>
<a className="ml-2 text-sm dark:text-white p-2">Cancel</a>
</Link>
</div>
</form>
@ -408,10 +453,13 @@ export async function getServerSideProps(context) {
},
});
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
const eventTypeObject = [eventType].map((e) => {
return {
...e,
periodStartDate: e.periodStartDate?.toString() ?? null,
periodEndDate: e.periodEndDate?.toString() ?? null,
};
})[0];
let booking = null;

View file

@ -9,10 +9,7 @@ function MyApp({ Component, pageProps }: AppProps) {
<TelemetryProvider value={createTelemetryClient()}>
<Provider session={pageProps.session}>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Head>
<Component {...pageProps} />
</Provider>

View file

@ -1,4 +1,4 @@
import Document, { Html, Head, Main, NextScript } from "next/document";
import Document, { Head, Html, Main, NextScript } from "next/document";
class MyDocument extends Document {
static async getInitialProps(ctx) {
@ -18,7 +18,7 @@ class MyDocument extends Document {
<meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" content="#ffffff" />
</Head>
<body className="dark:bg-gray-900 bg-white">
<body className="dark:bg-black bg-gray-100">
<Main />
<NextScript />
</body>

View file

@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../../lib/prisma";
import { getBusyCalendarTimes } from "../../../lib/calendarClient";
import { getBusyVideoTimes } from "../../../lib/videoClient";
import prisma from "@lib/prisma";
import { getBusyCalendarTimes } from "@lib/calendarClient";
import { getBusyVideoTimes } from "@lib/videoClient";
import dayjs from "dayjs";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -25,39 +25,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
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(
const calendarBusyTimes = await getBusyCalendarTimes(
currentUser.credentials,
req.query.dateFrom,
req.query.dateTo,
selectedCalendars
);
const videoAvailability = await getBusyVideoTimes(
const videoBusyTimes = await getBusyVideoTimes(
currentUser.credentials,
req.query.dateFrom,
req.query.dateTo
);
calendarBusyTimes.push(...videoBusyTimes);
let commonAvailability = [];
if (hasCalendarIntegrations && hasVideoIntegrations) {
commonAvailability = calendarAvailability.filter((availability) =>
videoAvailability.includes(availability)
);
} else if (hasVideoIntegrations) {
commonAvailability = videoAvailability;
} else if (hasCalendarIntegrations) {
commonAvailability = calendarAvailability;
}
commonAvailability = commonAvailability.map((a) => ({
const bufferedBusyTimes = calendarBusyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
res.status(200).json(commonAvailability);
res.status(200).json(bufferedBusyTimes);
}

View file

@ -1,17 +1,15 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@lib/prisma";
import { EventType } from "@prisma/client";
import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient";
import async from "async";
import { EventType, User } from "@prisma/client";
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import { createMeeting, getBusyVideoTimes, updateMeeting } from "../../../lib/videoClient";
import { getBusyVideoTimes } from "@lib/videoClient";
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
import { getEventName } from "../../../lib/event";
import { LocationType } from "../../../lib/location";
import merge from "lodash.merge";
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";
@ -37,11 +35,6 @@ function isAvailable(busyTimes, time, length) {
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Check if start times are the same
if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) {
t = false;
}
// Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime)) {
t = false;
@ -88,167 +81,8 @@ function isOutOfBounds(
}
}
interface GetLocationRequestFromIntegrationRequest {
location: string;
}
const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromIntegrationRequest) => {
if (location === LocationType.GoogleMeet.valueOf()) {
const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
requestId: requestId,
},
},
};
}
return null;
};
async function rescheduleEvent(
rescheduleUid: string | string[],
results: unknown[],
calendarCredentials: unknown[],
evt: CalendarEvent,
videoCredentials: unknown[],
referencesToCreate: { type: string; uid: string }[]
): Promise<{
referencesToCreate: { type: string; uid: string }[];
results: unknown[];
error: { errorCode: string; message: string } | null;
}> {
// Reschedule event
const booking = await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
},
select: {
id: true,
references: {
select: {
id: true,
type: true,
uid: true,
},
},
},
});
// Use all integrations
results = results.concat(
await async.mapLimit(calendarCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return updateEvent(credential, bookingRefUid, evt)
.then((response) => ({ type: credential.type, success: true, response }))
.catch((e) => {
log.error("updateEvent failed", e, evt);
return { type: credential.type, success: false };
});
})
);
results = results.concat(
await async.mapLimit(videoCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return updateMeeting(credential, bookingRefUid, evt)
.then((response) => ({ type: credential.type, success: true, response }))
.catch((e) => {
log.error("updateMeeting failed", e, evt);
return { type: credential.type, success: false };
});
})
);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
return { referencesToCreate: [], results: [], error: error };
}
// Clone elements
referencesToCreate = [...booking.references];
// Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: booking.id,
},
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: booking.id,
},
});
const bookingDeletes = prisma.booking.delete({
where: {
uid: rescheduleUid,
},
});
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
return { error: undefined, results, referencesToCreate };
}
export async function scheduleEvent(
results: unknown[],
calendarCredentials: unknown[],
evt: CalendarEvent,
videoCredentials: unknown[],
referencesToCreate: { type: string; uid: string }[]
): Promise<{
referencesToCreate: { type: string; uid: string }[];
results: unknown[];
error: { errorCode: string; message: string } | null;
}> {
// Schedule event
results = results.concat(
await async.mapLimit(calendarCredentials, 5, async (credential) => {
return createEvent(credential, evt)
.then((response) => ({ type: credential.type, success: true, response }))
.catch((e) => {
log.error("createEvent failed", e, evt);
return { type: credential.type, success: false };
});
})
);
results = results.concat(
await async.mapLimit(videoCredentials, 5, async (credential) => {
return createMeeting(credential, evt)
.then((response) => ({ type: credential.type, success: true, response }))
.catch((e) => {
log.error("createMeeting failed", e, evt);
return { type: credential.type, success: false };
});
})
);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
return { referencesToCreate: [], results: [], error: error };
}
referencesToCreate = results.map((result) => {
return {
type: result.type,
uid: result.response.createdEvent.id.toString(),
};
});
return { error: undefined, results, referencesToCreate };
}
export async function handleLegacyConfirmationMail(
results: unknown[],
results: Array<EventResult>,
eventType: EventType,
evt: CalendarEvent,
hashUID: string
@ -285,7 +119,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error);
}
let currentUser = await prisma.user.findFirst({
let currentUser: User = await prisma.user.findFirst({
where: {
username: user,
},
@ -315,11 +149,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
dayjs(req.body.end).endOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
selectedCalendars
);
const videoAvailability = await getBusyVideoTimes(
currentUser.credentials,
dayjs(req.body.start).startOf("day").utc().format(),
dayjs(req.body.end).endOf("day").utc().format()
);
const videoAvailability = await getBusyVideoTimes(currentUser.credentials);
let commonAvailability = [];
if (hasCalendarIntegrations && hasVideoIntegrations) {
@ -346,8 +176,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
// Initialize EventManager with credentials
const eventManager = new EventManager(currentUser.credentials);
const rescheduleUid = req.body.rescheduleUid;
const eventType: EventType = await prisma.eventType.findFirst({
@ -369,35 +199,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const rawLocation = req.body.location;
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];
let evt: CalendarEvent = {
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: [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }],
attendees: attendeesList,
location: req.body.location, // Will be processed by the EventManager later.
};
// If phone or inPerson use raw location
// set evt.location to req.body.location
if (!rawLocation?.includes("integration")) {
evt.location = rawLocation;
}
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
if (rawLocation?.includes("integration")) {
const maybeLocationRequestObject = getLocationRequestFromIntegration({
location: rawLocation,
});
evt = merge(evt, maybeLocationRequestObject);
}
let isAvailableToBeBooked = true;
try {
@ -445,44 +268,47 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error);
}
let results = [];
let results: Array<EventResult> = [];
let referencesToCreate = [];
if (rescheduleUid) {
const __ret = await rescheduleEvent(
rescheduleUid,
results,
calendarCredentials,
evt,
videoCredentials,
referencesToCreate
);
if (__ret.error) {
log.error(`Booking ${user} failed`, __ret.error, results);
return res.status(500).json(__ret.error);
// 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);
}
results = __ret.results;
referencesToCreate = __ret.referencesToCreate;
// Forward results
results = updateResults.results;
referencesToCreate = updateResults.referencesToCreate;
} else if (!eventType.requiresConfirmation) {
const __ret = await scheduleEvent(
results,
calendarCredentials,
evt,
videoCredentials,
referencesToCreate
);
if (__ret.error) {
log.error(`Booking ${user} failed`, __ret.error, results);
return res.status(500).json(__ret.error);
// 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);
}
results = __ret.results;
referencesToCreate = __ret.referencesToCreate;
// Forward results
results = createResults.results;
referencesToCreate = createResults.referencesToCreate;
}
const hashUID =
results.length > 0
? results[0].response.uid
: translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
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.
const legacyMailError = await handleLegacyConfirmationMail(results, eventType, evt, hashUID);
@ -510,6 +336,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
create: evt.attendees,
},
confirmed: !eventType.requiresConfirmation,
location: evt.location,
},
});
} catch (e) {

View file

@ -1,9 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/client";
import prisma from "../../../lib/prisma";
import { handleLegacyConfirmationMail, scheduleEvent } from "./[user]";
import { handleLegacyConfirmationMail } from "./[user]";
import { CalendarEvent } from "@lib/calendarClient";
import EventRejectionMail from "@lib/emails/EventRejectionMail";
import EventManager from "@lib/events/EventManager";
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const session = await getSession({ req: req });
@ -41,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: true,
confirmed: true,
attendees: true,
location: true,
userId: true,
id: true,
uid: true,
@ -54,9 +56,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "booking already confirmed" });
}
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
@ -65,10 +64,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: booking.endTime.toISOString(),
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
attendees: booking.attendees,
location: booking.location,
};
if (req.body.confirmed) {
const scheduleResult = await scheduleEvent([], calendarCredentials, evt, videoCredentials, []);
const eventManager = new EventManager(currentUser.credentials);
const scheduleResult = await eventManager.create(evt, booking.uid);
await handleLegacyConfirmationMail(
scheduleResult.results,

View file

@ -1,7 +1,7 @@
import prisma from '../../lib/prisma';
import {deleteEvent} from "../../lib/calendarClient";
import async from 'async';
import {deleteMeeting} from "../../lib/videoClient";
import prisma from "../../lib/prisma";
import { deleteEvent } from "../../lib/calendarClient";
import async from "async";
import { deleteMeeting } from "../../lib/videoClient";
export default async function handler(req, res) {
if (req.method == "POST") {
@ -15,36 +15,38 @@ export default async function handler(req, res) {
id: true,
user: {
select: {
credentials: true
}
credentials: true,
},
},
attendees: true,
references: {
select: {
uid: true,
type: true
}
}
}
type: true,
},
},
},
});
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid;
if(credential.type.endsWith("_calendar")) {
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")) {
} else if (credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
}
}
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: bookingToDelete.id
}
bookingId: bookingToDelete.id,
},
});
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: bookingToDelete.id
}
bookingId: bookingToDelete.id,
},
});
const bookingDeletes = prisma.booking.delete({
where: {
@ -52,17 +54,12 @@ export default async function handler(req, res) {
},
});
await Promise.all([
apiDeletes,
attendeeDeletes,
bookingReferenceDeletes,
bookingDeletes
]);
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes, bookingDeletes]);
//TODO Perhaps send emails to user and client to tell about the cancellation
res.status(200).json({message: 'Booking successfully deleted.'});
res.status(200).json({ message: "Booking successfully deleted." });
} else {
res.status(405).json({message: 'This endpoint only accepts POST requests.'});
res.status(405).json({ message: "This endpoint only accepts POST requests." });
}
}

View file

@ -1,20 +1,26 @@
import { useRouter } from 'next/router';
import { XIcon } from '@heroicons/react/outline';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from "next/router";
import { XIcon } from "@heroicons/react/outline";
import Head from "next/head";
import Link from "next/link";
export default function Error() {
const router = useRouter();
const { error } = router.query;
return (
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<Head>
<title>{error} - Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">

View file

@ -2,11 +2,10 @@ import { getCsrfToken } from "next-auth/client";
import prisma from "../../../lib/prisma";
import Head from "next/head";
import React from "react";
import React, { useMemo } from "react";
import debounce from "lodash.debounce";
import dayjs from "dayjs";
import { ResetPasswordRequest } from "@prisma/client";
import { useMemo } from "react";
import Link from "next/link";
import { GetServerSidePropsContext } from "next";

View file

@ -1,16 +1,22 @@
import Head from 'next/head';
import Link from 'next/link';
import { CheckIcon } from '@heroicons/react/outline';
import Head from "next/head";
import Link from "next/link";
import { CheckIcon } from "@heroicons/react/outline";
export default function Logout() {
return (
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<Head>
<title>Logged out - Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
@ -21,15 +27,13 @@ export default function Logout() {
You&apos;ve been logged out
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
We hope to see you again soon!
</p>
<p className="text-sm text-gray-500">We hope to see you again soon!</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-black text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
Go back to the login page
</a>
</Link>

View file

@ -1,27 +1,25 @@
import Head from 'next/head';
import {useRouter} from "next/router";
import {signIn} from 'next-auth/client'
import Head from "next/head";
import { useRouter } from "next/router";
import { signIn } from "next-auth/client";
import ErrorAlert from "../../components/ui/alerts/Error";
import {useState} from "react";
import {UsernameInput} from "../../components/ui/UsernameInput";
import { useState } from "react";
import { UsernameInput } from "../../components/ui/UsernameInput";
import prisma from "../../lib/prisma";
export default function Signup(props) {
const router = useRouter();
const [ hasErrors, setHasErrors ] = useState(false);
const [ errorMessage, setErrorMessage ] = useState('');
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const handleErrors = async (resp) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
}
};
const signUp = (e) => {
e.preventDefault();
if (e.target.password.value !== e.target.passwordcheck.value) {
@ -31,39 +29,37 @@ export default function Signup(props) {
const email: string = e.target.email.value;
const password: string = e.target.password.value;
fetch('/api/auth/signup',
{
fetch("/api/auth/signup", {
body: JSON.stringify({
username: e.target.username.value,
password,
email,
}),
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
method: 'POST'
}
)
method: "POST",
})
.then(handleErrors)
.then(
() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })
)
.catch( (err) => {
.then(() => signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string }))
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div
className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<Head>
<title>Sign up</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<h2 className="text-center text-3xl font-extrabold text-gray-900">Create your account</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow mx-2 sm:rounded-lg sm:px-10">
@ -74,23 +70,60 @@ export default function Signup(props) {
<UsernameInput required />
</div>
<div className="mb-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" id="email" placeholder="jdoe@example.com" disabled={!!props.email} readOnly={!!props.email} value={props.email} className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
name="email"
id="email"
placeholder="jdoe@example.com"
disabled={!!props.email}
readOnly={!!props.email}
value={props.email}
className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
</div>
<div className="mb-2">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
<input type="password" name="password" id="password" required placeholder="•••••••••••••" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
type="password"
name="password"
id="password"
required
placeholder="•••••••••••••"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
</div>
<div>
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">Confirm password</label>
<input type="password" name="passwordcheck" id="passwordcheck" required placeholder="•••••••••••••" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">
Confirm password
</label>
<input
type="password"
name="passwordcheck"
id="passwordcheck"
required
placeholder="•••••••••••••"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
</div>
</div>
<div className="mt-3 sm:mt-4 flex">
<input type="submit" value="Create Account"
className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm" />
<a onClick={() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })}
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">Login instead</a>
<input
type="submit"
value="Create Account"
className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm"
/>
<a
onClick={() =>
signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string })
}
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">
Login instead
</a>
</div>
</form>
</div>
@ -103,38 +136,40 @@ export async function getServerSideProps(ctx) {
if (!ctx.query.token) {
return {
notFound: true,
}
};
}
const verificationRequest = await prisma.verificationRequest.findUnique({
where: {
token: ctx.query.token,
}
},
});
// for now, disable if no verificationRequestToken given or token expired
if ( ! verificationRequest || verificationRequest.expires < new Date() ) {
if (!verificationRequest || verificationRequest.expires < new Date()) {
return {
notFound: true,
}
};
}
const existingUser = await prisma.user.findFirst({
where: {
AND: [
{
email: verificationRequest.identifier
email: verificationRequest.identifier,
},
{
emailVerified: {
not: null,
},
}
]
}
},
],
},
});
if (existingUser) {
return { redirect: { permanent: false, destination: '/auth/login?callbackUrl=' + ctx.query.callbackUrl } };
return {
redirect: { permanent: false, destination: "/auth/login?callbackUrl=" + ctx.query.callbackUrl },
};
}
return { props: { email: verificationRequest.identifier } };

View file

@ -7,9 +7,10 @@ import { useRouter } from "next/router";
import { useRef, useState } from "react";
import { getSession, useSession } from "next-auth/client";
import { ClockIcon } from "@heroicons/react/outline";
import Loader from '@components/Loader';
import Loader from "@components/Loader";
export default function Availability(props) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
const router = useRouter();
const [showAddModal, setShowAddModal] = useState(false);
@ -29,7 +30,7 @@ export default function Availability(props) {
const bufferMinsRef = useRef<HTMLInputElement>();
if (loading) {
return <Loader/>;
return <Loader />;
}
function toggleAddModal() {
@ -52,7 +53,7 @@ export default function Availability(props) {
m = m < 10 ? "0" + m : m;
return `${h}:${m}`;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function createEventTypeHandler(event) {
event.preventDefault();
@ -63,7 +64,7 @@ export default function Availability(props) {
const enteredIsHidden = isHiddenRef.current.checked;
// TODO: Add validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const response = await fetch("/api/availability/eventtype", {
method: "POST",
body: JSON.stringify({
@ -99,7 +100,7 @@ export default function Availability(props) {
const bufferMins = enteredBufferHours * 60 + enteredBufferMins;
// TODO: Add validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const response = await fetch("/api/availability/day", {
method: "PATCH",
body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }),
@ -124,7 +125,7 @@ export default function Availability(props) {
">
<div className="flex">
<div className="w-1/2 mr-2 bg-white shadow rounded-sm">
<div className="w-1/2 mr-2 bg-white border border-gray-200 rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Change the start and end times of your day
@ -143,7 +144,7 @@ export default function Availability(props) {
</div>
</div>
<div className="w-1/2 ml-2 bg-white shadow rounded-sm">
<div className="w-1/2 ml-2 border border-gray-200 rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Something doesn&apos;t look right?
@ -153,7 +154,7 @@ export default function Availability(props) {
</div>
<div className="mt-5">
<Link href="/availability/troubleshoot">
<a className="btn btn-primary">Launch troubleshooter</a>
<a className="btn btn-white">Launch troubleshooter</a>
</Link>
</div>
</div>

View file

@ -6,16 +6,19 @@ import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { GetServerSideProps } from "next";
import prisma from "@lib/prisma";
import Loader from '@components/Loader';
import Loader from "@components/Loader";
dayjs.extend(utc);
export default function Troubleshoot({ user }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
const [availability, setAvailability] = useState([]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [selectedDate, setSelectedDate] = useState(dayjs());
if (loading) {
return <Loader/>;
return <Loader />;
}
function convertMinsToHrsMins(mins) {

View file

@ -9,6 +9,7 @@ import { Menu, Transition } from "@headlessui/react";
import { DotsHorizontalIcon } from "@heroicons/react/solid";
import classNames from "@lib/classNames";
import { ClockIcon, XIcon } from "@heroicons/react/outline";
import Loader from "@components/Loader";
export default function Bookings({ bookings }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -17,7 +18,7 @@ export default function Bookings({ bookings }) {
const router = useRouter();
if (loading) {
return <p className="text-gray-400">Loading...</p>;
return <Loader />;
}
async function confirmBookingHandler(booking, confirm: boolean) {
@ -43,25 +44,8 @@ export default function Bookings({ bookings }) {
<div className="-mx-4 sm:mx-auto flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-sm">
<div className="border border-gray-200 overflow-hidden border-b rounded-sm">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Event
</th>
<th
scope="col"
className="hidden sm:table-cell px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{bookings
.filter((booking) => !booking.confirmed && !booking.rejected)
@ -70,7 +54,7 @@ export default function Bookings({ bookings }) {
<tr key={booking.id}>
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
{!booking.confirmed && !booking.rejected && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
Unconfirmed
</span>
)}
@ -140,7 +124,7 @@ export default function Bookings({ bookings }) {
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1">
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
<span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>

View file

@ -1,14 +1,14 @@
import {useState} from 'react';
import Head from 'next/head';
import prisma from '../../lib/prisma';
import {useRouter} from 'next/router';
import dayjs from 'dayjs';
import {CalendarIcon, ClockIcon, XIcon} from '@heroicons/react/solid';
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 {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
import { useState } from "react";
import Head from "next/head";
import prisma from "../../lib/prisma";
import { useRouter } from "next/router";
import dayjs from "dayjs";
import { CalendarIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
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 { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
@ -20,58 +20,61 @@ export default function Type(props) {
const router = useRouter();
const { uid } = router.query;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [is24h, setIs24h] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const telemetry = useTelemetry();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const cancellationHandler = async (event) => {
setLoading(true);
let payload = {
uid: uid
const payload = {
uid: uid,
};
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters()));
const res = await fetch(
'/api/cancel',
{
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())
);
const res = await fetch("/api/cancel", {
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
method: 'POST'
}
);
method: "POST",
});
if(res.status >= 200 && res.status < 300) {
router.push('/cancel/success?user=' + props.user.username + '&title=' + props.eventType.title);
if (res.status >= 200 && res.status < 300) {
router.push("/cancel/success?user=" + props.user.username + "&title=" + props.eventType.title);
} else {
setLoading(false);
setError("An error with status code " + res.status + " occurred. Please try again later.");
}
}
};
return (
<div>
<Head>
<title>
Cancel {props.booking.title} | {props.user.name || props.user.username} |
Calendso
Cancel {props.booking.title} | {props.user.name || props.user.username} | Calendso
</title>
<link rel="icon" href="/favicon.ico"/>
<link rel="icon" href="/favicon.ico" />
</Head>
<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">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true">&#8203;</span>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
{error && <div>
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
{error && (
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" />
</div>
@ -80,41 +83,49 @@ export default function Type(props) {
{error}
</h3>
</div>
</div>}
{!error && <div>
<div
className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600"/>
</div>
)}
{!error && (
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Really cancel your booking?
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Instead, you could also reschedule it.
</p>
<p className="text-sm text-gray-500">Instead, you could also reschedule it.</p>
</div>
<div className="mt-4 border-t border-b py-4">
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.booking.title}</h2>
<p className="text-gray-500 mb-1">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
<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")}
<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")}
</p>
</div>
</div>
</div>}
</div>
)}
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button onClick={cancellationHandler} disabled={loading} type="button"
<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"
<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>
@ -147,22 +158,22 @@ export async function getServerSideProps(context) {
id: true,
username: true,
name: true,
}
}
}
},
},
},
});
// 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()
endTime: booking.endTime.toString(),
});
return {
props: {
user: booking.user,
eventType: booking.eventType,
booking: bookingObj
booking: bookingObj,
},
}
};
}

View file

@ -1,12 +1,12 @@
import Head from 'next/head';
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 Head from "next/head";
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";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
@ -21,21 +21,22 @@ export default function Type(props) {
<div>
<Head>
<title>
Cancelled {props.title} | {props.user.name || props.user.username} |
Calendso
Cancelled {props.title} | {props.user.name || props.user.username} | Calendso
</title>
<link rel="icon" href="/favicon.ico"/>
<link rel="icon" href="/favicon.ico" />
</Head>
<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">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true">&#8203;</span>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CheckIcon className="h-6 w-6 text-green-600" />
@ -45,15 +46,15 @@ export default function Type(props) {
Cancellation successful
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Feel free to pick another event anytime.
</p>
<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"
<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>
@ -78,14 +79,14 @@ export async function getServerSideProps(context) {
name: true,
bio: true,
avatar: true,
eventTypes: true
}
eventTypes: true,
},
});
return {
props: {
user,
title: context.query.title
title: context.query.title,
},
}
};
}

View file

@ -2,21 +2,19 @@ import { GetServerSideProps } from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import Select, { OptionBase } from "react-select";
import prisma from "@lib/prisma";
import { LocationType } from "@lib/location";
import Shell from "@components/Shell";
import { getSession } from "next-auth/client";
import { Scheduler } from "@components/ui/Scheduler";
import { Disclosure } from "@headlessui/react";
import { Disclosure, RadioGroup } from "@headlessui/react";
import { PhoneIcon, XIcon } from "@heroicons/react/outline";
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
import {
LocationMarkerIcon,
LinkIcon,
PencilIcon,
PlusIcon,
DocumentIcon,
ChevronRightIcon,
@ -30,12 +28,14 @@ import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { Availability, EventType, User } from "@prisma/client";
import { validJson } from "@lib/jsonUtils";
import { RadioGroup } from "@headlessui/react";
import classnames from "classnames";
import throttle from "lodash.throttle";
import "react-dates/initialize";
import "react-dates/lib/css/_datepicker.css";
import { DateRangePicker, OrientationShape, toMomentObject } from "react-dates";
import Switch from "@components/ui/Switch";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
dayjs.extend(utc);
dayjs.extend(timezone);
@ -59,7 +59,17 @@ type DateOverride = {
endTime: number;
};
type EventTypeInput = {
type AdvancedOptions = {
eventName?: string;
periodType?: string;
periodDays?: number;
periodStartDate?: Date | string;
periodEndDate?: Date | string;
periodCountCalendarDays?: boolean;
requiresConfirmation?: boolean;
};
type EventTypeInput = AdvancedOptions & {
id: number;
title: string;
slug: string;
@ -67,16 +77,9 @@ type EventTypeInput = {
length: number;
hidden: boolean;
locations: unknown;
eventName: string;
customInputs: EventTypeCustomInput[];
timeZone: string;
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
periodType?: string;
periodDays?: number;
periodStartDate?: Date | string;
periodEndDate?: Date | string;
periodCountCalendarDays?: boolean;
enteredRequiresConfirmation: boolean;
};
const PERIOD_TYPES = [
@ -102,7 +105,6 @@ export default function EventTypePage({
}: Props): JSX.Element {
const router = useRouter();
console.log(eventType);
const inputOptions: OptionBase[] = [
{ value: EventTypeCustomInputType.Text, label: "Text" },
{ value: EventTypeCustomInputType.TextLong, label: "Multiline Text" },
@ -178,11 +180,11 @@ export default function EventTypePage({
);
});
const [hidden, setHidden] = useState<boolean>(eventType.hidden);
const titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>();
const descriptionRef = useRef<HTMLTextAreaElement>();
const lengthRef = useRef<HTMLInputElement>();
const isHiddenRef = useRef<HTMLInputElement>();
const requiresConfirmationRef = useRef<HTMLInputElement>();
const eventNameRef = useRef<HTMLInputElement>();
const periodDaysRef = useRef<HTMLInputElement>();
@ -199,26 +201,17 @@ export default function EventTypePage({
const enteredSlug: string = slugRef.current.value;
const enteredDescription: string = descriptionRef.current.value;
const enteredLength: number = parseInt(lengthRef.current.value);
const enteredIsHidden: boolean = isHiddenRef.current.checked;
const enteredRequiresConfirmation: boolean = requiresConfirmationRef.current.checked;
const enteredEventName: string = eventNameRef.current.value;
const type = periodType.type;
const enteredPeriodDays = parseInt(periodDaysRef?.current?.value);
const enteredPeriodDaysType = Boolean(parseInt(periodDaysTypeRef?.current.value));
const enteredPeriodStartDate = periodStartDate ? periodStartDate.toDate() : null;
const enteredPeriodEndDate = periodEndDate ? periodEndDate.toDate() : null;
console.log("values", {
type,
periodDaysTypeRef,
enteredPeriodDays,
enteredPeriodDaysType,
enteredPeriodStartDate,
enteredPeriodEndDate,
});
// TODO: Add validation
const advancedOptionsPayload: AdvancedOptions = {};
if (requiresConfirmationRef.current) {
advancedOptionsPayload.requiresConfirmation = requiresConfirmationRef.current.checked;
advancedOptionsPayload.eventName = eventNameRef.current.value;
advancedOptionsPayload.periodType = periodType.type;
advancedOptionsPayload.periodDays = parseInt(periodDaysRef?.current?.value);
advancedOptionsPayload.periodCountCalendarDays = Boolean(parseInt(periodDaysTypeRef?.current.value));
advancedOptionsPayload.periodStartDate = periodStartDate ? periodStartDate.toDate() : null;
advancedOptionsPayload.periodEndDate = periodEndDate ? periodEndDate.toDate() : null;
}
const payload: EventTypeInput = {
id: eventType.id,
@ -226,23 +219,14 @@ export default function EventTypePage({
slug: enteredSlug,
description: enteredDescription,
length: enteredLength,
hidden: enteredIsHidden,
hidden,
locations,
eventName: enteredEventName,
customInputs,
timeZone: selectedTimeZone,
periodType: type,
periodDays: enteredPeriodDays,
periodStartDate: enteredPeriodStartDate,
periodEndDate: enteredPeriodEndDate,
periodCountCalendarDays: enteredPeriodDaysType,
requiresConfirmation: enteredRequiresConfirmation,
availability: enteredAvailability || null,
...advancedOptionsPayload,
};
if (enteredAvailability) {
payload.availability = enteredAvailability;
}
await fetch("/api/availability/eventtype", {
method: "PATCH",
body: JSON.stringify(payload),
@ -251,7 +235,7 @@ export default function EventTypePage({
},
});
router.push("/availability");
router.push("/event-types");
}
async function deleteEventTypeHandler(event) {
@ -265,7 +249,7 @@ export default function EventTypePage({
},
});
router.push("/availability");
router.push("/event-types");
}
const openLocationModal = (type: LocationType) => {
@ -388,35 +372,28 @@ export default function EventTypePage({
<title>{eventType.title} | Event Type | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading={"Event Type: " + eventType.title} subtitle={eventType.description}>
<div className="block sm:flex">
<div className="w-full sm:w-10/12 mr-2">
<div className="bg-white rounded-sm border border-neutral-200 -mx-4 sm:mx-0 p-4 sm:p-8">
<form onSubmit={updateEventTypeHandler} className="space-y-4">
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<label htmlFor="title" className="flex font-medium text-neutral-700 mt-1">
<PencilIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
Title
</label>
</div>
<div className="w-full">
<Shell
heading={
<input
ref={titleRef}
type="text"
name="title"
id="title"
required
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-neutral-300 rounded-sm"
className="pl-0 text-xl font-bold text-gray-900 cursor-pointer border-none focus:ring-0 bg-transparent focus:outline-none"
placeholder="Quick Chat"
defaultValue={eventType.title}
/>
</div>
</div>
}
subtitle={eventType.description}>
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<label htmlFor="slug" className="flex font-medium text-neutral-700 mt-1">
<LinkIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
<div className="w-full sm:w-10/12 mr-2">
<div className="bg-white rounded-sm border border-neutral-200 -mx-4 sm:mx-0 p-4 sm:p-8">
<form onSubmit={updateEventTypeHandler} className="space-y-4">
<div className="block sm:flex items-center">
<div className="min-w-44 mb-4 sm:mb-0">
<label htmlFor="slug" className="text-sm flex font-medium text-neutral-700 mt-0">
<LinkIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
URL
</label>
</div>
@ -437,10 +414,41 @@ export default function EventTypePage({
</div>
</div>
</div>
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<label htmlFor="location" className="flex font-medium text-neutral-700 mt-1">
<LocationMarkerIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
<div className="block sm:flex items-center">
<div className="min-w-44 mb-4 sm:mb-0">
<label htmlFor="length" className="text-sm flex font-medium text-neutral-700 mt-0">
<ClockIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Duration
</label>
</div>
<div className="w-full">
<div className="mt-1 relative rounded-sm shadow-sm">
<input
ref={lengthRef}
type="number"
name="length"
id="length"
required
className="focus:ring-primary-500 focus:border-primary-500 block w-full pl-2 pr-12 sm:text-sm border-gray-300 rounded-sm"
placeholder="15"
defaultValue={eventType.length}
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<span className="text-gray-500 sm:text-sm" id="duration">
mins
</span>
</div>
</div>
</div>
</div>
<hr />
<div className="block sm:flex items-center">
<div className="min-w-44 mb-4 sm:mb-0">
<label htmlFor="location" className="text-sm flex font-medium text-neutral-700 mt-0">
<LocationMarkerIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Location
</label>
</div>
@ -468,19 +476,19 @@ export default function EventTypePage({
className="mb-2 p-2 border border-neutral-300 rounded-sm shadow-sm">
<div className="flex justify-between">
{location.type === LocationType.InPerson && (
<div className="flex-grow flex">
<div className="flex-grow flex items-center">
<LocationMarkerIcon className="h-6 w-6" />
<span className="ml-2 text-sm">{location.address}</span>
</div>
)}
{location.type === LocationType.Phone && (
<div className="flex-grow flex">
<div className="flex-grow flex items-center">
<PhoneIcon className="h-6 w-6" />
<span className="ml-2 text-sm">Phone call</span>
</div>
)}
{location.type === LocationType.GoogleMeet && (
<div className="flex-grow flex">
<div className="flex-grow flex items-center">
<svg
className="h-6 w-6"
viewBox="0 0 64 54"
@ -511,7 +519,7 @@ export default function EventTypePage({
</div>
)}
{location.type === LocationType.Zoom && (
<div className="flex-grow flex">
<div className="flex-grow flex items-center">
<svg
className="h-6 w-6"
viewBox="0 0 64 64"
@ -555,10 +563,12 @@ export default function EventTypePage({
<li>
<button
type="button"
className="sm:flex sm:items-start text-sm text-primary-600"
className="bg-neutral-100 rounded-sm py-2 px-3 flex"
onClick={() => setShowLocationModal(true)}>
<PlusIcon className="h-5 w-5" />
<span className="font-medium">Add another location option</span>
<PlusIcon className="h-4 w-4 mt-0.5 text-neutral-900" />
<span className="ml-1 text-neutral-700 text-sm font-medium">
Add another location
</span>
</button>
</li>
)}
@ -566,37 +576,13 @@ export default function EventTypePage({
)}
</div>
</div>
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<label htmlFor="length" className="flex font-medium text-neutral-700 mt-1">
<ClockIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
Duration
</label>
</div>
<div className="w-full">
<div className="mt-1 relative rounded-sm shadow-sm">
<input
ref={lengthRef}
type="number"
name="length"
id="length"
required
className="focus:ring-primary-500 focus:border-primary-500 block w-full pl-2 pr-12 sm:text-sm border-gray-300 rounded-sm"
placeholder="15"
defaultValue={eventType.length}
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<span className="text-gray-500 sm:text-sm" id="duration">
mins
</span>
</div>
</div>
</div>
</div>
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<label htmlFor="description" className="flex font-medium text-neutral-700 mt-1">
<DocumentIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
<hr className="border-neutral-200" />
<div className="block sm:flex items-center">
<div className="min-w-44 mb-4 sm:mb-0">
<label htmlFor="description" className="text-sm flex font-medium text-neutral-700 mt-0">
<DocumentIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Description
</label>
</div>
@ -620,9 +606,11 @@ export default function EventTypePage({
<span className="text-neutral-700 text-sm font-medium">Show advanced settings</span>
</Disclosure.Button>
<Disclosure.Panel className="space-y-4">
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<label htmlFor="eventName" className="flex font-medium text-neutral-700 mt-2">
<div className="block sm:flex items-center">
<div className="min-w-44 mb-4 sm:mb-0">
<label
htmlFor="eventName"
className="text-sm flex font-medium text-neutral-700 mt-2">
Event name
</label>
</div>
@ -640,11 +628,11 @@ export default function EventTypePage({
</div>
</div>
</div>
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<div className="block sm:flex items-center">
<div className="min-w-44 mb-4 sm:mb-0">
<label
htmlFor="additionalFields"
className="flex font-medium text-neutral-700 mt-2">
className="text-sm flex font-medium text-neutral-700 mt-2">
Additional inputs
</label>
</div>
@ -694,38 +682,11 @@ export default function EventTypePage({
</ul>
</div>
</div>
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<label htmlFor="hidden" className="flex font-medium text-neutral-700">
Hide event type
</label>
</div>
<div className="w-full">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
ref={isHiddenRef}
id="ishidden"
name="ishidden"
type="checkbox"
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
defaultChecked={eventType.hidden}
/>
</div>
<div className="ml-3 text-sm">
<p className="text-neutral-900">
Hide the event type from your page, so it can only be booked through its
URL.
</p>
</div>
</div>
</div>
</div>
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<div className="block sm:flex items-center">
<div className="min-w-44 mb-4 sm:mb-0">
<label
htmlFor="requiresConfirmation"
className="flex font-medium text-neutral-700">
className="text-sm flex font-medium text-neutral-700">
Opt-in booking
</label>
</div>
@ -750,11 +711,14 @@ export default function EventTypePage({
</div>
</div>
</div>
<hr className="border-neutral-200" />
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<div className="min-w-44 mb-4 sm:mb-0">
<label
htmlFor="inviteesCanSchedule"
className="flex font-medium text-neutral-700 mt-2">
className="text-sm flex font-medium text-neutral-700 mt-2">
Invitees can schedule
</label>
</div>
@ -849,9 +813,14 @@ export default function EventTypePage({
</RadioGroup>
</div>
</div>
<hr className="border-neutral-200" />
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0">
<label htmlFor="availability" className="flex font-medium text-neutral-700 mt-2">
<div className="min-w-44 mb-4 sm:mb-0">
<label
htmlFor="availability"
className="text-sm flex font-medium text-neutral-700 mt-2">
Availability
</label>
</div>
@ -885,6 +854,12 @@ export default function EventTypePage({
</div>
<div className="w-full sm:w-2/12 ml-2 px-4 mt-8 sm:mt-0 min-w-32">
<div className="space-y-4">
<Switch
name="isHidden"
defaultChecked={hidden}
onCheckedChange={setHidden}
label="Hide event type"
/>
<a
href={"/" + user.username + "/" + eventType.slug}
target="_blank"
@ -904,13 +879,20 @@ export default function EventTypePage({
<LinkIcon className="w-4 h-4 mt-1 mr-2 text-neutral-500" />
Copy link
</button>
<button
onClick={deleteEventTypeHandler}
type="button"
className="flex text-md font-medium text-neutral-700">
<Dialog>
<DialogTrigger className="flex text-md font-medium text-neutral-700">
<TrashIcon className="w-4 h-4 mt-1 mr-2 text-neutral-500" />
Delete
</button>
</DialogTrigger>
<ConfirmationDialogContent
alert="danger"
title="Delete Event Type"
confirmBtnText="Yes, delete event type"
onConfirm={deleteEventTypeHandler}>
Are you sure you want to delete this event type? Anyone who you&apos;ve shared this link
with will no longer be able to book using it.
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</div>

View file

@ -1,12 +1,7 @@
import Head from "next/head";
import Link from "next/link";
import prisma from "../../lib/prisma";
import Shell from "../../components/Shell";
import { useRouter } from "next/router";
import { getSession, useSession } from "next-auth/client";
import React, { Fragment, useRef } from "react";
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@components/Dialog";
import { Tooltip } from "@components/Tooltip";
import Loader from "@components/Loader";
import { Menu, Transition } from "@headlessui/react";
import {
ClockIcon,
DotsHorizontalIcon,
@ -16,9 +11,14 @@ import {
PlusIcon,
UserIcon,
} from "@heroicons/react/solid";
import Loader from "@components/Loader";
import classNames from "@lib/classNames";
import { Dialog, DialogContent, DialogTrigger, DialogClose } from "@components/Dialog";
import { getSession, useSession } from "next-auth/client";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, useRef } from "react";
import Shell from "../../components/Shell";
import prisma from "../../lib/prisma";
export default function Availability({ user, types }) {
const [session, loading] = useSession();
@ -73,8 +73,8 @@ export default function Availability({ user, types }) {
New event type
</DialogTrigger>
<DialogContent>
<div className="mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
<div className="mb-8">
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
Add a new event type
</h3>
<div>
@ -153,7 +153,7 @@ export default function Availability({ user, types }) {
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<div className="mt-8 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Continue
</button>
@ -176,15 +176,15 @@ export default function Availability({ user, types }) {
heading="Event Types"
subtitle="Create events to share for people to book on your calendar."
CTA={types.length !== 0 && <CreateNewEventDialog />}>
<div className="bg-white shadow overflow-hidden sm:rounded-sm -mx-4 sm:mx-0">
<div className="bg-white border border-gray-200 rounded-sm overflow-hidden -mx-4 sm:mx-0">
<ul className="divide-y divide-neutral-200">
{types.map((type) => (
<li key={type.id}>
<Link href={"/event-types/" + type.id}>
<a className="block hover:bg-neutral-50">
<div className="hover:bg-neutral-50">
<div className="px-4 py-4 flex items-center sm:px-6">
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate">
<Link href={"/event-types/" + type.id}>
<a className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<span className="truncate ">
<div className="flex text-sm">
<p className="font-medium text-neutral-900 truncate">{type.title}</p>
{type.hidden && (
@ -196,52 +196,63 @@ export default function Availability({ user, types }) {
<div className="mt-2 flex space-x-4">
<div className="flex items-center text-sm text-neutral-500">
<ClockIcon
className="flex-shrink-0 mr-1.5 h-5 w-5 text-neutral-400"
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
aria-hidden="true"
/>
<p>{type.length}m</p>
</div>
<div className="flex items-center text-sm text-neutral-500">
<UserIcon
className="flex-shrink-0 mr-1.5 h-5 w-5 text-neutral-400"
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
aria-hidden="true"
/>
<p>1-on-1</p>
</div>
<div className="flex items-center text-sm text-neutral-500">
<InformationCircleIcon
className="flex-shrink-0 mr-1.5 h-5 w-5 text-neutral-400"
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
aria-hidden="true"
/>
<p>{type.description.substring(0, 100)}</p>
<div className="max-w-32 sm:max-w-full truncate">
{type.description.substring(0, 100)}
</div>
</div>
</div>
<div className="mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
<div className="flex overflow-hidden space-x-5">
<Link href={"/" + session.user.username + "/" + type.slug}>
<a className="text-neutral-400">
<ExternalLinkIcon className="w-5 h-5" />
</span>
</a>
</Link>
<div className="hidden sm:flex mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
<div className="flex overflow-hidden space-x-5">
<Tooltip content="Preview">
<a
href={"/" + session.user.username + "/" + type.slug}
target="_blank"
rel="noreferrer"
className="group cursor-pointer text-neutral-400 p-2 border border-transparent hover:border-gray-200">
<ExternalLinkIcon className="group-hover:text-black w-5 h-5" />
</a>
</Tooltip>
<Tooltip content="Copy link">
<button
onClick={() => {
navigator.clipboard.writeText(
window.location.hostname + "/" + session.user.username + "/" + type.slug
);
}}
className="text-neutral-400">
<LinkIcon className="w-5 h-5" />
className="group text-neutral-400 p-2 border border-transparent hover:border-gray-200">
<LinkIcon className="group-hover:text-black w-5 h-5" />
</button>
</Tooltip>
</div>
</div>
</div>
<div className="ml-5 flex-shrink-0">
<div className="flex sm:hidden ml-5 flex-shrink-0">
<Menu as="div" className="inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1">
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
<span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
@ -271,7 +282,7 @@ export default function Availability({ user, types }) {
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<ExternalLinkIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Preview
@ -295,7 +306,7 @@ export default function Availability({ user, types }) {
"group flex items-center px-4 py-2 text-sm w-full font-medium"
)}>
<LinkIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Copy link to event
@ -311,7 +322,7 @@ export default function Availability({ user, types }) {
{/* "group flex items-center px-4 py-2 text-sm font-medium"*/}
{/* )}>*/}
{/* <DuplicateIcon*/}
{/* className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"*/}
{/* className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* Duplicate*/}
@ -344,8 +355,7 @@ export default function Availability({ user, types }) {
</Menu>
</div>
</div>
</a>
</Link>
</div>
</li>
))}
</ul>

View file

@ -7,9 +7,7 @@ function RedirectPage() {
router.push("/event-types");
return;
}
return (
<Loader/>
);
return <Loader />;
}
RedirectPage.getInitialProps = (ctx) => {

View file

@ -4,16 +4,18 @@ import { getIntegrationName, getIntegrationType } from "../../lib/integrations";
import Shell from "../../components/Shell";
import { useState } from "react";
import { useRouter } from "next/router";
import { useSession, getSession } from "next-auth/client";
import Loader from '@components/Loader';
import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader";
export default function integration(props) {
export default function Integration(props) {
const router = useRouter();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
const [showAPIKey, setShowAPIKey] = useState(false);
if (loading) {
return <Loader/>;
return <Loader />;
}
function toggleShowAPIKey() {
@ -23,6 +25,7 @@ export default function integration(props) {
async function deleteIntegrationHandler(event) {
event.preventDefault();
/*eslint-disable */
const response = await fetch("/api/integrations", {
method: "DELETE",
body: JSON.stringify({ id: props.integration.id }),
@ -30,6 +33,7 @@ export default function integration(props) {
"Content-Type": "application/json",
},
});
/*eslint-enable */
router.push("/integrations");
}
@ -37,27 +41,27 @@ export default function integration(props) {
return (
<div>
<Head>
<title>{getIntegrationName(props.integration.type)} | Integrations | Calendso</title>
<title>{getIntegrationName(props.integration.type)} App | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading={getIntegrationName(props.integration.type)} subtitle="Manage and delete integrations.">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 bg-white shadow overflow-hidden rounded-sm">
<Shell heading={getIntegrationName(props.integration.type)} subtitle="Manage and delete this app.">
<div className="block sm:grid grid-cols-3 gap-4">
<div className="col-span-2 bg-white border border-gray-200 mb-6 overflow-hidden rounded-sm">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Integration Details</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Information about your {getIntegrationName(props.integration.type)} integration.
Information about your {getIntegrationName(props.integration.type)} App.
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<dl className="grid gap-y-8">
<div>
<dt className="text-sm font-medium text-gray-500">Integration name</dt>
<dt className="text-sm font-medium text-gray-500">App name</dt>
<dd className="mt-1 text-sm text-gray-900">{getIntegrationName(props.integration.type)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Integration type</dt>
<dt className="text-sm font-medium text-gray-500">App Category</dt>
<dd className="mt-1 text-sm text-gray-900">{getIntegrationType(props.integration.type)}</dd>
</div>
<div>
@ -87,18 +91,18 @@ export default function integration(props) {
</div>
</div>
<div>
<div className="bg-white shadow rounded-sm">
<div className="bg-white border border-gray-200 mb-6 rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete this integration</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete this app</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>Once you delete this integration, it will be permanently removed.</p>
<p>Once you delete this app, it will be permanently removed.</p>
</div>
<div className="mt-5">
<button
onClick={deleteIntegrationHandler}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-sm 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">
Delete integration
Delete App
</button>
</div>
</div>
@ -111,6 +115,7 @@ export default function integration(props) {
}
export async function getServerSideProps(context) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const session = await getSession(context);
const integration = await prisma.credential.findFirst({

View file

@ -4,33 +4,17 @@ import prisma from "../../lib/prisma";
import Shell from "../../components/Shell";
import { useEffect, useState } from "react";
import { getSession, useSession } from "next-auth/client";
import {
CalendarIcon,
CheckCircleIcon,
ChevronRightIcon,
PlusIcon,
XCircleIcon,
} from "@heroicons/react/solid";
import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid";
import { InformationCircleIcon } from "@heroicons/react/outline";
import { Switch } from "@headlessui/react";
import Loader from '@components/Loader';
import classNames from "@lib/classNames";
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
import Switch from "@components/ui/Switch";
import Loader from "@components/Loader";
export default function Home({ integrations }) {
export default function IntegrationHome({ integrations }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
const [showAddModal, setShowAddModal] = useState(false);
const [showSelectCalendarModal, setShowSelectCalendarModal] = useState(false);
const [selectableCalendars, setSelectableCalendars] = useState([]);
function toggleAddModal() {
setShowAddModal(!showAddModal);
}
function toggleShowCalendarModal() {
setShowSelectCalendarModal(!showSelectCalendarModal);
}
function loadCalendars() {
fetch("api/availability/calendar")
.then((response) => response.json())
@ -47,17 +31,15 @@ export default function Home({ integrations }) {
function calendarSelectionHandler(calendar) {
return (selected) => {
const cals = [...selectableCalendars];
const i = cals.findIndex((c) => c.externalId === calendar.externalId);
cals[i].selected = selected;
setSelectableCalendars(cals);
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
selectableCalendars[i].selected = selected;
if (selected) {
fetch("api/availability/calendar", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(cals[i]),
body: JSON.stringify(selectableCalendars[i]),
}).then((response) => response.json());
} else {
fetch("api/availability/calendar", {
@ -65,7 +47,7 @@ export default function Home({ integrations }) {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(cals[i]),
body: JSON.stringify(selectableCalendars[i]),
}).then((response) => response.json());
}
};
@ -82,40 +64,117 @@ export default function Home({ integrations }) {
}
}
function onCloseSelectCalendar() {
setSelectableCalendars([...selectableCalendars]);
}
useEffect(loadCalendars, [integrations]);
if (loading) {
return (
<Loader/>
);
return <Loader />;
}
const ConnectNewAppDialog = () => (
<Dialog>
<DialogTrigger className="py-2 px-4 mt-6 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
<PlusIcon className="w-5 h-5 mr-1 inline" />
Connect a new App
</DialogTrigger>
<DialogContent>
<DialogHeader title="Connect a new App" subtitle="Connect a new app to your account." />
<div className="my-4">
<ul className="divide-y divide-gray-200">
{integrations
.filter((integration) => integration.installed)
.map((integration) => (
<li key={integration.type} className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
</div>
<div className="w-10/12">
<h2 className="text-gray-800 font-medium">{integration.title}</h2>
<p className="text-gray-400 text-sm">{integration.description}</p>
</div>
<div className="w-2/12 text-right pt-2">
<button
onClick={() => integrationHandler(integration.type)}
className="font-medium text-neutral-900 hover:text-neutral-500">
Add
</button>
</div>
</li>
))}
</ul>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose as="button" className="btn btn-white mx-2">
Cancel
</DialogClose>
</div>
</DialogContent>
</Dialog>
);
const SelectCalendarDialog = () => (
<Dialog onOpenChange={(open) => !open && onCloseSelectCalendar()}>
<DialogTrigger className="py-2 px-4 mt-6 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
Select calendars
</DialogTrigger>
<DialogContent>
<DialogHeader
title="Select calendars"
subtitle="If no entry is selected, all calendars will be checked"
/>
<div className="my-4">
<ul className="divide-y divide-gray-200">
{selectableCalendars.map((calendar) => (
<li key={calendar.name} className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img
className="h-8 w-8 mr-2"
src={getCalendarIntegrationImage(calendar.integration)}
alt={calendar.integration}
/>
</div>
<div className="w-10/12 pt-3">
<h2 className="text-gray-800 font-medium">{calendar.name}</h2>
</div>
<div className="w-2/12 text-right pt-3">
<Switch
defaultChecked={calendar.selected}
onCheckedChange={calendarSelectionHandler(calendar)}
/>
</div>
</li>
))}
</ul>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose as="button" className="btn btn-white mx-2">
Cancel
</DialogClose>
</div>
</DialogContent>
</Dialog>
);
return (
<div>
<Head>
<title>Integrations | Calendso</title>
<title>App Store | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell
heading="Integrations"
subtitle="Connect your favourite apps."
CTA={
<button
onClick={toggleAddModal}
type="button"
className="flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
<PlusIcon className="w-5 h-5 mr-1" />
Add new integration
</button>
}>
<div className="bg-white shadow overflow-hidden rounded-sm mb-8">
<Shell heading="App Store" subtitle="Connect your favourite apps." CTA={<ConnectNewAppDialog />}>
<div className="bg-white border border-gray-200 overflow-hidden rounded-sm mb-8">
{integrations.filter((ig) => ig.credential).length !== 0 ? (
<ul className="divide-y divide-gray-200">
{integrations
.filter((ig) => ig.credential)
.map((ig) => (
<li key={ig.id}>
<li key={ig.credential.id}>
<Link href={"/integrations/" + ig.credential.id}>
<a className="block hover:bg-gray-50">
<div className="flex items-center px-4 py-4 sm:px-6">
@ -168,224 +227,41 @@ export default function Home({ integrations }) {
</div>
<div className="py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
You don&apos;t have any integrations added.
You don&apos;t have any apps connected.
</h3>
<div className="mt-2 text-sm text-gray-500">
<p>
You currently do not have any integrations set up. Add your first integration to get
started.
You currently do not have any apps connected. Connect your first app to get started.
</p>
</div>
<div className="mt-3 text-sm">
<button
onClick={toggleAddModal}
className="font-medium text-neutral-900 hover:text-neutral-500">
{" "}
Add your first integration <span aria-hidden="true">&rarr;</span>
</button>
</div>
<ConnectNewAppDialog />
</div>
</div>
</div>
)}
</div>
{showAddModal && (
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* <!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
--> */}
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
{/* <!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
--> */}
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<PlusIcon className="h-6 w-6 text-neutral-900" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Add a new integration
</h3>
<div>
<p className="text-sm text-gray-400">Link a new integration to your account.</p>
</div>
</div>
</div>
<div className="my-4">
<ul className="divide-y divide-gray-200">
{integrations
.filter((integration) => integration.installed)
.map((integration) => (
<li key={integration.type} className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img
className="h-8 w-8 mr-2"
src={integration.imageSrc}
alt={integration.title}
/>
</div>
<div className="w-10/12">
<h2 className="text-gray-800 font-medium">{integration.title}</h2>
<p className="text-gray-400 text-sm">{integration.description}</p>
</div>
<div className="w-2/12 text-right pt-2">
<button
onClick={() => integrationHandler(integration.type)}
className="font-medium text-neutral-900 hover:text-neutral-500">
Add
</button>
</div>
</li>
))}
</ul>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
onClick={toggleAddModal}
type="button"
className="mt-3 w-full inline-flex justify-center rounded-sm border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:mt-0 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
)}
<div className="bg-white shadow rounded-sm">
<div className="bg-white border border-gray-200 rounded-sm mb-8">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Select calendars</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>Select which calendars are checked for availability to prevent double bookings.</p>
</div>
<SelectCalendarDialog />
</div>
</div>
<div className="border border-gray-200 rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Launch your own App</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>If you want to add your own App here, get in touch with us.</p>
</div>
<div className="mt-5">
<button type="button" onClick={toggleShowCalendarModal} className="btn btn-primary">
Select calendars
</button>
<a href="mailto:apps@calendso.com" className="btn btn-white">
Contact us
</a>
</div>
</div>
</div>
{showSelectCalendarModal && (
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* <!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
--> */}
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
{/* <!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
--> */}
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<CalendarIcon className="h-6 w-6 text-neutral-900" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Select calendars
</h3>
<div>
<p className="text-sm text-gray-400">
If no entry is selected, all calendars will be checked
</p>
</div>
</div>
</div>
<div className="my-4">
<ul className="divide-y divide-gray-200">
{selectableCalendars.map((calendar) => (
<li key={calendar.name} className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img
className="h-8 w-8 mr-2"
src={getCalendarIntegrationImage(calendar.integration)}
alt={calendar.integration}
/>
</div>
<div className="w-10/12 pt-3">
<h2 className="text-gray-800 font-medium">{calendar.name}</h2>
</div>
<div className="w-2/12 text-right pt-3">
<Switch
checked={calendar.selected}
onChange={calendarSelectionHandler(calendar)}
className={classNames(
calendar.selected ? "bg-neutral-900" : "bg-gray-200",
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500"
)}>
<span className="sr-only">Select calendar</span>
<span
aria-hidden="true"
className={classNames(
calendar.selected ? "translate-x-5" : "translate-x-0",
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
</div>
</li>
))}
</ul>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
onClick={toggleShowCalendarModal}
type="button"
className="mt-3 w-full inline-flex justify-center rounded-sm border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:mt-0 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
)}
</Shell>
</div>
);

View file

@ -1,18 +1,16 @@
import Head from "next/head";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/router";
import prisma from "../../lib/prisma";
import Shell from "../../components/Shell";
import SettingsShell from "../../components/Settings";
import { useSession, getSession } from "next-auth/client";
import Loader from '@components/Loader';
import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader";
export default function Embed(props) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
if (loading) {
return <Loader/>;
return <Loader />;
}
return (

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 { useSession, getSession } from "next-auth/client";
import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader";
export default function Settings() {

View file

@ -8,6 +8,7 @@ import { getSession, useSession } from "next-auth/client";
import { UsersIcon } from "@heroicons/react/outline";
import TeamList from "../../components/team/TeamList";
import TeamListItem from "../../components/team/TeamListItem";
import Loader from "@components/Loader";
export default function Teams() {
const [, loading] = useSession();
@ -38,7 +39,7 @@ export default function Teams() {
}, []);
if (loading) {
return <p className="text-gray-400">Loading...</p>;
return <Loader />;
}
const createTeam = (e) => {

View file

@ -4,7 +4,7 @@ import prisma, { whereAndSelect } from "../lib/prisma";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { CheckIcon } from "@heroicons/react/outline";
import { CalendarIcon, ClockIcon, LocationMarkerIcon } from "@heroicons/react/solid";
import { ClockIcon } from "@heroicons/react/solid";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import toArray from "dayjs/plugin/toArray";
@ -60,7 +60,7 @@ export default function Success(props) {
return (
isReady && (
<div>
<div className="bg-neutral-50 dark:bg-neutral-900 h-screen">
<Head>
<title>
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"} | {eventName} |
@ -68,7 +68,7 @@ export default function Success(props) {
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-24">
<main className="max-w-3xl mx-auto py-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">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
@ -76,61 +76,61 @@ export default function Success(props) {
&#8203;
</span>
<div
className="inline-block align-bottom dark:bg-gray-800 bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
className="inline-block align-bottom dark:bg-gray-800 bg-white rounded-sm px-8 pt-5 pb-4 text-left overflow-hidden border border-neutral-200 dark:border-neutral-700 transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:py-6"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
{!props.eventType.requiresConfirmation && (
<CheckIcon className="h-6 w-6 text-green-600" />
<CheckIcon className="h-8 w-8 text-green-600" />
)}
{props.eventType.requiresConfirmation && (
<ClockIcon className="h-6 w-6 text-green-600" />
<ClockIcon className="h-8 w-8 text-green-600" />
)}
</div>
<div className="mt-3 text-center sm:mt-5">
<h3
className="text-lg leading-6 font-medium dark:text-white text-gray-900"
className="text-2xl leading-6 font-semibold dark:text-white text-neutral-900"
id="modal-headline">
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"}
{props.eventType.requiresConfirmation ? "Submitted" : "This meeting is scheduled"}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-300">
<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.`
: `You are scheduled in with ${props.user.name || props.user.username}.`}
: `We emailed you and the other attendees a calendar invitation with all the details.`}
</p>
</div>
<div className="mt-4 border-t border-b dark:border-gray-900 py-4">
<h2 className="text-lg font-medium text-gray-600 dark:text-gray-100 mb-2">
{eventName}
</h2>
<p className="text-gray-500 dark:text-gray-50 mb-1">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
<div className="mt-4 text-gray-700 dark:text-gray-300 border-t border-b dark:border-gray-900 py-4 grid grid-cols-3 text-left">
<div className="font-medium">What</div>
<div className="mb-6 col-span-2">{eventName}</div>
<div className="font-medium">When</div>
<div className="mb-6 col-span-2">
{date.format("dddd, DD MMMM YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
<span className="text-gray-500">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
{location && (
<p className="text-gray-500 mb-1">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{location}
</p>
<>
<div className="font-medium">Where</div>
<div className="col-span-2">{location}</div>
</>
)}
<p className="text-gray-500 dark:text-gray-50">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{date.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
</p>
</div>
</div>
</div>
{!props.eventType.requiresConfirmation && (
<div className="mt-5 sm:mt-0 pt-2 text-center">
<span className="font-medium text-gray-500 dark:text-gray-50">
Add to your calendar
<div className="mt-5 sm:mt-0 sm:pt-4 pt-2 text-center flex">
<span className="font-medium text-gray-700 dark:text-gray-50 flex self-center mr-6">
Add to calendar
</span>
<div className="flex mt-2">
<div className="flex">
<Link
href={
`https://calendar.google.com/calendar/r/eventedit?dates=${date
@ -142,9 +142,9 @@ export default function Success(props) {
props.eventType.description
}` + (location ? "&location=" + encodeURIComponent(location) : "")
}>
<a className="mx-2 btn-wide btn-white">
<a className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3">
<svg
className="inline-block w-4 h-4 mr-1 -mt-1"
className="inline-block w-4 h-4 -mt-1"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
@ -166,7 +166,9 @@ export default function Success(props) {
eventName
) + (location ? "&location=" + location : "")
}>
<a className="mx-2 btn-wide btn-white" target="_blank">
<a
className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3"
target="_blank">
<svg
className="inline-block w-4 h-4 mr-1 -mt-1"
fill="currentColor"
@ -190,7 +192,9 @@ export default function Success(props) {
eventName
) + (location ? "&location=" + location : "")
}>
<a className="mx-2 btn-wide btn-white" target="_blank">
<a
className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3"
target="_blank">
<svg
className="inline-block w-4 h-4 mr-1 -mt-1"
fill="currentColor"
@ -202,7 +206,9 @@ export default function Success(props) {
</a>
</Link>
<Link href={"data:text/calendar," + eventLink()}>
<a className="mx-2 btn-wide btn-white" download={props.eventType.title + ".ics"}>
<a
className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3"
download={props.eventType.title + ".ics"}>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "location" TEXT;

View file

@ -133,6 +133,7 @@ model Booking {
endTime DateTime
attendees Attendee[]
location String?
createdAt DateTime @default(now())
updatedAt DateTime?

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;}

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;}

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#104D86;}
.st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;}
</style>
<path class="st0" d="M27.5,88.2c-4.9,0-9.7-1.2-14-3.6c-4.2-2.4-7.6-5.8-9.9-10c-4.8-8.8-4.8-19.4,0-28.2c2.3-4.2,5.8-7.7,10-10
c4.3-2.4,9.1-3.7,14-3.6c6-0.1,11.8,1.7,16.5,5.3s8,8.7,9.9,15.4H42.8c-1.3-3-3.4-5.5-6.2-7.2c-2.6-1.6-5.6-2.5-8.7-2.5

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F68D2E;}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -10,8 +10,8 @@
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1" id="Livello_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2228.833 2073.333"
<svg version="1.1" id="Livello_1"
xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 2228.833 2073.333"
enable-background="new 0 0 2228.833 2073.333" xml:space="preserve">
<metadata>
<sfw xmlns="&ns_sfw;">

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -1 +1 @@
<svg height="64" viewBox="0 0 32 32" width="64" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><clipPath id="a"><path d="m-200-175h1000v562h-1000z"/></clipPath><clipPath id="b"><circle cx="107" cy="106" r="102"/></clipPath><clipPath id="c"><circle cx="107" cy="106" r="100"/></clipPath><clipPath id="d"><circle cx="107" cy="106" r="92"/></clipPath><clipPath id="e"><path clipRule="evenodd" d="m135 94.06 26-19c2.27-1.85 4-1.42 4 2v57.94c0 3.84-2.16 3.4-4 2l-26-19zm-88-16.86v43.2a17.69 17.69 0 0 0 17.77 17.6h63a3.22 3.22 0 0 0 3.23-3.2v-43.2a17.69 17.69 0 0 0 -17.77-17.6h-63a3.22 3.22 0 0 0 -3.23 3.2z"/></clipPath><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 61h366v90h-366z" fill="#4a8cff"/></g><g clip-path="url(#a)" transform="matrix(.156863 0 0 .156863 -.784314 -.627496)"><g clip-path="url(#b)"><path d="m0-1h214v214h-214z" fill="#e5e5e4"/></g><g clip-path="url(#c)"><path d="m2 1h210v210h-210z" fill="#fff"/></g><g clip-path="url(#d)"><path d="m10 9h194v194h-194z" fill="#4a8cff"/></g><g clip-path="url(#e)"><path d="m42 69h128v74h-128z" fill="#fff"/></g></g><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 19.25h180v38.17h-180z" fill="#90908f"/></g></svg>
<svg height="64" viewBox="0 0 32 32" width="64" xmlns="http://www.w3.org/2000/svg"><clipPath id="a"><path d="m-200-175h1000v562h-1000z"/></clipPath><clipPath id="b"><circle cx="107" cy="106" r="102"/></clipPath><clipPath id="c"><circle cx="107" cy="106" r="100"/></clipPath><clipPath id="d"><circle cx="107" cy="106" r="92"/></clipPath><clipPath id="e"><path clipRule="evenodd" d="m135 94.06 26-19c2.27-1.85 4-1.42 4 2v57.94c0 3.84-2.16 3.4-4 2l-26-19zm-88-16.86v43.2a17.69 17.69 0 0 0 17.77 17.6h63a3.22 3.22 0 0 0 3.23-3.2v-43.2a17.69 17.69 0 0 0 -17.77-17.6h-63a3.22 3.22 0 0 0 -3.23 3.2z"/></clipPath><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 61h366v90h-366z" fill="#4a8cff"/></g><g clip-path="url(#a)" transform="matrix(.156863 0 0 .156863 -.784314 -.627496)"><g clip-path="url(#b)"><path d="m0-1h214v214h-214z" fill="#e5e5e4"/></g><g clip-path="url(#c)"><path d="m2 1h210v210h-210z" fill="#fff"/></g><g clip-path="url(#d)"><path d="m10 9h194v194h-194z" fill="#4a8cff"/></g><g clip-path="url(#e)"><path d="m42 69h128v74h-128z" fill="#fff"/></g></g><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 19.25h180v38.17h-180z" fill="#90908f"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -81,6 +81,102 @@
}
}
/* !important to style multi-email input */
::-moz-selection {
color: white;
background: black;
}
::selection {
color: white;
background: black;
}
/* add padding bottom to bottom nav on standalone mode */
@media all and (display-mode: standalone) {
.bottom-nav {
padding-bottom: 24px;
}
}
.react-multi-email > [type='text'] {
@apply 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;
}
.react-multi-email {
margin: 0;
max-width: 100%;
-webkit-box-flex: 1;
-ms-flex: 1 0 auto;
flex: 1 0 auto;
text-align: left;
line-height: 1.25rem;
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
align-content: flex-start;
padding-top: 0.1rem !important;
padding-bottom: 0.1rem !important;
padding-left: 0.75rem !important;
@apply dark:border-black border-white dark:bg-black bg-white;
}
.react-multi-email > [type='text']{
box-shadow: none !important;
}
.react-multi-email.focused{
margin: -1px;
border: 2px solid #000 !important;
@apply dark:bg-black
}
.react-multi-email > [type='text']:focus{
box-shadow: none !important;
}
.react-multi-email > span[data-placeholder] {
display: none;
position: absolute;
left: 0.8rem;
top: 0.75rem;
line-height: 1.25rem;
font-size: 0.875rem;
}
.react-multi-email.empty > span[data-placeholder] {
display: inline;
color: #000;
}
.react-multi-email.focused > span[data-placeholder] {
display: none;
}
.react-multi-email > input {
width: 100% !important;
display: inline-block !important;
@apply mt-1;
}
.react-multi-email [data-tag] {
box-shadow: none !important;
@apply inline-flex items-center px-2 py-1 my-1 mr-2 border border-transparent text-sm font-medium rounded-md text-gray-900 dark:text-white bg-neutral-200 hover:bg-neutral-100 dark:bg-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;
}
.react-multi-email [data-tag] [data-tag-item] {
max-width: 100%;
overflow: hidden;
}
.react-multi-email [data-tag] [data-tag-handle] {
margin-left: 0.833em;
cursor: pointer;
}
/* !important to override react-select */
.react-select__value-container{
border: 0 !important;
@ -140,7 +236,8 @@
height: 30px;
margin: 60px auto;
position: relative;
border: 4px solid #000;
border-width: 4px;
border-style: solid;
animation: loader 2s infinite ease;
}
@ -148,7 +245,6 @@
vertical-align: top;
display: inline-block;
width: 100%;
background-color: #000;
animation: loader-inner 2s infinite ease-in;
}

View file

@ -5,6 +5,19 @@ module.exports = {
theme: {
extend: {
colors: {
black: "#111111",
gray: {
50: "#F8F8F8",
100: "#F5F5F5",
200: "#E1E1E1",
300: "#CFCFCF",
400: "#ACACAC",
500: "#888888",
600: "#494949",
700: "#3E3E3E",
800: "#313131",
900: "#292929",
},
neutral: {
50: "#F7F8F9",
100: "#F4F5F6",

162
yarn.lock
View file

@ -753,6 +753,14 @@
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.26.0-23.9b816b3aa13cc270074f172f30d6eda8a8ce867d.tgz#cfdacfad3acc0f3bf1d7710aa8f3852fd85ac6d9"
integrity sha512-a0jIhLvw9rFh6nZTr5Y3uzP28I2xNDu3pqxANvwMNnmIoYr1wYEcO1pMXn/36BGXldDdAWMmAbhfloHA3IB8DA==
"@radix-ui/popper@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@radix-ui/popper/-/popper-0.0.10.tgz#9f707d9cec8762423f81acaf8e650e40a554cb73"
integrity sha512-YFKuPqQPKscreQid7NuB4it3PMzSwGg03vgrud6sVliHkI43QNAOHyrHyMNo015jg6QK5GVDn+7J2W5uygqSGA==
dependencies:
"@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"
@ -760,6 +768,15 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-arrow@0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-0.0.15.tgz#2fb7e4cab626f87d4f7a403672c57bce74b0a7b4"
integrity sha512-lw3/3nPmEeK67IgndT764w/65EMm5psXnr2efCeo0eWOERTnFAswNka2bKJUSKY02FHECkH4qVzhwupFyeYv0g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-collapsible@^0.0.16":
version "0.0.16"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-0.0.16.tgz#6a99068f70bb85a60f8cbd43f093bd3053ab61cc"
@ -850,11 +867,43 @@
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"
integrity sha512-p1nM6z2rLkstfHVsqSxcDMn0eAGXkx/G5e4XIGmOCxYa/7EkOQ+lBz0+/7sk+Ut+8B37h7d0bfxnzr3ILVxJUw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "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-polymorphic@0.0.12":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@radix-ui/react-polymorphic/-/react-polymorphic-0.0.12.tgz#bf4ae516669b68e059549538104d97322f7c876b"
integrity sha512-/GYNMicBnGzjD1d2fCAuzql1VeFrp8mqM3xfzT1kxhnV85TKdURO45jBfMgqo17XNXoNhWIAProUsCO4qFAAIg==
"@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"
integrity sha512-0sGqBp+v9/yrsMhPfAejxcem2MwAFgaSAxF3Sieaklm6ZVYM/hTZxxWI5NVOLGV+482GwW0wIqwpVUzREjmh+w==
"@radix-ui/react-popper@0.0.18":
version "0.0.18"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-0.0.18.tgz#e85ec077c18ffca92ce97cc19586dcc6f022fffb"
integrity sha512-j8nPqX5scAmeGuyW9VQv+M4MkKsV/ulR1Yt0eu13LyGLT3L7FM2YBMt3KlbpUxrT3mrNGC0eEQAiVgm/G3/fGQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/popper" "0.0.10"
"@radix-ui/react-arrow" "0.0.15"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-context" "0.0.5"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-use-rect" "0.0.7"
"@radix-ui/react-use-size" "0.0.6"
"@radix-ui/rect" "0.0.5"
"@radix-ui/react-portal@0.0.14":
version "0.0.14"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-0.0.14.tgz#31513d8777cf5e50a3a30ebc9deb34821e890e9e"
@ -865,6 +914,16 @@
"@radix-ui/react-primitive" "0.0.14"
"@radix-ui/react-use-layout-effect" "0.0.5"
"@radix-ui/react-portal@0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-0.0.15.tgz#833bccb192aafb9420bd037d5827e88caf429dc4"
integrity sha512-qMESsdqph1gbRGzy9oSzUoeZYXnR2egXVcEZDqmesfn8w/o1rC1wadKkyBf7qo/YyjUX4mvXknAA+ftp1aQp+w==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/react-use-layout-effect" "0.0.5"
"@radix-ui/react-presence@0.0.14":
version "0.0.14"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-0.0.14.tgz#6a86058bbbf46234dd8840dacd620b3ac5797025"
@ -873,6 +932,15 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.0.5"
"@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"
integrity sha512-+5+ePKUdTkqN1ze7nYmcoeHSsmKCcREwt0NhvNgDocPaqEUoZSkK9Mq6eMiMXSj02NkXH9P+bK32VCClYFnMBQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-use-layout-effect" "0.0.5"
"@radix-ui/react-primitive@0.0.14":
version "0.0.14"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-0.0.14.tgz#752a967cb05d4c5643634fe20274e7dc905d1cce"
@ -881,7 +949,15 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-polymorphic" "0.0.12"
"@radix-ui/react-slot@0.0.12":
"@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"
integrity sha512-Y7JLnen/G3AT0cQXXkBo3A1OuWaKGerkd2gKs0Fuqxv+kTxEmYoqSp/soo0Mm3Ccw61LKLQAjPiE37GK9/Zqwg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-slot@0.0.12", "@radix-ui/react-slot@^0.0.12":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-0.0.12.tgz#c4d8a75fffca561aeeca2ed9603384d86757f60a"
integrity sha512-Em8P/xYyh3O/32IhrmARJNH+J/XCAVnw6h2zGu6oeReliIX7ktU67pMSeyyIZiU2hNXzaXYB/xDdixizQe/DGA==
@ -889,6 +965,45 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.0.5"
"@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"
integrity sha512-2f2fhxvZSb21N+Va1lV4wvyY+zgPkJoKZOiK3rEH9zAmkyQ1nIDeI6eKwipeRO9WcGMeftOZBgVQTZhWSK0Rag==
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-label" "0.0.15"
"@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-use-previous" "0.0.5"
"@radix-ui/react-use-size" "0.0.6"
"@radix-ui/react-tooltip@^0.0.21":
version "0.0.21"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-0.0.21.tgz#86160645cf0441fa7f465c8aaa265887cc3ff9b4"
integrity sha512-+QLMXclfX0XM3inY5LEAvmKsomQ+S0cqzo1v/oS8CiIcawg01RDLV9mzjDYLnpE4eKokn30d+gk4r1YAtWIbZA==
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-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-slot" "0.0.12"
"@radix-ui/react-use-controllable-state" "0.0.6"
"@radix-ui/react-use-escape-keydown" "0.0.6"
"@radix-ui/react-use-layout-effect" "0.0.5"
"@radix-ui/react-use-previous" "0.0.5"
"@radix-ui/react-use-rect" "0.0.7"
"@radix-ui/react-visually-hidden" "0.0.15"
"@radix-ui/react-use-body-pointer-events@0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.0.6.tgz#30b21301880417e7dbb345871ff5a83f2abe0d8d"
@ -927,6 +1042,44 @@
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"
integrity sha512-GjtJlWlDAEMqCm2RDnVdWI6tk4/ZQfRq/VlP05Xy5rFZj6lD37VZWVWUELMBasRPzd2AS/9wPmphOgjH0VnE5A==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-rect@0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-0.0.7.tgz#e3a55fa7183ef436042198787bf38f8c9befcc14"
integrity sha512-OmaeFTgyiGNAchaxzDu+kFLz4Ly8RUcT5nwfoz4Nddd86I8Zdq93iNFnOpVLoVYqBnFEmvR6zexHXNFATrMbbQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/rect" "0.0.5"
"@radix-ui/react-use-size@0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-0.0.6.tgz#998eaf6e8871b868f81f3b7faac06c3e896c37a0"
integrity sha512-kP4RIb2I5oHQzwzXJ21Hu8htNqf+sdaRzywxQpbj+hmqeUhpvIkhoq+ShNWV9wE/3c1T7gPnka8/nKYsKaKdCg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-visually-hidden@0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-0.0.15.tgz#7bd18af3fb5da1349f9b04006d22c3d6e9ce0453"
integrity sha512-8J13Nzu9MfT2z+mDTGRfBukPi5L9LXLV7w1HvNZPVqxGLK8p7/CoXnt8XdS1HKSFm6akZmWJXMZVNVBUsONOcA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-polymorphic" "0.0.13"
"@radix-ui/react-primitive" "0.0.15"
"@radix-ui/rect@0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-0.0.5.tgz#6000d8d800288114af4bbc5863e6b58755d7d978"
integrity sha512-gXw171KbjyttA7K1DRIvPguLmKsg8raitB67MIcsdZwcquy+a1O2w3xY21NIKEqGhJwqJkECPUmMJDXgMNYuAg==
dependencies:
"@babel/runtime" "^7.13.10"
"@sinonjs/commons@^1.7.0":
version "1.8.3"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
@ -2187,7 +2340,7 @@ cssstyle@^2.3.0:
dependencies:
cssom "~0.3.6"
csstype@^3.0.2:
csstype@^3.0.2, csstype@^3.0.4:
version "3.0.8"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340"
integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==
@ -5409,6 +5562,11 @@ react-moment-proptypes@^1.6.0:
dependencies:
moment ">=1.6.0"
react-multi-email@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/react-multi-email/-/react-multi-email-0.5.3.tgz#734a0d4d1af23feef5cb5e635bde23963b0a9e8b"
integrity sha512-1AneeJlAwjvzkPV740q2SXes/kW3HKOzR3gs+U7whrHN5nz+yH5Unosf/rvz8kRj/eFwBf6fTzMqlJiupu7S5Q==
react-outside-click-handler@^1.2.4:
version "1.3.0"
resolved "https://registry.yarnpkg.com/react-outside-click-handler/-/react-outside-click-handler-1.3.0.tgz#3831d541ac059deecd38ec5423f81e80ad60e115"