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". 2. On the upper right, click "Develop" => "Build App".
3. On "OAuth", select "Create". 3. On "OAuth", select "Create".
4. Name your App. 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. 6. De-select the option to publish the app on the Zoom App Marketplace.
7. Click "Create". 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. 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. 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. 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: 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`.
1. account:write:admin
2. meeting:write:admin
3. user:write:admin
12. Click "Done". 12. Click "Done".
13. You're good to go. Now you can easily add your Zoom integration in the Calendso settings. 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: license:
name: MIT License name: MIT License
url: https://opensource.org/licenses/MIT url: https://opensource.org/licenses/MIT
version: 0.1.0 version: 1.0.0
server: server:
url: http://localhost:{port} url: http://localhost:{port}
description: Local Development Server description: Local Development Server
@ -28,6 +28,8 @@ tags:
description: Manage integrations description: Manage integrations
- name: User - name: User
description: Manage the user's profile and settings description: Manage the user's profile and settings
- name: Teams
description: Manage teams
paths: paths:
/api/auth/signin: /api/auth/signin:
get: get:
@ -58,6 +60,12 @@ paths:
summary: Handles signing out summary: Handles signing out
tags: tags:
- Authentication - Authentication
/api/auth/signup:
post:
description: Creates a new user from an invitation.
summary: Create a new user
tags:
- Authentication
/api/auth/session: /api/auth/session:
get: 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. 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 summary: Changes the password for the currently logged in account
tags: tags:
- Authentication - 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: /api/availability/:user:
get: get:
description: Gets the busy times for a particular user, by username. description: Gets the busy times for a particular user, by username.
summary: Gets the busy times for a user summary: Gets the busy times for a user
tags: tags:
- Availability - 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: /api/availability/day:
patch: patch:
description: Updates the start and end times for a user's availability. description: Updates the start and end times for a user's availability.
@ -127,6 +163,12 @@ paths:
summary: Creates a booking for a user summary: Creates a booking for a user
tags: tags:
- Booking - Booking
/api/book/confirm:
post:
description: Accepts an opt-in booking.
summary: Accepts an opt-in booking
tags:
- Booking
/api/integrations: /api/integrations:
get: get:
description: Gets a list of the user's integrations. description: Gets a list of the user's integrations.
@ -150,9 +192,72 @@ paths:
summary: Gets and stores the OAuth token summary: Gets and stores the OAuth token
tags: tags:
- Integrations - 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: /api/user/profile:
patch: patch:
description: Updates a user's profile. description: Updates a user's profile.
summary: Updates a user's profile summary: Updates a user's profile
tags: tags:
- User - 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) => ( export const DialogContent = React.forwardRef(({ children, ...props }, forwardedRef) => (
<DialogPrimitive.Content <DialogPrimitive.Content
{...props} {...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}> ref={forwardedRef}>
{children} {children}
</DialogPrimitive.Content> </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"; DialogContent.displayName = "DialogContent";
export const DialogTrigger = DialogPrimitive.Trigger; export const DialogTrigger = DialogPrimitive.Trigger;

View file

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

View file

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

View file

@ -1,66 +1,63 @@
import { Fragment } from 'react' import { Fragment } from "react";
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from "@headlessui/react";
import { CheckIcon } from '@heroicons/react/outline' import { CheckIcon } from "@heroicons/react/outline";
export default function Modal(props) { export default function Modal(props) {
return ( return (
<Transition.Root show={props.open} as={Fragment}> <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
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> as="div"
<Transition.Child static
as={Fragment} className="fixed z-50 inset-0 overflow-y-auto"
enter="ease-out duration-300" open={props.open}
enterFrom="opacity-0" onClose={props.handleClose}>
enterTo="opacity-100" <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
leave="ease-in duration-200" <Transition.Child
leaveFrom="opacity-100" as={Fragment}
leaveTo="opacity-0" enter="ease-out duration-300"
> enterFrom="opacity-0"
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" /> enterTo="opacity-100"
</Transition.Child> leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */} {/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203; &#8203;
</span> </span>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100" enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100" 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 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> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100"> <CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-5">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{props.heading}
</Dialog.Title>
<div className="mt-2">
<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()}
>
Dismiss
</button>
</div>
</div>
</Transition.Child>
</div> </div>
</Dialog> <div className="mt-3 text-center sm:mt-5">
</Transition.Root> <Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
) {props.heading}
} </Dialog.Title>
<div className="mt-2">
<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()}>
Dismiss
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
}

View file

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

View file

@ -7,19 +7,20 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib
import { SelectorIcon } from "@heroicons/react/outline"; import { SelectorIcon } from "@heroicons/react/outline";
import { import {
CalendarIcon, CalendarIcon,
ClockIcon,
PuzzleIcon,
CogIcon,
ChatAltIcon, ChatAltIcon,
LogoutIcon, ClockIcon,
CogIcon,
ExternalLinkIcon, ExternalLinkIcon,
LinkIcon, LinkIcon,
LogoutIcon,
PuzzleIcon,
} from "@heroicons/react/solid"; } from "@heroicons/react/solid";
import Logo from "./Logo"; import Logo from "./Logo";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
export default function Shell(props) { export default function Shell(props) {
const router = useRouter(); const router = useRouter();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession(); const [session, loading] = useSession();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
@ -43,7 +44,7 @@ export default function Shell(props) {
current: router.pathname.startsWith("/availability"), current: router.pathname.startsWith("/availability"),
}, },
{ {
name: "Integrations", name: "App Store",
href: "/integrations", href: "/integrations",
icon: PuzzleIcon, icon: PuzzleIcon,
current: router.pathname.startsWith("/integrations"), current: router.pathname.startsWith("/integrations"),
@ -71,7 +72,7 @@ export default function Shell(props) {
<div className="h-screen flex overflow-hidden bg-gray-100"> <div className="h-screen flex overflow-hidden bg-gray-100">
{/* Static sidebar for desktop */} {/* Static sidebar for desktop */}
<div className="hidden md:flex md:flex-shrink-0"> <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 */} {/* 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 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"> <div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
@ -105,7 +106,7 @@ export default function Shell(props) {
))} ))}
</nav> </nav>
</div> </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} /> <UserDropdown session={session} />
</div> </div>
</div> </div>
@ -135,10 +136,10 @@ export default function Shell(props) {
</div> </div>
</div> </div>
</nav> </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="block sm:flex justify-between px-4 sm:px-6 md:px-8">
<div className="mb-6"> <div className="mb-8">
<h1 className="text-2xl font-semibold text-gray-900">{props.heading}</h1> <h1 className="text-xl font-bold text-gray-900">{props.heading}</h1>
<p className="text-sm text-neutral-500 mr-4">{props.subtitle}</p> <p className="text-sm text-neutral-500 mr-4">{props.subtitle}</p>
</div> </div>
<div className="mb-4 flex-shrink-0">{props.CTA}</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> <div className="px-4 sm:px-6 md:px-8">{props.children}</div>
{/* show bottom navigation for md and smaller (tablet and phones) */} {/* 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 */} {/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
{navigation.flatMap((item, itemIdx) => {navigation.flatMap((item, itemIdx) =>
item.name === "Settings" ? ( 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 { useRouter } from "next/router";
import Slots from "./Slots"; import Slots from "./Slots";
import { ExclamationIcon } from "@heroicons/react/solid"; import { ExclamationIcon } from "@heroicons/react/solid";
import React from "react";
import Loader from "@components/Loader";
const AvailableTimes = ({ const AvailableTimes = ({
date, date,
@ -25,9 +27,12 @@ const AvailableTimes = ({
}); });
return ( return (
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto"> <div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
<div className="text-gray-600 font-light text-xl mb-4 text-left"> <div className="text-gray-600 font-light text-lg mb-4 text-left">
<span className="w-1/2 dark:text-white text-gray-600">{date.format("dddd DD MMMM YYYY")}</span> <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> </div>
{slots.length > 0 && {slots.length > 0 &&
slots.map((slot) => ( slots.map((slot) => (
@ -37,7 +42,7 @@ const AvailableTimes = ({
`/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` + `/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "") (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)} {slot.format(timeFormat)}
</a> </a>
</Link> </Link>
@ -49,7 +54,7 @@ const AvailableTimes = ({
</div> </div>
)} )}
{!isFullyBooked && slots.length === 0 && !hasErrors && <div className="loader" />} {!isFullyBooked && slots.length === 0 && !hasErrors && <Loader />}
{hasErrors && ( {hasErrors && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4"> <div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">

View file

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

View file

@ -25,7 +25,7 @@ const TimeOptions = (props) => {
return ( return (
selectedTimeZone !== "" && ( 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="flex mb-4">
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div> <div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
<div className="w-1/2"> <div className="w-1/2">
@ -37,7 +37,7 @@ const TimeOptions = (props) => {
checked={is24hClock} checked={is24hClock}
onChange={setIs24hClock} onChange={setIs24hClock}
className={classNames( 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" "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> <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,105 +1,131 @@
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {UsersIcon,UserRemoveIcon} from "@heroicons/react/outline"; import { UserRemoveIcon, UsersIcon } from "@heroicons/react/outline";
import {useSession} from "next-auth/client"; import { useSession } from "next-auth/client";
export default function EditTeamModal(props) { export default function EditTeamModal(props) {
const [session] = useSession();
const [members, setMembers] = useState([]);
const [checkedDisbandTeam, setCheckedDisbandTeam] = useState(false);
const [ session, loading ] = useSession(); const loadMembers = () =>
const [ members, setMembers ] = useState([]); fetch("/api/teams/" + props.team.id + "/membership")
const [ checkedDisbandTeam, setCheckedDisbandTeam ] = useState(false); .then((res: any) => res.json())
.then((data) => setMembers(data.members));
const loadMembers = () => fetch('/api/teams/' + props.team.id + '/membership') useEffect(() => {
.then( (res: any) => res.json() ).then( (data) => setMembers(data.members) );
useEffect( () => {
loadMembers(); loadMembers();
}, []); }, []);
const deleteTeam = (e) => { const deleteTeam = (e) => {
e.preventDefault(); e.preventDefault();
return fetch('/api/teams/' + props.team.id, { return fetch("/api/teams/" + props.team.id, {
method: 'DELETE', method: "DELETE",
}).then(props.onExit); }).then(props.onExit);
} };
const removeMember = (member) => { const removeMember = (member) => {
return fetch('/api/teams/' + props.team.id + '/membership', { return fetch("/api/teams/" + props.team.id + "/membership", {
method: 'DELETE', method: "DELETE",
body: JSON.stringify({ userId: member.id }), body: JSON.stringify({ userId: member.id }),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}).then(loadMembers); }).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="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></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>
<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="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"> <div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-black bg-opacity-10 sm:mx-0 sm:h-10 sm:w-10"> <div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-black bg-opacity-10 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="h-6 w-6 text-black" /> <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>
<div>
<p className="text-sm text-gray-400">Manage and delete your team.</p>
</div>
</div>
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <form>
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit the {props.team.name} team</h3>
<div> <div>
<p className="text-sm text-gray-400"> <div className="mb-4">
Manage and delete your team. {members.length > 0 && (
</p> <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>
<td className="capitalize">{member.role.toLowerCase()}</td>
<td className="text-right py-2 px-1">
{member.email !== session.user.email && (
<button
type="button"
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" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</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-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md"
/>
Disband this team
</label>
</div>
</div> </div>
</div> <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
</div> {/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
<form>
<div>
<div className="mb-4">
{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>
<td className="capitalize">{member.role.toLowerCase()}</td>
<td className="text-right py-2 px-1">
{member.email !== session.user.email &&
<button
type="button"
onClick={(e) => 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"/>
</button>
}
</td>
</tr>)}
</tbody>
</table>
</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" />
Disband this team
</label>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
Update Update
</button>*/} </button>*/}
{checkedDisbandTeam && <button onClick={deleteTeam} className="btn bg-red-700 rounded text-white px-2 font-medium text-sm"> {checkedDisbandTeam && (
Disband Team <button
</button>} onClick={deleteTeam}
<button onClick={props.onExit} type="button" className="btn btn-white mr-2"> className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
Close Disband Team
</button> </button>
</div> )}
</form> <button onClick={props.onExit} type="button" className="btn btn-white mr-2">
Close
</button>
</div>
</form>
</div>
</div> </div>
</div> </div>
</div>); );
} }

View file

@ -2,11 +2,9 @@ import { UsersIcon } from "@heroicons/react/outline";
import { useState } from "react"; import { useState } from "react";
export default function MemberInvitationModal(props) { export default function MemberInvitationModal(props) {
const [errorMessage, setErrorMessage] = useState("");
const [ errorMessage, setErrorMessage ] = useState('');
const handleError = async (res) => { const handleError = async (res) => {
const responseData = await res.json(); const responseData = await res.json();
if (res.ok === false) { if (res.ok === false) {
@ -18,24 +16,26 @@ export default function MemberInvitationModal(props) {
}; };
const inviteMember = (e) => { const inviteMember = (e) => {
e.preventDefault(); e.preventDefault();
const payload = { const payload = {
role: e.target.elements['role'].value, role: e.target.elements["role"].value,
usernameOrEmail: e.target.elements['inviteUser'].value, usernameOrEmail: e.target.elements["inviteUser"].value,
sendEmailInvitation: e.target.elements['sendInviteEmail'].checked, sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
} };
return fetch('/api/teams/' + props.team.id + '/invite', { return fetch("/api/teams/" + props.team.id + "/invite", {
method: 'POST', method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}).then(handleError).then(props.onExit).catch( (e) => { })
// do nothing. .then(handleError)
}); .then(props.onExit)
.catch(() => {
// do nothing.
});
}; };
return ( return (
@ -45,7 +45,9 @@ export default function MemberInvitationModal(props) {
role="dialog" role="dialog"
aria-modal="true"> 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="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"> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203; &#8203;
@ -127,4 +129,4 @@ export default function MemberInvitationModal(props) {
</div> </div>
</div> </div>
); );
} }

View file

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

View file

@ -1,61 +1,89 @@
import {CogIcon, TrashIcon, UserAddIcon, UsersIcon} from "@heroicons/react/outline"; import { CogIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline";
import Dropdown from "../ui/Dropdown"; import Dropdown from "../ui/Dropdown";
import {useState} from "react"; import { useState } from "react";
export default function TeamListItem(props) { export default function TeamListItem(props) {
const [team, setTeam] = useState(props.team);
const [ team, setTeam ] = useState(props.team);
const acceptInvite = () => invitationResponse(true); const acceptInvite = () => invitationResponse(true);
const declineInvite = () => invitationResponse(false); const declineInvite = () => invitationResponse(false);
const invitationResponse = (accept: boolean) => fetch('/api/user/membership', { const invitationResponse = (accept: boolean) =>
method: accept ? 'PATCH' : 'DELETE', fetch("/api/user/membership", {
body: JSON.stringify({ teamId: props.team.id }), method: accept ? "PATCH" : "DELETE",
headers: { body: JSON.stringify({ teamId: props.team.id }),
'Content-Type': 'application/json' headers: {
} "Content-Type": "application/json",
}).then( () => { },
// success }).then(() => {
setTeam(null); // success
props.onChange(); setTeam(null);
}); props.onChange();
});
return (team && <li className="mb-2 mt-2 divide-y"> return (
<div className="flex justify-between mb-2 mt-2"> team && (
<div> <li className="mb-2 mt-2 divide-y">
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline"/> <div className="flex justify-between mb-2 mt-2">
<div className="inline-block -mt-1"> <div>
<span className="font-bold text-neutral-700 text-sm">{props.team.name}</span> <UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline" />
<span className="text-xs text-gray-400 -mt-1 block capitalize">{props.team.role.toLowerCase()}</span> <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>
</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>
<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>
<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>
</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>
</ul>
</Dropdown>
</div>
)}
</div> </div>
</div> {/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
{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>
<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>
</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>
</ul>
</Dropdown>
</div>}
</div>
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
{props.team.members.length > 0 && <div> {props.team.members.length > 0 && <div>
<h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2> <h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2>
<table className="table-auto mb-2 w-full"> <table className="table-auto mb-2 w-full">
@ -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-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> <button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2">Disband</button>
</div>}*/} </div>}*/}
</li>); </li>
)
);
} }

View file

@ -1,15 +1,26 @@
import { useState } from 'react';
export default function Button(props) { export default function Button(props) {
return( return (
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary dark:btn-white">
{!props.loading && props.children} {!props.loading && props.children}
{props.loading && {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"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin mx-4 h-5 w-5 text-white"
<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> 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> </svg>
} )}
</button> </button>
); );
} }

View file

@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
const PoweredByCalendso = () => ( 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`}> <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"> <a target="_blank" className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
powered by{" "} powered by{" "}

View file

@ -91,7 +91,7 @@ export const Scheduler = ({
type="button" type="button"
onClick={() => removeScheduleAt(idx)} onClick={() => removeScheduleAt(idx)}
className="btn-sm bg-transparent px-2 py-1 ml-1"> 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> </button>
</li> </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" role="dialog"
aria-modal="true"> 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="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"> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203; &#8203;

View file

@ -6,10 +6,12 @@ import { stripHtml } from "./emails/helpers";
const translator = short(); const translator = short();
export default class CalEventParser { 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.calEvent = calEvent;
this.maybeUid = maybeUid;
} }
/** /**
@ -30,14 +32,14 @@ export default class CalEventParser {
* Returns a unique identifier for the given calendar event. * Returns a unique identifier for the given calendar event.
*/ */
public getUid(): string { 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). * Returns a footer section with links to change the event (as HTML).
*/ */
public getChangeEventFooterHtml(): string { 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 prisma from "./prisma";
import { Credential } from "@prisma/client"; import { Credential } from "@prisma/client";
import CalEventParser from "./CalEventParser"; 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 // eslint-disable-next-line @typescript-eslint/no-var-requires
const { google } = require("googleapis"); const { google } = require("googleapis");
@ -206,7 +210,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
return { return {
getAvailability: (dateFrom, dateTo, selectedCalendars) => { getAvailability: (dateFrom, dateTo, selectedCalendars) => {
const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'"; const filter = "?startdatetime=" + dateFrom + "&enddatetime=" + dateTo;
return auth return auth
.getToken() .getToken()
.then((accessToken) => { .then((accessToken) => {
@ -229,7 +233,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
headers: { headers: {
Prefer: 'outlook.timezone="Etc/GMT"', 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", { return fetch("https://graph.microsoft.com/v1.0/$batch", {
@ -309,7 +313,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
getAvailability: (dateFrom, dateTo, selectedCalendars) => getAvailability: (dateFrom, dateTo, selectedCalendars) =>
new Promise((resolve, reject) => new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => { auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType) .filter((e) => e.integration === integrationType)
.map((e) => e.externalId); .map((e) => e.externalId);
@ -375,7 +382,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
payload["conferenceData"] = event.conferenceData; payload["conferenceData"] = event.conferenceData;
} }
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.insert( calendar.events.insert(
{ {
auth: myGoogleAuth, auth: myGoogleAuth,
@ -418,7 +428,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
payload["location"] = event.location; payload["location"] = event.location;
} }
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.update( calendar.events.update(
{ {
auth: myGoogleAuth, auth: myGoogleAuth,
@ -441,7 +454,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
deleteEvent: (uid: string) => deleteEvent: (uid: string) =>
new Promise((resolve, reject) => new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => { auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.delete( calendar.events.delete(
{ {
auth: myGoogleAuth, auth: myGoogleAuth,
@ -463,7 +479,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
listCalendars: () => listCalendars: () =>
new Promise((resolve, reject) => new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => { auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList calendar.calendarList
.list() .list()
.then((cals) => { .then((cals) => {
@ -515,8 +534,13 @@ const listCalendars = (withCredentials) =>
results.reduce((acc, calendars) => acc.concat(calendars), []) results.reduce((acc, calendars) => acc.concat(calendars), [])
); );
const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<unknown> => { const createEvent = async (
const parser: CalEventParser = new CalEventParser(calEvent); credential: Credential,
calEvent: CalendarEvent,
noMail = false,
maybeUid: string = null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid(); const uid: string = parser.getUid();
/* /*
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r). * Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
@ -525,76 +549,103 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro
*/ */
const richEvent: CalendarEvent = parser.asRichEventPlain(); 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 maybeHangoutLink = creationResult?.hangoutLink;
const maybeEntryPoints = creationResult?.entryPoints; const maybeEntryPoints = creationResult?.entryPoints;
const maybeConferenceData = creationResult?.conferenceData; const maybeConferenceData = creationResult?.conferenceData;
const organizerMail = new EventOrganizerMail(calEvent, uid, { if (!noMail) {
hangoutLink: maybeHangoutLink, const organizerMail = new EventOrganizerMail(calEvent, uid, {
conferenceData: maybeConferenceData, hangoutLink: maybeHangoutLink,
entryPoints: maybeEntryPoints, conferenceData: maybeConferenceData,
}); entryPoints: maybeEntryPoints,
});
const attendeeMail = new EventAttendeeMail(calEvent, uid, { const attendeeMail = new EventAttendeeMail(calEvent, uid, {
hangoutLink: maybeHangoutLink, hangoutLink: maybeHangoutLink,
conferenceData: maybeConferenceData, conferenceData: maybeConferenceData,
entryPoints: maybeEntryPoints, entryPoints: maybeEntryPoints,
}); });
try {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e);
}
if (!creationResult || !creationResult.disableConfirmationEmail) {
try { try {
await attendeeMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.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);
}
} }
} }
return { return {
type: credential.type,
success,
uid, uid,
createdEvent: creationResult, createdEvent: creationResult,
originalEvent: calEvent,
}; };
}; };
const updateEvent = async ( const updateEvent = async (
credential: Credential, credential: Credential,
uidToUpdate: string, uidToUpdate: string,
calEvent: CalendarEvent calEvent: CalendarEvent,
): Promise<unknown> => { noMail = false
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent); const parser: CalEventParser = new CalEventParser(calEvent);
const newUid: string = parser.getUid(); const newUid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEventPlain(); const richEvent: CalendarEvent = parser.asRichEventPlain();
let success = true;
const updateResult = credential 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; : null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); if (!noMail) {
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
try { const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e);
}
if (!updateResult || !updateResult.disableConfirmationEmail) {
try { try {
await attendeeMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.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);
}
} }
} }
return { return {
type: credential.type,
success,
uid: newUid, uid: newUid,
updatedEvent: updateResult, updatedEvent: updateResult,
originalEvent: calEvent,
}; };
}; };
@ -606,12 +657,4 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
return Promise.resolve({}); return Promise.resolve({});
}; };
export { export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };
getBusyCalendarTimes,
createEvent,
updateEvent,
deleteEvent,
CalendarEvent,
listCalendars,
IntegrationCalendar,
};

View file

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

View file

@ -1,14 +1,21 @@
import {CalendarEvent} from "../calendarClient"; import { CalendarEvent } from "../calendarClient";
import EventAttendeeMail from "./EventAttendeeMail"; import EventAttendeeMail from "./EventAttendeeMail";
import {getFormattedMeetingId, getIntegrationName} from "./helpers"; import { getFormattedMeetingId, getIntegrationName } from "./helpers";
import {VideoCallData} from "../videoClient"; import { VideoCallData } from "../videoClient";
import { AdditionInformation } from "@lib/emails/EventMail";
export default class VideoEventAttendeeMail extends EventAttendeeMail { export default class VideoEventAttendeeMail extends EventAttendeeMail {
videoCallData: VideoCallData; videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { constructor(
calEvent: CalendarEvent,
uid: string,
videoCallData: VideoCallData,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid); super(calEvent, uid);
this.videoCallData = videoCallData; this.videoCallData = videoCallData;
this.additionInformation = additionInformation;
} }
/** /**
@ -24,4 +31,4 @@ export default class VideoEventAttendeeMail extends EventAttendeeMail {
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> <strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
`; `;
} }
} }

View file

@ -2,13 +2,20 @@ import { CalendarEvent } from "../calendarClient";
import EventOrganizerMail from "./EventOrganizerMail"; import EventOrganizerMail from "./EventOrganizerMail";
import { VideoCallData } from "../videoClient"; import { VideoCallData } from "../videoClient";
import { getFormattedMeetingId, getIntegrationName } from "./helpers"; import { getFormattedMeetingId, getIntegrationName } from "./helpers";
import { AdditionInformation } from "@lib/emails/EventMail";
export default class VideoEventOrganizerMail extends EventOrganizerMail { export default class VideoEventOrganizerMail extends EventOrganizerMail {
videoCallData: VideoCallData; videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { constructor(
calEvent: CalendarEvent,
uid: string,
videoCallData: VideoCallData,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid); super(calEvent, uid);
this.videoCallData = videoCallData; 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 prisma from "./prisma";
import {CalendarEvent} from "./calendarClient"; import { CalendarEvent } from "./calendarClient";
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail"; import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
import {v5 as uuidv5} from 'uuid'; import { v5 as uuidv5 } from "uuid";
import short from 'short-uuid'; import short from "short-uuid";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; 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(); const translator = short();
@ -33,63 +41,67 @@ function handleErrorsRaw(response) {
} }
const zoomAuth = (credential) => { 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 refreshAccessToken = (refreshToken) =>
const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64'); fetch("https://zoom.us/oauth/token", {
method: "POST",
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', { headers: {
method: 'POST', Authorization: authHeader,
headers: { "Content-Type": "application/x-www-form-urlencoded",
'Authorization': authHeader, },
'Content-Type': 'application/x-www-form-urlencoded' body: new URLSearchParams({
}, refresh_token: refreshToken,
body: new URLSearchParams({ grant_type: "refresh_token",
'refresh_token': refreshToken, }),
'grant_type': 'refresh_token',
}) })
}) .then(handleErrorsJson)
.then(handleErrorsJson) .then(async (responseBody) => {
.then(async (responseBody) => { // Store new tokens in database.
// Store new tokens in database. await prisma.credential.update({
await prisma.credential.update({ where: {
where: { id: credential.id,
id: credential.id },
}, data: {
data: { key: responseBody,
key: responseBody },
} });
credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
return credential.key.access_token;
}); });
credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token;
})
return { 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 { interface VideoApiAdapter {
createMeeting(event: CalendarEvent): Promise<any>; 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>; getAvailability(dateFrom, dateTo): Promise<any>;
} }
const ZoomVideo = (credential): VideoApiAdapter => { const ZoomVideo = (credential): VideoApiAdapter => {
const auth = zoomAuth(credential); const auth = zoomAuth(credential);
const translateEvent = (event: CalendarEvent) => { const translateEvent = (event: CalendarEvent) => {
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
return { return {
topic: event.title, topic: event.title,
type: 2, // Means that this is a scheduled meeting type: 2, // Means that this is a scheduled meeting
start_time: event.startTime, 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?) //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.attendees[0].timeZone, timezone: event.attendees[0].timeZone,
//password: "string", TODO: Should we use a password? Maybe generate a random one? //password: "string", TODO: Should we use a password? Maybe generate a random one?
@ -97,8 +109,8 @@ const ZoomVideo = (credential): VideoApiAdapter => {
settings: { settings: {
host_video: true, host_video: true,
participant_video: true, participant_video: true,
cn_meeting: false, // TODO: true if host meeting in China cn_meeting: false, // TODO: true if host meeting in China
in_meeting: false, // TODO: true if host meeting in India in_meeting: false, // TODO: true if host meeting in India
join_before_host: true, join_before_host: true,
mute_upon_entry: false, mute_upon_entry: false,
watermark: false, watermark: false,
@ -107,82 +119,112 @@ const ZoomVideo = (credential): VideoApiAdapter => {
audio: "both", audio: "both",
auto_recording: "none", auto_recording: "none",
enforce_login: false, enforce_login: false,
registrants_email_notification: true registrants_email_notification: true,
} },
}; };
}; };
return { return {
getAvailability: (dateFrom, dateTo) => { getAvailability: () => {
return auth.getToken().then( return auth
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled. .getToken()
(accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', { .then(
method: 'get', // TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
headers: { (accessToken) =>
'Authorization': 'Bearer ' + accessToken fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", {
} method: "get",
}) headers: {
.then(handleErrorsJson) Authorization: "Bearer " + accessToken,
.then(responseBody => { },
return responseBody.meetings.map((meeting) => ({ })
start: meeting.start_time, .then(handleErrorsJson)
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString() .then((responseBody) => {
})) return responseBody.meetings.map((meeting) => ({
}) start: meeting.start_time,
).catch((err) => { end: new Date(
console.log(err); new Date(meeting.start_time).getTime() + meeting.duration * 60000
}); ).toISOString(),
}));
})
)
.catch((err) => {
console.log(err);
});
}, },
createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { createMeeting: (event: CalendarEvent) =>
method: 'POST', auth.getToken().then((accessToken) =>
headers: { fetch("https://api.zoom.us/v2/users/me/meetings", {
'Authorization': 'Bearer ' + accessToken, method: "POST",
'Content-Type': 'application/json', headers: {
}, Authorization: "Bearer " + accessToken,
body: JSON.stringify(translateEvent(event)) "Content-Type": "application/json",
}).then(handleErrorsJson)), },
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { body: JSON.stringify(translateEvent(event)),
method: 'DELETE', }).then(handleErrorsJson)
headers: { ),
'Authorization': 'Bearer ' + accessToken deleteMeeting: (uid: string) =>
} auth.getToken().then((accessToken) =>
}).then(handleErrorsRaw)), fetch("https://api.zoom.us/v2/meetings/" + uid, {
updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { method: "DELETE",
method: 'PATCH', headers: {
headers: { Authorization: "Bearer " + accessToken,
'Authorization': 'Bearer ' + accessToken, },
'Content-Type': 'application/json' }).then(handleErrorsRaw)
}, ),
body: JSON.stringify(translateEvent(event)) updateMeeting: (uid: string, event: CalendarEvent) =>
}).then(handleErrorsRaw)), 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 // factory
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => { const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
switch (cred.type) { withCredentials
case 'zoom_video': .map((cred) => {
return ZoomVideo(cred); switch (cred.type) {
default: case "zoom_video":
return; // unknown credential, could be legacy? In any case, ignore return ZoomVideo(cred);
} default:
}).filter(Boolean); return; // unknown credential, could be legacy? In any case, ignore
}
})
.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( const createMeeting = async (
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) credential: Credential,
).then( calEvent: CalendarEvent,
(results) => results.reduce((acc, availability) => acc.concat(availability), []) maybeUid: string = null
); ): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => { const uid: string = parser.getUid();
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) { 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 = { const videoCallData: VideoCallData = {
type: credential.type, type: credential.type,
@ -191,60 +233,92 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any>
url: creationResult.join_url, url: creationResult.join_url,
}; };
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData); const entryPoint: EntryPoint = {
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData); 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 { try {
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("organizerMail.sendEmail failed", e) console.error("organizerMail.sendEmail failed", e);
} }
if (!creationResult || !creationResult.disableConfirmationEmail) { if (!creationResult || !creationResult.disableConfirmationEmail) {
try { try {
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.sendEmail failed", e) console.error("attendeeMail.sendEmail failed", e);
} }
} }
return { return {
type: credential.type,
success,
uid, 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)); const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) { 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 organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
try { try {
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("organizerMail.sendEmail failed", e) console.error("organizerMail.sendEmail failed", e);
} }
if (!updateResult || !updateResult.disableConfirmationEmail) { if (!updateResult || !updateResult.disableConfirmationEmail) {
try { try {
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.sendEmail failed", e) console.error("attendeeMail.sendEmail failed", e);
} }
} }
return { return {
type: credential.type,
success,
uid: newUid, 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) { if (credential) {
return videoIntegrations([credential])[0].deleteMeeting(uid); return videoIntegrations([credential])[0].deleteMeeting(uid);
} }
@ -252,4 +326,4 @@ const deleteMeeting = (credential, uid: String): Promise<any> => {
return Promise.resolve({}); return Promise.resolve({});
}; };
export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting}; export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };

View file

@ -1,31 +1,40 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const withTM = require('next-transpile-modules')(['react-timezone-select']); const withTM = require("next-transpile-modules")(["react-timezone-select"]);
// TODO: Revisit this later with getStaticProps in App // TODO: Revisit this later with getStaticProps in App
if (process.env.NEXTAUTH_URL) { 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 ) { 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.'); 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) { 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) => { const validJson = (jsonString) => {
try { try {
const o = JSON.parse(jsonString); const o = JSON.parse(jsonString);
if (o && typeof o === "object") { if (o && typeof o === "object") {
return o; return o;
}
} }
catch (e) { console.error(e); } } catch (e) {
return false; console.error(e);
} }
return false;
};
if (process.env.GOOGLE_API_CREDENTIALS && ! validJson(process.env.GOOGLE_API_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."); 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({ module.exports = withTM({
@ -42,10 +51,10 @@ module.exports = withTM({
async redirects() { async redirects() {
return [ return [
{ {
source: '/settings', source: "/settings",
destination: '/settings/profile', destination: "/settings/profile",
permanent: true, permanent: true,
} },
] ];
} },
}); });

View file

@ -19,6 +19,9 @@
"@prisma/client": "^2.23.0", "@prisma/client": "^2.23.0",
"@radix-ui/react-collapsible": "^0.0.16", "@radix-ui/react-collapsible": "^0.0.16",
"@radix-ui/react-dialog": "^0.0.19", "@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", "@tailwindcss/forms": "^0.2.1",
"async": "^3.2.0", "async": "^3.2.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@ -38,6 +41,7 @@
"react": "17.0.1", "react": "17.0.1",
"react-dates": "^21.8.0", "react-dates": "^21.8.0",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.1.21", "react-phone-number-input": "^3.1.21",
"react-select": "^4.3.0", "react-select": "^4.3.0",
"react-timezone-select": "^1.0.2", "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 prisma, { whereAndSelect } from "@lib/prisma";
import Avatar from "../components/Avatar"; import Avatar from "../components/Avatar";
import Theme from "@components/Theme"; 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 { export default function User(props): User {
const { isReady } = Theme(props.user.theme); const { isReady } = Theme(props.user.theme);
const eventTypes = props.eventTypes.map((type) => ( const eventTypes = props.eventTypes.map((type) => (
<li <div
key={type.id} 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}`}> <Link href={`/${props.user.username}/${type.slug}`}>
<a className="block px-6 py-4"> <a className="block px-6 py-4">
<div <h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
className="inline-block w-3 h-3 rounded-full mr-2" <div className="mt-2 flex space-x-4">
style={{ backgroundColor: getRandomColorCode() }}></div> <div className="flex text-sm text-neutral-500">
<h2 className="inline-block font-medium dark:text-white">{type.title}</h2> <ClockIcon
<p className="inline-block text-gray-400 dark:text-gray-100 ml-2">{type.description}</p> 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> </a>
</Link> </Link>
</li> </div>
)); ));
return ( return (
isReady && ( <>
<div> <Head>
<Head> <title>{props.user.name || props.user.username} | Calendso</title>
<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-2xl mx-auto my-24"> <meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
<div className="mb-8 text-center"> <meta name="description" content={"Book a time with " + (props.user.name || props.user.username)} />
<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"> <meta property="og:type" content="website" />
{props.user.name || props.user.username} <meta property="og:url" content="https://calendso/" />
</h1> <meta
<p className="text-gray-600 dark:text-white">{props.user.bio}</p> property="og:title"
</div> content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
<div className="shadow overflow-hidden rounded-md"> />
<ul className="divide-y divide-gray-200 dark:divide-gray-900">{eventTypes}</ul> <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-bold text-neutral-900 dark:text-white mb-1">
{props.user.name || props.user.username}
</h1>
<p className="text-neutral-500 dark:text-white">{props.user.bio}</p>
</div>
<div className="space-y-6">{eventTypes}</div>
{eventTypes.length == 0 && ( {eventTypes.length == 0 && (
<div className="p-8 text-center text-gray-400 dark:text-white"> <div className="shadow overflow-hidden rounded-sm">
<h2 className="font-semibold text-3xl text-gray-600">Uh oh!</h2> <div className="p-8 text-center text-gray-400 dark:text-white">
<p className="max-w-md mx-auto">This user hasn&apos;t set up any event types yet.</p> <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> </div>
)} )}
</div> </main>
</main> </div>
</div> )}
) </>
); );
} }
@ -76,6 +149,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
select: { select: {
slug: true, slug: true,
title: true, title: true,
length: true,
description: true, description: true,
}, },
}); });

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { GetServerSideProps, GetServerSidePropsContext } from "next"; import { GetServerSideProps, GetServerSidePropsContext } from "next";
import Head from "next/head"; 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 { useRouter } from "next/router";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import * as Collapsible from "@radix-ui/react-collapsible"; import * as Collapsible from "@radix-ui/react-collapsible";
@ -49,9 +49,15 @@ export default function Type(props): Type {
router.replace( router.replace(
{ {
query: { query: Object.assign(
date: formattedDate, {},
}, {
...router.query,
},
{
date: formattedDate,
}
),
}, },
undefined, undefined,
{ {
@ -72,125 +78,159 @@ export default function Type(props): Type {
}; };
return ( return (
isReady && ( <>
<div> <Head>
<Head> <title>
<title> {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username}{" "} Calendso
| Calendso </title>
</title> <meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} /> <meta name="description" content={props.eventType.description} />
<meta name="description" content={props.eventType.description} />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://calendso/" /> <meta property="og:url" content="https://calendso/" />
<meta <meta
property="og:title" property="og:title"
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
/> />
<meta property="og:description" content={props.eventType.description} /> <meta property="og:description" content={props.eventType.description} />
<meta <meta
property="og:image" property="og:image"
content={ content={
"https://og-image-one-pi.vercel.app/" + "https://og-image-one-pi.vercel.app/" +
encodeURIComponent( encodeURIComponent(
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description "Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
).replace(/'/g, "%27") + ).replace(/'/g, "%27") +
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
encodeURIComponent(props.user.avatar) encodeURIComponent(props.user.avatar)
} }
/> />
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://calendso/" /> <meta property="twitter:url" content="https://calendso/" />
<meta <meta
property="twitter:title" property="twitter:title"
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
/> />
<meta property="twitter:description" content={props.eventType.description} /> <meta property="twitter:description" content={props.eventType.description} />
<meta <meta
property="twitter:image" property="twitter:image"
content={ content={
"https://og-image-one-pi.vercel.app/" + "https://og-image-one-pi.vercel.app/" +
encodeURIComponent( encodeURIComponent(
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description "Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
).replace(/'/g, "%27") + ).replace(/'/g, "%27") +
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
encodeURIComponent(props.user.avatar) encodeURIComponent(props.user.avatar)
} }
/> />
</Head> </Head>
<main
className={
"mx-auto my-0 sm:my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-6xl" : "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={
"pr-8 sm:border-r sm:dark:border-gray-900 " + (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>
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
{props.eventType.title}
</h1>
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}> {isReady && (
<Collapsible.Trigger className="text-gray-500 mb-1 px-2 py-1 -ml-2"> <div>
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> <main
{timeZone()} className={
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" /> "mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
</Collapsible.Trigger> (selectedDate ? "max-w-5xl" : "max-w-3xl")
<Collapsible.Content> }>
<TimeOptions <div className="sm:dark:border-gray-600 dark:bg-gray-900 bg-white md:border border-gray-200 rounded-sm">
onSelectTimeZone={handleSelectTimeZone} {/* mobile: details */}
onToggle24hClock={handleToggle24hClock} <div className="p-4 sm:p-8 block md:hidden">
/> <div className="flex items-center">
</Collapsible.Content> <Avatar user={props.user} className="inline-block h-9 w-9 rounded-full" />
</Collapsible.Root> <div className="ml-3">
<p className="text-sm font-medium dark:text-gray-300 text-black">{props.user.name}</p>
<p className="dark:text-gray-200 text-gray-600 mt-3 mb-8">{props.eventType.description}</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>
<DatePicker
date={selectedDate} <div className="sm:flex px-4 sm:py-5 sm:p-4">
periodType={props.eventType?.periodType} <div
periodStartDate={props.eventType?.periodStartDate} className={
periodEndDate={props.eventType?.periodEndDate} "hidden md:block pr-8 sm:border-r sm:dark:border-gray-800 " +
periodDays={props.eventType?.periodDays} (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
periodCountCalendarDays={props.eventType?.periodCountCalendarDays} }>
weekStart={props.user.weekStart} <Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
onDatePicked={changeDate} <h2 className="font-medium dark:text-gray-300 text-gray-500">{props.user.name}</h2>
workingHours={props.workingHours} <h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
organizerTimeZone={props.eventType.timeZone || props.user.timeZone} {props.eventType.title}
inviteeTimeZone={timeZone()} </h1>
eventLength={props.eventType.length} <p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
minimumBookingNotice={props.eventType.minimumBookingNotice} <ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
/> {props.eventType.length} minutes
{selectedDate && ( </p>
<AvailableTimes
workingHours={props.workingHours} <TimezoneDropdown />
timeFormat={timeFormat}
organizerTimeZone={props.eventType.timeZone || props.user.timeZone} <p className="dark:text-gray-200 text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
minimumBookingNotice={props.eventType.minimumBookingNotice} </div>
eventTypeId={props.eventType.id} <DatePicker
eventLength={props.eventType.length}
date={selectedDate} date={selectedDate}
user={props.user} periodType={props.eventType?.periodType}
periodStartDate={props.eventType?.periodStartDate}
periodEndDate={props.eventType?.periodEndDate}
periodDays={props.eventType?.periodDays}
periodCountCalendarDays={props.eventType?.periodCountCalendarDays}
weekStart={props.user.weekStart}
onDatePicked={changeDate}
workingHours={props.workingHours}
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
inviteeTimeZone={timeZone()}
eventLength={props.eventType.length}
minimumBookingNotice={props.eventType.minimumBookingNotice}
/> />
)}
<div className="ml-1 mt-4 block sm:hidden">
<TimezoneDropdown />
</div>
{selectedDate && (
<AvailableTimes
workingHours={props.workingHours}
timeFormat={timeFormat}
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
minimumBookingNotice={props.eventType.minimumBookingNotice}
eventTypeId={props.eventType.id}
eventLength={props.eventType.length}
date={selectedDate}
user={props.user}
/>
)}
</div>
</div> </div>
</div> {!props.user.hideBranding && <PoweredByCalendso />}
{!props.user.hideBranding && <PoweredByCalendso />} </main>
</main> </div>
</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) => { 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 Button from "../../components/ui/Button";
import { EventTypeCustomInputType } from "../../lib/eventTypeInput"; import { EventTypeCustomInputType } from "../../lib/eventTypeInput";
import Theme from "@components/Theme"; import Theme from "@components/Theme";
import { ReactMultiEmail } from "react-multi-email";
import "react-multi-email/style.css";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -27,7 +29,8 @@ export default function Book(props: any): JSX.Element {
const [preferredTimeZone, setPreferredTimeZone] = useState(""); const [preferredTimeZone, setPreferredTimeZone] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [guestToggle, setGuestToggle] = useState(false);
const [guestEmails, setGuestEmails] = useState([]);
const locations = props.eventType.locations || []; const locations = props.eventType.locations || [];
const [selectedLocation, setSelectedLocation] = useState<LocationType>( 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())); telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
}); });
function toggleGuestEmailInput() {
setGuestToggle(!guestToggle);
}
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
// TODO: Move to translations // TODO: Move to translations
@ -85,6 +92,7 @@ export default function Book(props: any): JSX.Element {
name: event.target.name.value, name: event.target.name.value,
email: event.target.email.value, email: event.target.email.value,
notes: notes, notes: notes,
guests: guestEmails,
timeZone: preferredTimeZone, timeZone: preferredTimeZone,
eventTypeId: props.eventType.id, eventTypeId: props.eventType.id,
rescheduleUid: rescheduleUid, rescheduleUid: rescheduleUid,
@ -153,9 +161,9 @@ export default function Book(props: any): JSX.Element {
</Head> </Head>
<main className="max-w-3xl mx-auto my-0 sm:my-24"> <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: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" /> <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> <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"> <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} {locationInfo(selectedLocation).address}
</p> </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" /> <CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{preferredTimeZone && {preferredTimeZone &&
dayjs(date) dayjs(date)
@ -192,7 +200,7 @@ export default function Book(props: any): JSX.Element {
name="name" name="name"
id="name" id="name"
required 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" placeholder="John Doe"
defaultValue={props.booking ? props.booking.attendees[0].name : ""} defaultValue={props.booking ? props.booking.attendees[0].name : ""}
/> />
@ -210,7 +218,7 @@ export default function Book(props: any): JSX.Element {
name="email" name="email"
id="email" id="email"
required 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" placeholder="you@example.com"
defaultValue={props.booking ? props.booking.attendees[0].email : ""} defaultValue={props.booking ? props.booking.attendees[0].email : ""}
/> />
@ -252,7 +260,7 @@ export default function Book(props: any): JSX.Element {
placeholder="Enter phone number" placeholder="Enter phone number"
id="phone" id="phone"
required 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={() => { onChange={() => {
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */ /* 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} id={"custom_" + input.id}
required={input.required} required={input.required}
rows={3} 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="" placeholder=""
/> />
)} )}
@ -288,7 +296,7 @@ export default function Book(props: any): JSX.Element {
name={"custom_" + input.id} name={"custom_" + input.id}
id={"custom_" + input.id} id={"custom_" + input.id}
required={input.required} 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="" placeholder=""
/> />
)} )}
@ -298,7 +306,7 @@ export default function Book(props: any): JSX.Element {
name={"custom_" + input.id} name={"custom_" + input.id}
id={"custom_" + input.id} id={"custom_" + input.id}
required={input.required} 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="" placeholder=""
/> />
)} )}
@ -308,7 +316,7 @@ export default function Book(props: any): JSX.Element {
type="checkbox" type="checkbox"
name={"custom_" + input.id} name={"custom_" + input.id}
id={"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="" placeholder=""
/> />
<label <label
@ -320,6 +328,42 @@ export default function Book(props: any): JSX.Element {
)} )}
</div> </div>
))} ))}
<div className="mb-4">
{!guestToggle && (
<label
onClick={toggleGuestEmailInput}
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-blue-500 mb-1 hover:cursor-pointer">
+ Additional Guests
</label>
)}
{guestToggle && (
<div>
<label
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
Guests
</label>
<ReactMultiEmail
placeholder="guest@example.com"
emails={guestEmails}
onChange={(_emails: string[]) => {
setGuestEmails(_emails);
}}
getLabel={(email: string, index: number, removeEmail: (index: number) => void) => {
return (
<div data-tag key={index}>
{email}
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
</div>
);
}}
/>
</div>
)}
</div>
<div className="mb-4"> <div className="mb-4">
<label <label
htmlFor="notes" htmlFor="notes"
@ -330,13 +374,14 @@ export default function Book(props: any): JSX.Element {
name="notes" name="notes"
id="notes" id="notes"
rows={3} 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." placeholder="Please share anything that will help prepare for our meeting."
defaultValue={props.booking ? props.booking.description : ""} defaultValue={props.booking ? props.booking.description : ""}
/> />
</div> </div>
<div className="flex items-start"> <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"} {rescheduleUid ? "Reschedule" : "Confirm"}
</Button> </Button>
<Link <Link
@ -347,7 +392,7 @@ export default function Book(props: any): JSX.Element {
props.eventType.slug + props.eventType.slug +
(rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "") (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> </Link>
</div> </div>
</form> </form>
@ -408,10 +453,13 @@ export async function getServerSideProps(context) {
}, },
}); });
const eventTypeObject = Object.assign({}, eventType, { const eventTypeObject = [eventType].map((e) => {
periodStartDate: eventType.periodStartDate?.toString() ?? null, return {
periodEndDate: eventType.periodEndDate?.toString() ?? null, ...e,
}); periodStartDate: e.periodStartDate?.toString() ?? null,
periodEndDate: e.periodEndDate?.toString() ?? null,
};
})[0];
let booking = null; let booking = null;

View file

@ -9,10 +9,7 @@ function MyApp({ Component, pageProps }: AppProps) {
<TelemetryProvider value={createTelemetryClient()}> <TelemetryProvider value={createTelemetryClient()}>
<Provider session={pageProps.session}> <Provider session={pageProps.session}>
<Head> <Head>
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0" />
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
</Head> </Head>
<Component {...pageProps} /> <Component {...pageProps} />
</Provider> </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 { class MyDocument extends Document {
static async getInitialProps(ctx) { static async getInitialProps(ctx) {
@ -18,7 +18,7 @@ class MyDocument extends Document {
<meta name="msapplication-TileColor" content="#ff0000" /> <meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
</Head> </Head>
<body className="dark:bg-gray-900 bg-white"> <body className="dark:bg-black bg-gray-100">
<Main /> <Main />
<NextScript /> <NextScript />
</body> </body>

View file

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

View file

@ -1,17 +1,15 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { EventType } from "@prisma/client"; import { EventType, User } from "@prisma/client";
import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient"; import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import async from "async";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import short from "short-uuid"; import short from "short-uuid";
import { createMeeting, getBusyVideoTimes, updateMeeting } from "../../../lib/videoClient"; import { getBusyVideoTimes } from "@lib/videoClient";
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail"; import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
import { getEventName } from "../../../lib/event"; import { getEventName } from "@lib/event";
import { LocationType } from "../../../lib/location";
import merge from "lodash.merge";
import dayjs from "dayjs"; import dayjs from "dayjs";
import logger from "../../../lib/logger"; import logger from "../../../lib/logger";
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
@ -37,11 +35,6 @@ function isAvailable(busyTimes, time, length) {
const startTime = dayjs(busyTime.start); const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end); 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 // Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime)) { if (dayjs(time).isBetween(startTime, endTime)) {
t = false; 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( export async function handleLegacyConfirmationMail(
results: unknown[], results: Array<EventResult>,
eventType: EventType, eventType: EventType,
evt: CalendarEvent, evt: CalendarEvent,
hashUID: string hashUID: string
@ -285,7 +119,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error); return res.status(400).json(error);
} }
let currentUser = await prisma.user.findFirst({ let currentUser: User = await prisma.user.findFirst({
where: { where: {
username: user, 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]"), dayjs(req.body.end).endOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
selectedCalendars selectedCalendars
); );
const videoAvailability = await getBusyVideoTimes( const videoAvailability = await getBusyVideoTimes(currentUser.credentials);
currentUser.credentials,
dayjs(req.body.start).startOf("day").utc().format(),
dayjs(req.body.end).endOf("day").utc().format()
);
let commonAvailability = []; let commonAvailability = [];
if (hasCalendarIntegrations && hasVideoIntegrations) { 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")); // Initialize EventManager with credentials
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video")); const eventManager = new EventManager(currentUser.credentials);
const rescheduleUid = req.body.rescheduleUid; const rescheduleUid = req.body.rescheduleUid;
const eventType: EventType = await prisma.eventType.findFirst({ 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, type: eventType.title,
title: getEventName(req.body.name, eventType.title, eventType.eventName), title: getEventName(req.body.name, eventType.title, eventType.eventName),
description: req.body.notes, description: req.body.notes,
startTime: req.body.start, startTime: req.body.start,
endTime: req.body.end, endTime: req.body.end,
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, 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; let isAvailableToBeBooked = true;
try { try {
@ -445,44 +268,47 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error); return res.status(400).json(error);
} }
let results = []; let results: Array<EventResult> = [];
let referencesToCreate = []; let referencesToCreate = [];
if (rescheduleUid) { if (rescheduleUid) {
const __ret = await rescheduleEvent( // Use EventManager to conditionally use all needed integrations.
rescheduleUid, const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
results,
calendarCredentials, if (results.length > 0 && results.every((res) => !res.success)) {
evt, const error = {
videoCredentials, errorCode: "BookingReschedulingMeetingFailed",
referencesToCreate message: "Booking Rescheduling failed",
); };
if (__ret.error) {
log.error(`Booking ${user} failed`, __ret.error, results); log.error(`Booking ${user} failed`, error, results);
return res.status(500).json(__ret.error); return res.status(500).json(error);
} }
results = __ret.results;
referencesToCreate = __ret.referencesToCreate; // Forward results
results = updateResults.results;
referencesToCreate = updateResults.referencesToCreate;
} else if (!eventType.requiresConfirmation) { } else if (!eventType.requiresConfirmation) {
const __ret = await scheduleEvent( // Use EventManager to conditionally use all needed integrations.
results, const createResults: CreateUpdateResult = await eventManager.create(evt);
calendarCredentials,
evt, if (results.length > 0 && results.every((res) => !res.success)) {
videoCredentials, const error = {
referencesToCreate errorCode: "BookingCreatingMeetingFailed",
); message: "Booking failed",
if (__ret.error) { };
log.error(`Booking ${user} failed`, __ret.error, results);
return res.status(500).json(__ret.error); 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 = const hashUID =
results.length > 0 results.length > 0 ? results[0].uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
? results[0].response.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. // 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. // UID generation should happen in the integration itself, not here.
const legacyMailError = await handleLegacyConfirmationMail(results, eventType, evt, hashUID); const legacyMailError = await handleLegacyConfirmationMail(results, eventType, evt, hashUID);
@ -510,6 +336,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
create: evt.attendees, create: evt.attendees,
}, },
confirmed: !eventType.requiresConfirmation, confirmed: !eventType.requiresConfirmation,
location: evt.location,
}, },
}); });
} catch (e) { } catch (e) {

View file

@ -1,9 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/client"; import { getSession } from "next-auth/client";
import prisma from "../../../lib/prisma"; import prisma from "../../../lib/prisma";
import { handleLegacyConfirmationMail, scheduleEvent } from "./[user]"; import { handleLegacyConfirmationMail } from "./[user]";
import { CalendarEvent } from "@lib/calendarClient"; import { CalendarEvent } from "@lib/calendarClient";
import EventRejectionMail from "@lib/emails/EventRejectionMail"; import EventRejectionMail from "@lib/emails/EventRejectionMail";
import EventManager from "@lib/events/EventManager";
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> { export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const session = await getSession({ req: req }); const session = await getSession({ req: req });
@ -41,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: true, endTime: true,
confirmed: true, confirmed: true,
attendees: true, attendees: true,
location: true,
userId: true, userId: true,
id: true, id: true,
uid: 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" }); 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 = { const evt: CalendarEvent = {
type: booking.title, type: booking.title,
title: booking.title, title: booking.title,
@ -65,10 +64,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: booking.endTime.toISOString(), endTime: booking.endTime.toISOString(),
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
attendees: booking.attendees, attendees: booking.attendees,
location: booking.location,
}; };
if (req.body.confirmed) { 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( await handleLegacyConfirmationMail(
scheduleResult.results, scheduleResult.results,

View file

@ -1,7 +1,7 @@
import prisma from '../../lib/prisma'; import prisma from "../../lib/prisma";
import {deleteEvent} from "../../lib/calendarClient"; import { deleteEvent } from "../../lib/calendarClient";
import async from 'async'; import async from "async";
import {deleteMeeting} from "../../lib/videoClient"; import { deleteMeeting } from "../../lib/videoClient";
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method == "POST") { if (req.method == "POST") {
@ -15,36 +15,38 @@ export default async function handler(req, res) {
id: true, id: true,
user: { user: {
select: { select: {
credentials: true credentials: true,
} },
}, },
attendees: true, attendees: true,
references: { references: {
select: { select: {
uid: true, uid: true,
type: true type: true,
} },
} },
} },
}); });
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => { const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid; const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
if(credential.type.endsWith("_calendar")) { if (bookingRefUid) {
return await deleteEvent(credential, bookingRefUid); if (credential.type.endsWith("_calendar")) {
} else if(credential.type.endsWith("_video")) { return await deleteEvent(credential, bookingRefUid);
return await deleteMeeting(credential, bookingRefUid); } else if (credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
}
} }
}); });
const attendeeDeletes = prisma.attendee.deleteMany({ const attendeeDeletes = prisma.attendee.deleteMany({
where: { where: {
bookingId: bookingToDelete.id bookingId: bookingToDelete.id,
} },
}); });
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: { where: {
bookingId: bookingToDelete.id bookingId: bookingToDelete.id,
} },
}); });
const bookingDeletes = prisma.booking.delete({ const bookingDeletes = prisma.booking.delete({
where: { where: {
@ -52,17 +54,12 @@ export default async function handler(req, res) {
}, },
}); });
await Promise.all([ await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes, bookingDeletes]);
apiDeletes,
attendeeDeletes,
bookingReferenceDeletes,
bookingDeletes
]);
//TODO Perhaps send emails to user and client to tell about the cancellation //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 { } 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,45 +1,51 @@
import { useRouter } from 'next/router'; import { useRouter } from "next/router";
import { XIcon } from '@heroicons/react/outline'; import { XIcon } from "@heroicons/react/outline";
import Head from 'next/head'; import Head from "next/head";
import Link from 'next/link'; import Link from "next/link";
export default function Error() { export default function Error() {
const router = useRouter(); const router = useRouter();
const { error } = router.query; const { error } = router.query;
return ( return (
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <div
<Head> className="fixed z-50 inset-0 overflow-y-auto"
<title>{error} - Calendso</title> aria-labelledby="modal-title"
<link rel="icon" href="/favicon.ico" /> role="dialog"
</Head> 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"> <Head>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <title>{error} - Calendso</title>
<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"> <link rel="icon" href="/favicon.ico" />
<div> </Head>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100"> <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<XIcon className="h-6 w-6 text-red-600" /> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
</div> &#8203;
<div className="mt-3 text-center sm:mt-5"> </span>
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> <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">
{error} <div>
</h3> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<div className="mt-2"> <XIcon className="h-6 w-6 text-red-600" />
<p className="text-sm text-gray-500">
An error occurred when logging you in. Head back to the login screen and try again.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-sm border border-transparent shadow-sm px-4 py-2 bg-neutral-900 text-base font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:text-sm">
Go back to the login page
</a>
</Link>
</div>
</div>
</div> </div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{error}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
An error occurred when logging you in. Head back to the login screen and try again.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-sm border border-transparent shadow-sm px-4 py-2 bg-neutral-900 text-base font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:text-sm">
Go back to the login page
</a>
</Link>
</div>
</div> </div>
); </div>
</div>
);
} }

View file

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

View file

@ -1,41 +1,45 @@
import Head from 'next/head'; import Head from "next/head";
import Link from 'next/link'; import Link from "next/link";
import { CheckIcon } from '@heroicons/react/outline'; import { CheckIcon } from "@heroicons/react/outline";
export default function Logout() { export default function Logout() {
return ( return (
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <div
<Head> className="fixed z-50 inset-0 overflow-y-auto"
<title>Logged out - Calendso</title> aria-labelledby="modal-title"
<link rel="icon" href="/favicon.ico" /> role="dialog"
</Head> 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"> <Head>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <title>Logged out - Calendso</title>
<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"> <link rel="icon" href="/favicon.ico" />
<div> </Head>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100"> <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<CheckIcon className="h-6 w-6 text-green-600" /> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
</div> &#8203;
<div className="mt-3 text-center sm:mt-5"> </span>
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> <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">
You&apos;ve been logged out <div>
</h3> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<div className="mt-2"> <CheckIcon className="h-6 w-6 text-green-600" />
<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">
Go back to the login page
</a>
</Link>
</div>
</div>
</div> </div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
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>
</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-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>
</div>
</div> </div>
); </div>
</div>
);
} }

View file

@ -1,27 +1,25 @@
import Head from 'next/head'; import Head from "next/head";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {signIn} from 'next-auth/client' import { signIn } from "next-auth/client";
import ErrorAlert from "../../components/ui/alerts/Error"; import ErrorAlert from "../../components/ui/alerts/Error";
import {useState} from "react"; import { useState } from "react";
import {UsernameInput} from "../../components/ui/UsernameInput"; import { UsernameInput } from "../../components/ui/UsernameInput";
import prisma from "../../lib/prisma"; import prisma from "../../lib/prisma";
export default function Signup(props) { export default function Signup(props) {
const router = useRouter(); const router = useRouter();
const [ hasErrors, setHasErrors ] = useState(false); const [hasErrors, setHasErrors] = useState(false);
const [ errorMessage, setErrorMessage ] = useState(''); const [errorMessage, setErrorMessage] = useState("");
const handleErrors = async (resp) => { const handleErrors = async (resp) => {
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json(); const err = await resp.json();
throw new Error(err.message); throw new Error(err.message);
} }
} };
const signUp = (e) => { const signUp = (e) => {
e.preventDefault(); e.preventDefault();
if (e.target.password.value !== e.target.passwordcheck.value) { 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 email: string = e.target.email.value;
const password: string = e.target.password.value; const password: string = e.target.password.value;
fetch('/api/auth/signup', fetch("/api/auth/signup", {
{ body: JSON.stringify({
body: JSON.stringify({ username: e.target.username.value,
username: e.target.username.value, password,
password, email,
email, }),
}), headers: {
headers: { "Content-Type": "application/json",
'Content-Type': 'application/json', },
}, method: "POST",
method: 'POST' })
}
)
.then(handleErrors) .then(handleErrors)
.then( .then(() => signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string }))
() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string }) .catch((err) => {
)
.catch( (err) => {
setHasErrors(true); setHasErrors(true);
setErrorMessage(err.message); setErrorMessage(err.message);
}); });
}; };
return ( 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> <Head>
<title>Sign up</title> <title>Sign up</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="text-center text-3xl font-extrabold text-gray-900"> <h2 className="text-center text-3xl font-extrabold text-gray-900">Create your account</h2>
Create your account
</h2>
</div> </div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <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"> <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 /> <UsernameInput required />
</div> </div>
<div className="mb-2"> <div className="mb-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label> <label htmlFor="email" className="block text-sm font-medium text-gray-700">
<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" /> 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>
<div className="mb-2"> <div className="mb-2">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label> <label htmlFor="password" className="block text-sm font-medium text-gray-700">
<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" /> 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>
<div> <div>
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">Confirm password</label> <label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">
<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" /> 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> </div>
<div className="mt-3 sm:mt-4 flex"> <div className="mt-3 sm:mt-4 flex">
<input type="submit" value="Create Account" <input
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" /> type="submit"
<a onClick={() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })} value="Create Account"
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> 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> </div>
</form> </form>
</div> </div>
@ -103,39 +136,41 @@ export async function getServerSideProps(ctx) {
if (!ctx.query.token) { if (!ctx.query.token) {
return { return {
notFound: true, notFound: true,
} };
} }
const verificationRequest = await prisma.verificationRequest.findUnique({ const verificationRequest = await prisma.verificationRequest.findUnique({
where: { where: {
token: ctx.query.token, token: ctx.query.token,
} },
}); });
// for now, disable if no verificationRequestToken given or token expired // for now, disable if no verificationRequestToken given or token expired
if ( ! verificationRequest || verificationRequest.expires < new Date() ) { if (!verificationRequest || verificationRequest.expires < new Date()) {
return { return {
notFound: true, notFound: true,
} };
} }
const existingUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findFirst({
where: { where: {
AND: [ AND: [
{ {
email: verificationRequest.identifier email: verificationRequest.identifier,
}, },
{ {
emailVerified: { emailVerified: {
not: null, not: null,
}, },
} },
] ],
} },
}); });
if (existingUser) { 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 } }; return { props: { email: verificationRequest.identifier } };
} }

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import { Menu, Transition } from "@headlessui/react";
import { DotsHorizontalIcon } from "@heroicons/react/solid"; import { DotsHorizontalIcon } from "@heroicons/react/solid";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { ClockIcon, XIcon } from "@heroicons/react/outline"; import { ClockIcon, XIcon } from "@heroicons/react/outline";
import Loader from "@components/Loader";
export default function Bookings({ bookings }) { export default function Bookings({ bookings }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -17,7 +18,7 @@ export default function Bookings({ bookings }) {
const router = useRouter(); const router = useRouter();
if (loading) { if (loading) {
return <p className="text-gray-400">Loading...</p>; return <Loader />;
} }
async function confirmBookingHandler(booking, confirm: boolean) { 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="-mx-4 sm:mx-auto flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <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="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"> <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"> <tbody className="bg-white divide-y divide-gray-200">
{bookings {bookings
.filter((booking) => !booking.confirmed && !booking.rejected) .filter((booking) => !booking.confirmed && !booking.rejected)
@ -70,7 +54,7 @@ export default function Bookings({ bookings }) {
<tr key={booking.id}> <tr key={booking.id}>
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}> <td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
{!booking.confirmed && !booking.rejected && ( {!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 Unconfirmed
</span> </span>
)} )}
@ -140,7 +124,7 @@ export default function Bookings({ bookings }) {
{({ open }) => ( {({ open }) => (
<> <>
<div> <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> <span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" /> <DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button> </Menu.Button>

View file

@ -1,14 +1,14 @@
import {useState} from 'react'; import { useState } from "react";
import Head from 'next/head'; import Head from "next/head";
import prisma from '../../lib/prisma'; import prisma from "../../lib/prisma";
import {useRouter} from 'next/router'; import { useRouter } from "next/router";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import {CalendarIcon, ClockIcon, XIcon} from '@heroicons/react/solid'; import { CalendarIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from "dayjs/plugin/isBetween";
import utc from 'dayjs/plugin/utc'; import utc from "dayjs/plugin/utc";
import timezone from 'dayjs/plugin/timezone'; import timezone from "dayjs/plugin/timezone";
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween); dayjs.extend(isBetween);
@ -16,153 +16,164 @@ dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export default function Type(props) { export default function Type(props) {
// Get router variables // Get router variables
const router = useRouter(); const router = useRouter();
const { uid } = router.query; const { uid } = router.query;
const [is24h, setIs24h] = useState(false); // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [loading, setLoading] = useState(false); const [is24h, setIs24h] = useState(false);
const [error, setError] = useState(null); const [loading, setLoading] = useState(false);
const telemetry = useTelemetry(); const [error, setError] = useState(null);
const telemetry = useTelemetry();
const cancellationHandler = async (event) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars
setLoading(true); const cancellationHandler = async (event) => {
setLoading(true);
let payload = { const payload = {
uid: uid uid: uid,
}; };
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())); telemetry.withJitsu((jitsu) =>
const res = await fetch( jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())
'/api/cancel',
{
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}
);
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
</title>
<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="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>
<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>
<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-title">
{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>
<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>
</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"/>
{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")}
</p>
</div>
</div>
</div>}
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button onClick={cancellationHandler} disabled={loading} type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white">
Cancel
</button>
<button onClick={() => router.push('/reschedule/' + uid)} disabled={loading} type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Reschedule
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
); );
const res = await fetch("/api/cancel", {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
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
</title>
<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="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>
<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>
<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-title">
{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>
<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>
</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" />
{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")}
</p>
</div>
</div>
</div>
)}
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button
onClick={cancellationHandler}
disabled={loading}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white">
Cancel
</button>
<button
onClick={() => router.push("/reschedule/" + uid)}
disabled={loading}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Reschedule
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
} }
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const booking = await prisma.booking.findFirst({ const booking = await prisma.booking.findFirst({
where: { where: {
uid: context.query.uid, uid: context.query.uid,
}, },
select: {
id: true,
title: true,
description: true,
startTime: true,
endTime: true,
attendees: true,
eventType: true,
user: {
select: { select: {
id: true, id: true,
title: true, username: true,
description: true, name: true,
startTime: true,
endTime: true,
attendees: true,
eventType: true,
user: {
select: {
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()
});
return {
props: {
user: booking.user,
eventType: booking.eventType,
booking: bookingObj
}, },
} },
},
});
// 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(),
});
return {
props: {
user: booking.user,
eventType: booking.eventType,
booking: bookingObj,
},
};
} }

View file

@ -1,12 +1,12 @@
import Head from 'next/head'; import Head from "next/head";
import prisma from '../../lib/prisma'; import prisma from "../../lib/prisma";
import {useRouter} from 'next/router'; import { useRouter } from "next/router";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from "dayjs/plugin/isBetween";
import utc from 'dayjs/plugin/utc'; import utc from "dayjs/plugin/utc";
import timezone from 'dayjs/plugin/timezone'; import timezone from "dayjs/plugin/timezone";
import {CheckIcon} from "@heroicons/react/outline"; import { CheckIcon } from "@heroicons/react/outline";
dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween); dayjs.extend(isBetween);
@ -14,78 +14,79 @@ dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export default function Type(props) { export default function Type(props) {
// Get router variables // Get router variables
const router = useRouter(); const router = useRouter();
return ( return (
<div> <div>
<Head> <Head>
<title> <title>
Cancelled {props.title} | {props.user.name || props.user.username} | Cancelled {props.title} | {props.user.name || props.user.username} | Calendso
Calendso </title>
</title> <link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico"/> </Head>
</Head> <main className="max-w-3xl mx-auto my-24">
<main className="max-w-3xl mx-auto my-24"> <div className="fixed z-50 inset-0 overflow-y-auto">
<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 <div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
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">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true"> &#8203;
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" </span>
aria-hidden="true">&#8203;</span> <div
<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"
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"
role="dialog" aria-modal="true" aria-labelledby="modal-headline"> aria-modal="true"
<div> aria-labelledby="modal-headline">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100"> <div>
<CheckIcon className="h-6 w-6 text-green-600" /> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
</div> <CheckIcon className="h-6 w-6 text-green-600" />
<div className="mt-3 text-center sm:mt-5"> </div>
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline"> <div className="mt-3 text-center sm:mt-5">
Cancellation successful <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
</h3> Cancellation successful
<div className="mt-2"> </h3>
<p className="text-sm text-gray-500"> <div className="mt-2">
Feel free to pick another event anytime. <p className="text-sm text-gray-500">Feel free to pick another event anytime.</p>
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button onClick={() => router.push('/' + props.user.username)} type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Pick another
</button>
</div>
</div>
</div>
</div>
</div> </div>
</div>
</div> </div>
</main> <div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button
onClick={() => router.push("/" + props.user.username)}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Pick another
</button>
</div>
</div>
</div>
</div>
</div>
</div> </div>
); </main>
</div>
);
} }
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
username: context.query.user, username: context.query.user,
}, },
select: { select: {
username: true, username: true,
name: true, name: true,
bio: true, bio: true,
avatar: true, avatar: true,
eventTypes: true eventTypes: true,
} },
}); });
return { return {
props: { props: {
user, 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 Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import Select, { OptionBase } from "react-select"; import Select, { OptionBase } from "react-select";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { LocationType } from "@lib/location"; import { LocationType } from "@lib/location";
import Shell from "@components/Shell"; import Shell from "@components/Shell";
import { getSession } from "next-auth/client"; import { getSession } from "next-auth/client";
import { Scheduler } from "@components/ui/Scheduler"; 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 { PhoneIcon, XIcon } from "@heroicons/react/outline";
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput"; import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
import { import {
LocationMarkerIcon, LocationMarkerIcon,
LinkIcon, LinkIcon,
PencilIcon,
PlusIcon, PlusIcon,
DocumentIcon, DocumentIcon,
ChevronRightIcon, ChevronRightIcon,
@ -30,12 +28,14 @@ import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import { Availability, EventType, User } from "@prisma/client"; import { Availability, EventType, User } from "@prisma/client";
import { validJson } from "@lib/jsonUtils"; import { validJson } from "@lib/jsonUtils";
import { RadioGroup } from "@headlessui/react";
import classnames from "classnames"; import classnames from "classnames";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import "react-dates/initialize"; import "react-dates/initialize";
import "react-dates/lib/css/_datepicker.css"; import "react-dates/lib/css/_datepicker.css";
import { DateRangePicker, OrientationShape, toMomentObject } from "react-dates"; 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(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -59,7 +59,17 @@ type DateOverride = {
endTime: number; 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; id: number;
title: string; title: string;
slug: string; slug: string;
@ -67,16 +77,9 @@ type EventTypeInput = {
length: number; length: number;
hidden: boolean; hidden: boolean;
locations: unknown; locations: unknown;
eventName: string;
customInputs: EventTypeCustomInput[]; customInputs: EventTypeCustomInput[];
timeZone: string; timeZone: string;
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }; availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
periodType?: string;
periodDays?: number;
periodStartDate?: Date | string;
periodEndDate?: Date | string;
periodCountCalendarDays?: boolean;
enteredRequiresConfirmation: boolean;
}; };
const PERIOD_TYPES = [ const PERIOD_TYPES = [
@ -102,7 +105,6 @@ export default function EventTypePage({
}: Props): JSX.Element { }: Props): JSX.Element {
const router = useRouter(); const router = useRouter();
console.log(eventType);
const inputOptions: OptionBase[] = [ const inputOptions: OptionBase[] = [
{ value: EventTypeCustomInputType.Text, label: "Text" }, { value: EventTypeCustomInputType.Text, label: "Text" },
{ value: EventTypeCustomInputType.TextLong, label: "Multiline 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 titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>(); const slugRef = useRef<HTMLInputElement>();
const descriptionRef = useRef<HTMLTextAreaElement>(); const descriptionRef = useRef<HTMLTextAreaElement>();
const lengthRef = useRef<HTMLInputElement>(); const lengthRef = useRef<HTMLInputElement>();
const isHiddenRef = useRef<HTMLInputElement>();
const requiresConfirmationRef = useRef<HTMLInputElement>(); const requiresConfirmationRef = useRef<HTMLInputElement>();
const eventNameRef = useRef<HTMLInputElement>(); const eventNameRef = useRef<HTMLInputElement>();
const periodDaysRef = useRef<HTMLInputElement>(); const periodDaysRef = useRef<HTMLInputElement>();
@ -199,26 +201,17 @@ export default function EventTypePage({
const enteredSlug: string = slugRef.current.value; const enteredSlug: string = slugRef.current.value;
const enteredDescription: string = descriptionRef.current.value; const enteredDescription: string = descriptionRef.current.value;
const enteredLength: number = parseInt(lengthRef.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 advancedOptionsPayload: AdvancedOptions = {};
const enteredPeriodDays = parseInt(periodDaysRef?.current?.value); if (requiresConfirmationRef.current) {
const enteredPeriodDaysType = Boolean(parseInt(periodDaysTypeRef?.current.value)); advancedOptionsPayload.requiresConfirmation = requiresConfirmationRef.current.checked;
advancedOptionsPayload.eventName = eventNameRef.current.value;
const enteredPeriodStartDate = periodStartDate ? periodStartDate.toDate() : null; advancedOptionsPayload.periodType = periodType.type;
const enteredPeriodEndDate = periodEndDate ? periodEndDate.toDate() : null; advancedOptionsPayload.periodDays = parseInt(periodDaysRef?.current?.value);
advancedOptionsPayload.periodCountCalendarDays = Boolean(parseInt(periodDaysTypeRef?.current.value));
console.log("values", { advancedOptionsPayload.periodStartDate = periodStartDate ? periodStartDate.toDate() : null;
type, advancedOptionsPayload.periodEndDate = periodEndDate ? periodEndDate.toDate() : null;
periodDaysTypeRef, }
enteredPeriodDays,
enteredPeriodDaysType,
enteredPeriodStartDate,
enteredPeriodEndDate,
});
// TODO: Add validation
const payload: EventTypeInput = { const payload: EventTypeInput = {
id: eventType.id, id: eventType.id,
@ -226,23 +219,14 @@ export default function EventTypePage({
slug: enteredSlug, slug: enteredSlug,
description: enteredDescription, description: enteredDescription,
length: enteredLength, length: enteredLength,
hidden: enteredIsHidden, hidden,
locations, locations,
eventName: enteredEventName,
customInputs, customInputs,
timeZone: selectedTimeZone, timeZone: selectedTimeZone,
periodType: type, availability: enteredAvailability || null,
periodDays: enteredPeriodDays, ...advancedOptionsPayload,
periodStartDate: enteredPeriodStartDate,
periodEndDate: enteredPeriodEndDate,
periodCountCalendarDays: enteredPeriodDaysType,
requiresConfirmation: enteredRequiresConfirmation,
}; };
if (enteredAvailability) {
payload.availability = enteredAvailability;
}
await fetch("/api/availability/eventtype", { await fetch("/api/availability/eventtype", {
method: "PATCH", method: "PATCH",
body: JSON.stringify(payload), body: JSON.stringify(payload),
@ -251,7 +235,7 @@ export default function EventTypePage({
}, },
}); });
router.push("/availability"); router.push("/event-types");
} }
async function deleteEventTypeHandler(event) { async function deleteEventTypeHandler(event) {
@ -265,7 +249,7 @@ export default function EventTypePage({
}, },
}); });
router.push("/availability"); router.push("/event-types");
} }
const openLocationModal = (type: LocationType) => { const openLocationModal = (type: LocationType) => {
@ -388,35 +372,28 @@ export default function EventTypePage({
<title>{eventType.title} | Event Type | Calendso</title> <title>{eventType.title} | Event Type | Calendso</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Shell heading={"Event Type: " + eventType.title} subtitle={eventType.description}> <Shell
heading={
<input
ref={titleRef}
type="text"
name="title"
id="title"
required
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}
/>
}
subtitle={eventType.description}>
<div className="block sm:flex"> <div className="block sm:flex">
<div className="w-full sm:w-10/12 mr-2"> <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"> <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"> <form onSubmit={updateEventTypeHandler} className="space-y-4">
<div className="block sm:flex"> <div className="block sm:flex items-center">
<div className="min-w-32 mb-4 sm:mb-0"> <div className="min-w-44 mb-4 sm:mb-0">
<label htmlFor="title" className="flex font-medium text-neutral-700 mt-1"> <label htmlFor="slug" className="text-sm flex font-medium text-neutral-700 mt-0">
<PencilIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" /> <LinkIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
Title
</label>
</div>
<div className="w-full">
<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"
placeholder="Quick Chat"
defaultValue={eventType.title}
/>
</div>
</div>
<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" />
URL URL
</label> </label>
</div> </div>
@ -437,10 +414,41 @@ export default function EventTypePage({
</div> </div>
</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">
<label htmlFor="location" className="flex font-medium text-neutral-700 mt-1"> <div className="min-w-44 mb-4 sm:mb-0">
<LocationMarkerIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" /> <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 Location
</label> </label>
</div> </div>
@ -468,19 +476,19 @@ export default function EventTypePage({
className="mb-2 p-2 border border-neutral-300 rounded-sm shadow-sm"> className="mb-2 p-2 border border-neutral-300 rounded-sm shadow-sm">
<div className="flex justify-between"> <div className="flex justify-between">
{location.type === LocationType.InPerson && ( {location.type === LocationType.InPerson && (
<div className="flex-grow flex"> <div className="flex-grow flex items-center">
<LocationMarkerIcon className="h-6 w-6" /> <LocationMarkerIcon className="h-6 w-6" />
<span className="ml-2 text-sm">{location.address}</span> <span className="ml-2 text-sm">{location.address}</span>
</div> </div>
)} )}
{location.type === LocationType.Phone && ( {location.type === LocationType.Phone && (
<div className="flex-grow flex"> <div className="flex-grow flex items-center">
<PhoneIcon className="h-6 w-6" /> <PhoneIcon className="h-6 w-6" />
<span className="ml-2 text-sm">Phone call</span> <span className="ml-2 text-sm">Phone call</span>
</div> </div>
)} )}
{location.type === LocationType.GoogleMeet && ( {location.type === LocationType.GoogleMeet && (
<div className="flex-grow flex"> <div className="flex-grow flex items-center">
<svg <svg
className="h-6 w-6" className="h-6 w-6"
viewBox="0 0 64 54" viewBox="0 0 64 54"
@ -511,7 +519,7 @@ export default function EventTypePage({
</div> </div>
)} )}
{location.type === LocationType.Zoom && ( {location.type === LocationType.Zoom && (
<div className="flex-grow flex"> <div className="flex-grow flex items-center">
<svg <svg
className="h-6 w-6" className="h-6 w-6"
viewBox="0 0 64 64" viewBox="0 0 64 64"
@ -555,10 +563,12 @@ export default function EventTypePage({
<li> <li>
<button <button
type="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)}> onClick={() => setShowLocationModal(true)}>
<PlusIcon className="h-5 w-5" /> <PlusIcon className="h-4 w-4 mt-0.5 text-neutral-900" />
<span className="font-medium">Add another location option</span> <span className="ml-1 text-neutral-700 text-sm font-medium">
Add another location
</span>
</button> </button>
</li> </li>
)} )}
@ -566,37 +576,13 @@ export default function EventTypePage({
)} )}
</div> </div>
</div> </div>
<div className="block sm:flex">
<div className="min-w-32 mb-4 sm:mb-0"> <hr className="border-neutral-200" />
<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" /> <div className="block sm:flex items-center">
Duration <div className="min-w-44 mb-4 sm:mb-0">
</label> <label htmlFor="description" className="text-sm flex font-medium text-neutral-700 mt-0">
</div> <DocumentIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
<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" />
Description Description
</label> </label>
</div> </div>
@ -620,9 +606,11 @@ export default function EventTypePage({
<span className="text-neutral-700 text-sm font-medium">Show advanced settings</span> <span className="text-neutral-700 text-sm font-medium">Show advanced settings</span>
</Disclosure.Button> </Disclosure.Button>
<Disclosure.Panel className="space-y-4"> <Disclosure.Panel className="space-y-4">
<div className="block sm:flex"> <div className="block sm:flex items-center">
<div className="min-w-32 mb-4 sm:mb-0"> <div className="min-w-44 mb-4 sm:mb-0">
<label htmlFor="eventName" className="flex font-medium text-neutral-700 mt-2"> <label
htmlFor="eventName"
className="text-sm flex font-medium text-neutral-700 mt-2">
Event name Event name
</label> </label>
</div> </div>
@ -640,11 +628,11 @@ export default function EventTypePage({
</div> </div>
</div> </div>
</div> </div>
<div className="block sm:flex"> <div className="block sm:flex items-center">
<div className="min-w-32 mb-4 sm:mb-0"> <div className="min-w-44 mb-4 sm:mb-0">
<label <label
htmlFor="additionalFields" htmlFor="additionalFields"
className="flex font-medium text-neutral-700 mt-2"> className="text-sm flex font-medium text-neutral-700 mt-2">
Additional inputs Additional inputs
</label> </label>
</div> </div>
@ -694,38 +682,11 @@ export default function EventTypePage({
</ul> </ul>
</div> </div>
</div> </div>
<div className="block sm:flex"> <div className="block sm:flex items-center">
<div className="min-w-32 mb-4 sm:mb-0"> <div className="min-w-44 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">
<label <label
htmlFor="requiresConfirmation" htmlFor="requiresConfirmation"
className="flex font-medium text-neutral-700"> className="text-sm flex font-medium text-neutral-700">
Opt-in booking Opt-in booking
</label> </label>
</div> </div>
@ -750,11 +711,14 @@ export default function EventTypePage({
</div> </div>
</div> </div>
</div> </div>
<hr className="border-neutral-200" />
<div className="block sm:flex"> <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 <label
htmlFor="inviteesCanSchedule" 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 Invitees can schedule
</label> </label>
</div> </div>
@ -849,9 +813,14 @@ export default function EventTypePage({
</RadioGroup> </RadioGroup>
</div> </div>
</div> </div>
<hr className="border-neutral-200" />
<div className="block sm:flex"> <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="availability" className="flex font-medium text-neutral-700 mt-2"> <label
htmlFor="availability"
className="text-sm flex font-medium text-neutral-700 mt-2">
Availability Availability
</label> </label>
</div> </div>
@ -885,6 +854,12 @@ export default function EventTypePage({
</div> </div>
<div className="w-full sm:w-2/12 ml-2 px-4 mt-8 sm:mt-0 min-w-32"> <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"> <div className="space-y-4">
<Switch
name="isHidden"
defaultChecked={hidden}
onCheckedChange={setHidden}
label="Hide event type"
/>
<a <a
href={"/" + user.username + "/" + eventType.slug} href={"/" + user.username + "/" + eventType.slug}
target="_blank" target="_blank"
@ -904,13 +879,20 @@ export default function EventTypePage({
<LinkIcon className="w-4 h-4 mt-1 mr-2 text-neutral-500" /> <LinkIcon className="w-4 h-4 mt-1 mr-2 text-neutral-500" />
Copy link Copy link
</button> </button>
<button <Dialog>
onClick={deleteEventTypeHandler} <DialogTrigger className="flex text-md font-medium text-neutral-700">
type="button" <TrashIcon className="w-4 h-4 mt-1 mr-2 text-neutral-500" />
className="flex text-md font-medium text-neutral-700"> Delete
<TrashIcon className="w-4 h-4 mt-1 mr-2 text-neutral-500" /> </DialogTrigger>
Delete <ConfirmationDialogContent
</button> 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> </div>
</div> </div>

View file

@ -1,12 +1,7 @@
import Head from "next/head"; import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@components/Dialog";
import Link from "next/link"; import { Tooltip } from "@components/Tooltip";
import prisma from "../../lib/prisma"; import Loader from "@components/Loader";
import Shell from "../../components/Shell";
import { useRouter } from "next/router";
import { getSession, useSession } from "next-auth/client";
import React, { Fragment, useRef } from "react";
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
import { import {
ClockIcon, ClockIcon,
DotsHorizontalIcon, DotsHorizontalIcon,
@ -16,9 +11,14 @@ import {
PlusIcon, PlusIcon,
UserIcon, UserIcon,
} from "@heroicons/react/solid"; } from "@heroicons/react/solid";
import Loader from "@components/Loader";
import classNames from "@lib/classNames"; 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 }) { export default function Availability({ user, types }) {
const [session, loading] = useSession(); const [session, loading] = useSession();
@ -73,8 +73,8 @@ export default function Availability({ user, types }) {
New event type New event type
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<div className="mb-4"> <div className="mb-8">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> <h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
Add a new event type Add a new event type
</h3> </h3>
<div> <div>
@ -153,7 +153,7 @@ export default function Availability({ user, types }) {
</div> </div>
</div> </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"> <button type="submit" className="btn btn-primary">
Continue Continue
</button> </button>
@ -176,15 +176,15 @@ export default function Availability({ user, types }) {
heading="Event Types" heading="Event Types"
subtitle="Create events to share for people to book on your calendar." subtitle="Create events to share for people to book on your calendar."
CTA={types.length !== 0 && <CreateNewEventDialog />}> 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"> <ul className="divide-y divide-neutral-200">
{types.map((type) => ( {types.map((type) => (
<li key={type.id}> <li key={type.id}>
<Link href={"/event-types/" + type.id}> <div className="hover:bg-neutral-50">
<a className="block hover:bg-neutral-50"> <div className="px-4 py-4 flex items-center sm:px-6">
<div className="px-4 py-4 flex items-center sm:px-6"> <Link href={"/event-types/" + type.id}>
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between"> <a className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate"> <span className="truncate ">
<div className="flex text-sm"> <div className="flex text-sm">
<p className="font-medium text-neutral-900 truncate">{type.title}</p> <p className="font-medium text-neutral-900 truncate">{type.title}</p>
{type.hidden && ( {type.hidden && (
@ -196,156 +196,166 @@ export default function Availability({ user, types }) {
<div className="mt-2 flex space-x-4"> <div className="mt-2 flex space-x-4">
<div className="flex items-center text-sm text-neutral-500"> <div className="flex items-center text-sm text-neutral-500">
<ClockIcon <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" aria-hidden="true"
/> />
<p>{type.length}m</p> <p>{type.length}m</p>
</div> </div>
<div className="flex items-center text-sm text-neutral-500"> <div className="flex items-center text-sm text-neutral-500">
<UserIcon <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" aria-hidden="true"
/> />
<p>1-on-1</p> <p>1-on-1</p>
</div> </div>
<div className="flex items-center text-sm text-neutral-500"> <div className="flex items-center text-sm text-neutral-500">
<InformationCircleIcon <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" 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> </div>
</div> </span>
<div className="mt-4 flex-shrink-0 sm:mt-0 sm:ml-5"> </a>
<div className="flex overflow-hidden space-x-5"> </Link>
<Link href={"/" + session.user.username + "/" + type.slug}>
<a className="text-neutral-400">
<ExternalLinkIcon className="w-5 h-5" />
</a>
</Link>
<button
onClick={() => {
navigator.clipboard.writeText(
window.location.hostname + "/" + session.user.username + "/" + type.slug
);
}}
className="text-neutral-400">
<LinkIcon className="w-5 h-5" />
</button>
</div>
</div>
</div>
<div className="ml-5 flex-shrink-0">
<Menu as="div" className="inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1">
<span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition <div className="hidden sm:flex mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
show={open} <div className="flex overflow-hidden space-x-5">
as={Fragment} <Tooltip content="Preview">
enter="transition ease-out duration-100" <a
enterFrom="transform opacity-0 scale-95" href={"/" + session.user.username + "/" + type.slug}
enterTo="transform opacity-100 scale-100" target="_blank"
leave="transition ease-in duration-75" rel="noreferrer"
leaveFrom="transform opacity-100 scale-100" className="group cursor-pointer text-neutral-400 p-2 border border-transparent hover:border-gray-200">
leaveTo="transform opacity-0 scale-95"> <ExternalLinkIcon className="group-hover:text-black w-5 h-5" />
<Menu.Items </a>
static </Tooltip>
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
<div className="py-1"> <Tooltip content="Copy link">
<Menu.Item> <button
{({ active }) => ( onClick={() => {
<a navigator.clipboard.writeText(
href={"/" + session.user.username + "/" + type.slug} window.location.hostname + "/" + session.user.username + "/" + type.slug
target="_blank" );
rel="noreferrer" }}
className={classNames( className="group text-neutral-400 p-2 border border-transparent hover:border-gray-200">
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700", <LinkIcon className="group-hover:text-black w-5 h-5" />
"group flex items-center px-4 py-2 text-sm font-medium" </button>
)}> </Tooltip>
<ExternalLinkIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Preview
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => {
navigator.clipboard.writeText(
window.location.hostname +
"/" +
session.user.username +
"/" +
type.slug
);
}}
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"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"
aria-hidden="true"
/>
Copy link to event
</button>
)}
</Menu.Item>
{/*<Menu.Item>*/}
{/* {({ active }) => (*/}
{/* <a*/}
{/* href="#"*/}
{/* className={classNames(*/}
{/* active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",*/}
{/* "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"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* Duplicate*/}
{/* </a>*/}
{/* )}*/}
{/*</Menu.Item>*/}
</div>
{/*<div className="py-1">*/}
{/* <Menu.Item>*/}
{/* {({ active }) => (*/}
{/* <a*/}
{/* href="#"*/}
{/* className={classNames(*/}
{/* active ? "bg-red-100 text-red-900" : "text-red-700",*/}
{/* "group flex items-center px-4 py-2 text-sm font-medium"*/}
{/* )}>*/}
{/* <TrashIcon*/}
{/* className="mr-3 h-5 w-5 text-red-400 group-hover:text-red-700"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* Delete*/}
{/* </a>*/}
{/* )}*/}
{/* </Menu.Item>*/}
{/*</div>*/}
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div> </div>
</div> </div>
</a> <div className="flex sm:hidden ml-5 flex-shrink-0">
</Link> <Menu as="div" className="inline-block text-left">
{({ open }) => (
<>
<div>
<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>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
href={"/" + session.user.username + "/" + type.slug}
target="_blank"
rel="noreferrer"
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<ExternalLinkIcon
className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Preview
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => {
navigator.clipboard.writeText(
window.location.hostname +
"/" +
session.user.username +
"/" +
type.slug
);
}}
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm w-full font-medium"
)}>
<LinkIcon
className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Copy link to event
</button>
)}
</Menu.Item>
{/*<Menu.Item>*/}
{/* {({ active }) => (*/}
{/* <a*/}
{/* href="#"*/}
{/* className={classNames(*/}
{/* active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",*/}
{/* "group flex items-center px-4 py-2 text-sm font-medium"*/}
{/* )}>*/}
{/* <DuplicateIcon*/}
{/* className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* Duplicate*/}
{/* </a>*/}
{/* )}*/}
{/*</Menu.Item>*/}
</div>
{/*<div className="py-1">*/}
{/* <Menu.Item>*/}
{/* {({ active }) => (*/}
{/* <a*/}
{/* href="#"*/}
{/* className={classNames(*/}
{/* active ? "bg-red-100 text-red-900" : "text-red-700",*/}
{/* "group flex items-center px-4 py-2 text-sm font-medium"*/}
{/* )}>*/}
{/* <TrashIcon*/}
{/* className="mr-3 h-5 w-5 text-red-400 group-hover:text-red-700"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* Delete*/}
{/* </a>*/}
{/* )}*/}
{/* </Menu.Item>*/}
{/*</div>*/}
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div>
</div>
</div>
</li> </li>
))} ))}
</ul> </ul>

View file

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

View file

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

View file

@ -4,33 +4,17 @@ import prisma from "../../lib/prisma";
import Shell from "../../components/Shell"; import Shell from "../../components/Shell";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getSession, useSession } from "next-auth/client"; import { getSession, useSession } from "next-auth/client";
import { import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid";
CalendarIcon,
CheckCircleIcon,
ChevronRightIcon,
PlusIcon,
XCircleIcon,
} from "@heroicons/react/solid";
import { InformationCircleIcon } from "@heroicons/react/outline"; import { InformationCircleIcon } from "@heroicons/react/outline";
import { Switch } from "@headlessui/react"; import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
import Loader from '@components/Loader'; import Switch from "@components/ui/Switch";
import classNames from "@lib/classNames"; 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 [session, loading] = useSession();
const [showAddModal, setShowAddModal] = useState(false);
const [showSelectCalendarModal, setShowSelectCalendarModal] = useState(false);
const [selectableCalendars, setSelectableCalendars] = useState([]); const [selectableCalendars, setSelectableCalendars] = useState([]);
function toggleAddModal() {
setShowAddModal(!showAddModal);
}
function toggleShowCalendarModal() {
setShowSelectCalendarModal(!showSelectCalendarModal);
}
function loadCalendars() { function loadCalendars() {
fetch("api/availability/calendar") fetch("api/availability/calendar")
.then((response) => response.json()) .then((response) => response.json())
@ -47,17 +31,15 @@ export default function Home({ integrations }) {
function calendarSelectionHandler(calendar) { function calendarSelectionHandler(calendar) {
return (selected) => { return (selected) => {
const cals = [...selectableCalendars]; const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
const i = cals.findIndex((c) => c.externalId === calendar.externalId); selectableCalendars[i].selected = selected;
cals[i].selected = selected;
setSelectableCalendars(cals);
if (selected) { if (selected) {
fetch("api/availability/calendar", { fetch("api/availability/calendar", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(cals[i]), body: JSON.stringify(selectableCalendars[i]),
}).then((response) => response.json()); }).then((response) => response.json());
} else { } else {
fetch("api/availability/calendar", { fetch("api/availability/calendar", {
@ -65,7 +47,7 @@ export default function Home({ integrations }) {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(cals[i]), body: JSON.stringify(selectableCalendars[i]),
}).then((response) => response.json()); }).then((response) => response.json());
} }
}; };
@ -82,40 +64,117 @@ export default function Home({ integrations }) {
} }
} }
function onCloseSelectCalendar() {
setSelectableCalendars([...selectableCalendars]);
}
useEffect(loadCalendars, [integrations]); useEffect(loadCalendars, [integrations]);
if (loading) { if (loading) {
return ( return <Loader />;
<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 ( return (
<div> <div>
<Head> <Head>
<title>Integrations | Calendso</title> <title>App Store | Calendso</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Shell <Shell heading="App Store" subtitle="Connect your favourite apps." CTA={<ConnectNewAppDialog />}>
heading="Integrations" <div className="bg-white border border-gray-200 overflow-hidden rounded-sm mb-8">
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">
{integrations.filter((ig) => ig.credential).length !== 0 ? ( {integrations.filter((ig) => ig.credential).length !== 0 ? (
<ul className="divide-y divide-gray-200"> <ul className="divide-y divide-gray-200">
{integrations {integrations
.filter((ig) => ig.credential) .filter((ig) => ig.credential)
.map((ig) => ( .map((ig) => (
<li key={ig.id}> <li key={ig.credential.id}>
<Link href={"/integrations/" + ig.credential.id}> <Link href={"/integrations/" + ig.credential.id}>
<a className="block hover:bg-gray-50"> <a className="block hover:bg-gray-50">
<div className="flex items-center px-4 py-4 sm:px-6"> <div className="flex items-center px-4 py-4 sm:px-6">
@ -168,224 +227,41 @@ export default function Home({ integrations }) {
</div> </div>
<div className="py-5 sm:p-6"> <div className="py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900"> <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> </h3>
<div className="mt-2 text-sm text-gray-500"> <div className="mt-2 text-sm text-gray-500">
<p> <p>
You currently do not have any integrations set up. Add your first integration to get You currently do not have any apps connected. Connect your first app to get started.
started.
</p> </p>
</div> </div>
<div className="mt-3 text-sm"> <ConnectNewAppDialog />
<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>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
{showAddModal && ( <div className="bg-white border border-gray-200 rounded-sm mb-8">
<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="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Select calendars</h3> <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"> <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> <p>Select which calendars are checked for availability to prevent double bookings.</p>
</div> </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"> <div className="mt-5">
<button type="button" onClick={toggleShowCalendarModal} className="btn btn-primary"> <a href="mailto:apps@calendso.com" className="btn btn-white">
Select calendars Contact us
</button> </a>
</div> </div>
</div> </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> </Shell>
</div> </div>
); );

View file

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

View file

@ -4,7 +4,7 @@ import prisma from "../../lib/prisma";
import Modal from "../../components/Modal"; import Modal from "../../components/Modal";
import Shell from "../../components/Shell"; import Shell from "../../components/Shell";
import SettingsShell from "../../components/Settings"; import SettingsShell from "../../components/Settings";
import { useSession, getSession } from "next-auth/client"; import { getSession, useSession } from "next-auth/client";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
export default function Settings() { export default function Settings() {

View file

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

View file

@ -4,7 +4,7 @@ import prisma, { whereAndSelect } from "../lib/prisma";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CheckIcon } from "@heroicons/react/outline"; import { CheckIcon } from "@heroicons/react/outline";
import { CalendarIcon, ClockIcon, LocationMarkerIcon } from "@heroicons/react/solid"; import { ClockIcon } from "@heroicons/react/solid";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import toArray from "dayjs/plugin/toArray"; import toArray from "dayjs/plugin/toArray";
@ -60,7 +60,7 @@ export default function Success(props) {
return ( return (
isReady && ( isReady && (
<div> <div className="bg-neutral-50 dark:bg-neutral-900 h-screen">
<Head> <Head>
<title> <title>
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"} | {eventName} | Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"} | {eventName} |
@ -68,7 +68,7 @@ export default function Success(props) {
</title> </title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="max-w-3xl mx-auto my-24"> <main className="max-w-3xl mx-auto py-24">
<div className="fixed z-50 inset-0 overflow-y-auto"> <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"> <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; &#8203;
</span> </span>
<div <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" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-headline"> aria-labelledby="modal-headline">
<div> <div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100"> <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
{!props.eventType.requiresConfirmation && ( {!props.eventType.requiresConfirmation && (
<CheckIcon className="h-6 w-6 text-green-600" /> <CheckIcon className="h-8 w-8 text-green-600" />
)} )}
{props.eventType.requiresConfirmation && ( {props.eventType.requiresConfirmation && (
<ClockIcon className="h-6 w-6 text-green-600" /> <ClockIcon className="h-8 w-8 text-green-600" />
)} )}
</div> </div>
<div className="mt-3 text-center sm:mt-5"> <div className="mt-3 text-center sm:mt-5">
<h3 <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"> id="modal-headline">
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"} {props.eventType.requiresConfirmation ? "Submitted" : "This meeting is scheduled"}
</h3> </h3>
<div className="mt-2"> <div className="mt-3">
<p className="text-sm text-gray-500 dark:text-gray-300"> <p className="text-sm text-neutral-600 dark:text-gray-300">
{props.eventType.requiresConfirmation {props.eventType.requiresConfirmation
? `${ ? `${
props.user.name || props.user.username props.user.name || props.user.username
} still needs to confirm or reject the booking.` } 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> </p>
</div> </div>
<div className="mt-4 border-t border-b dark:border-gray-900 py-4"> <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">
<h2 className="text-lg font-medium text-gray-600 dark:text-gray-100 mb-2"> <div className="font-medium">What</div>
{eventName} <div className="mb-6 col-span-2">{eventName}</div>
</h2> <div className="font-medium">When</div>
<p className="text-gray-500 dark:text-gray-50 mb-1"> <div className="mb-6 col-span-2">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> {date.format("dddd, DD MMMM YYYY")}
{props.eventType.length} minutes <br />
</p> {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 && ( {location && (
<p className="text-gray-500 mb-1"> <>
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> <div className="font-medium">Where</div>
{location} <div className="col-span-2">{location}</div>
</p> </>
)} )}
<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> </div>
</div> </div>
{!props.eventType.requiresConfirmation && ( {!props.eventType.requiresConfirmation && (
<div className="mt-5 sm:mt-0 pt-2 text-center"> <div className="mt-5 sm:mt-0 sm:pt-4 pt-2 text-center flex">
<span className="font-medium text-gray-500 dark:text-gray-50"> <span className="font-medium text-gray-700 dark:text-gray-50 flex self-center mr-6">
Add to your calendar Add to calendar
</span> </span>
<div className="flex mt-2"> <div className="flex">
<Link <Link
href={ href={
`https://calendar.google.com/calendar/r/eventedit?dates=${date `https://calendar.google.com/calendar/r/eventedit?dates=${date
@ -142,9 +142,9 @@ export default function Success(props) {
props.eventType.description props.eventType.description
}` + (location ? "&location=" + encodeURIComponent(location) : "") }` + (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 <svg
className="inline-block w-4 h-4 mr-1 -mt-1" className="inline-block w-4 h-4 -mt-1"
fill="currentColor" fill="currentColor"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
@ -166,7 +166,9 @@ export default function Success(props) {
eventName eventName
) + (location ? "&location=" + location : "") ) + (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 <svg
className="inline-block w-4 h-4 mr-1 -mt-1" className="inline-block w-4 h-4 mr-1 -mt-1"
fill="currentColor" fill="currentColor"
@ -190,7 +192,9 @@ export default function Success(props) {
eventName eventName
) + (location ? "&location=" + location : "") ) + (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 <svg
className="inline-block w-4 h-4 mr-1 -mt-1" className="inline-block w-4 h-4 mr-1 -mt-1"
fill="currentColor" fill="currentColor"
@ -202,7 +206,9 @@ export default function Success(props) {
</a> </a>
</Link> </Link>
<Link href={"data:text/calendar," + eventLink()}> <Link href={"data:text/calendar," + eventLink()}>
<a className="mx-2 btn-wide btn-white" download={props.eventType.title + ".ics"}> <a
className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3"
download={props.eventType.title + ".ics"}>
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" 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 endTime DateTime
attendees Attendee[] attendees Attendee[]
location String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime? updatedAt DateTime?

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;} .st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;}
</style> </style>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;} .st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;}
</style> </style>

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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#104D86;} .st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;}
</style> </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 <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 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,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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"> viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill:#F68D2E;} .st0{fill:#F68D2E;}
</style> </style>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -10,9 +10,9 @@
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/"> <!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/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;" <svg version="1.1" id="Livello_1"
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" 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"> enable-background="new 0 0 2228.833 2073.333" xml:space="preserve">
<metadata> <metadata>
<sfw xmlns="&ns_sfw;"> <sfw xmlns="&ns_sfw;">
<slices></slices> <slices></slices>

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 */ /* !important to override react-select */
.react-select__value-container{ .react-select__value-container{
border: 0 !important; border: 0 !important;
@ -140,7 +236,8 @@
height: 30px; height: 30px;
margin: 60px auto; margin: 60px auto;
position: relative; position: relative;
border: 4px solid #000; border-width: 4px;
border-style: solid;
animation: loader 2s infinite ease; animation: loader 2s infinite ease;
} }
@ -148,7 +245,6 @@
vertical-align: top; vertical-align: top;
display: inline-block; display: inline-block;
width: 100%; width: 100%;
background-color: #000;
animation: loader-inner 2s infinite ease-in; animation: loader-inner 2s infinite ease-in;
} }

View file

@ -5,6 +5,19 @@ module.exports = {
theme: { theme: {
extend: { extend: {
colors: { 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: { neutral: {
50: "#F7F8F9", 50: "#F7F8F9",
100: "#F4F5F6", 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" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.26.0-23.9b816b3aa13cc270074f172f30d6eda8a8ce867d.tgz#cfdacfad3acc0f3bf1d7710aa8f3852fd85ac6d9"
integrity sha512-a0jIhLvw9rFh6nZTr5Y3uzP28I2xNDu3pqxANvwMNnmIoYr1wYEcO1pMXn/36BGXldDdAWMmAbhfloHA3IB8DA== 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": "@radix-ui/primitive@0.0.5":
version "0.0.5" version "0.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-0.0.5.tgz#8464fb4db04401bde72d36e27e05714080668d40" resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-0.0.5.tgz#8464fb4db04401bde72d36e27e05714080668d40"
@ -760,6 +768,15 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@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": "@radix-ui/react-collapsible@^0.0.16":
version "0.0.16" version "0.0.16"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-0.0.16.tgz#6a99068f70bb85a60f8cbd43f093bd3053ab61cc" resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-0.0.16.tgz#6a99068f70bb85a60f8cbd43f093bd3053ab61cc"
@ -850,11 +867,43 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@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": "@radix-ui/react-polymorphic@0.0.12":
version "0.0.12" version "0.0.12"
resolved "https://registry.yarnpkg.com/@radix-ui/react-polymorphic/-/react-polymorphic-0.0.12.tgz#bf4ae516669b68e059549538104d97322f7c876b" resolved "https://registry.yarnpkg.com/@radix-ui/react-polymorphic/-/react-polymorphic-0.0.12.tgz#bf4ae516669b68e059549538104d97322f7c876b"
integrity sha512-/GYNMicBnGzjD1d2fCAuzql1VeFrp8mqM3xfzT1kxhnV85TKdURO45jBfMgqo17XNXoNhWIAProUsCO4qFAAIg== 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": "@radix-ui/react-portal@0.0.14":
version "0.0.14" version "0.0.14"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-0.0.14.tgz#31513d8777cf5e50a3a30ebc9deb34821e890e9e" 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-primitive" "0.0.14"
"@radix-ui/react-use-layout-effect" "0.0.5" "@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": "@radix-ui/react-presence@0.0.14":
version "0.0.14" version "0.0.14"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-0.0.14.tgz#6a86058bbbf46234dd8840dacd620b3ac5797025" 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" "@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.0.5" "@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": "@radix-ui/react-primitive@0.0.14":
version "0.0.14" version "0.0.14"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-0.0.14.tgz#752a967cb05d4c5643634fe20274e7dc905d1cce" 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" "@babel/runtime" "^7.13.10"
"@radix-ui/react-polymorphic" "0.0.12" "@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" version "0.0.12"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-0.0.12.tgz#c4d8a75fffca561aeeca2ed9603384d86757f60a" 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== integrity sha512-Em8P/xYyh3O/32IhrmARJNH+J/XCAVnw6h2zGu6oeReliIX7ktU67pMSeyyIZiU2hNXzaXYB/xDdixizQe/DGA==
@ -889,6 +965,45 @@
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.0.5" "@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": "@radix-ui/react-use-body-pointer-events@0.0.6":
version "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" 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: dependencies:
"@babel/runtime" "^7.13.10" "@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": "@sinonjs/commons@^1.7.0":
version "1.8.3" version "1.8.3"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
@ -2187,7 +2340,7 @@ cssstyle@^2.3.0:
dependencies: dependencies:
cssom "~0.3.6" cssom "~0.3.6"
csstype@^3.0.2: csstype@^3.0.2, csstype@^3.0.4:
version "3.0.8" version "3.0.8"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340"
integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==
@ -5409,6 +5562,11 @@ react-moment-proptypes@^1.6.0:
dependencies: dependencies:
moment ">=1.6.0" 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: react-outside-click-handler@^1.2.4:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/react-outside-click-handler/-/react-outside-click-handler-1.3.0.tgz#3831d541ac059deecd38ec5423f81e80ad60e115" resolved "https://registry.yarnpkg.com/react-outside-click-handler/-/react-outside-click-handler-1.3.0.tgz#3831d541ac059deecd38ec5423f81e80ad60e115"