Merge branch 'main' into bugfix/eventType-merge-artifacts
|
@ -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.
|
||||||
|
|
||||||
|
|
107
calendso.yaml
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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're using the free self-hosted version. Support the
|
You'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>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
​
|
​
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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">
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
42
components/dialog/ConfirmationDialogContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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">​</span>
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
<div className="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>);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
​
|
​
|
||||||
|
@ -127,4 +129,4 @@ export default function MemberInvitationModal(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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{" "}
|
||||||
|
|
|
@ -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
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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">
|
||||||
​
|
​
|
||||||
|
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 };
|
||||||
|
|
|
@ -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,
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
@ -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"> →</span>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
136
pages/[user].tsx
|
@ -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'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'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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">​</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>
|
​
|
||||||
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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">​</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>
|
​
|
||||||
<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'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'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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 } };
|
||||||
}
|
}
|
||||||
|
|
|
@ -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't look right?
|
Something doesn'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>
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">​</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">
|
||||||
|
​
|
||||||
|
</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,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
​
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
</span>
|
||||||
aria-hidden="true">​</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,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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've shared this link
|
||||||
|
with will no longer be able to book using it.
|
||||||
|
</ConfirmationDialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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't have any integrations added.
|
You don'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">→</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">
|
|
||||||
​
|
|
||||||
</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">
|
|
||||||
​
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) {
|
||||||
​
|
​
|
||||||
</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"
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "location" TEXT;
|
|
@ -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?
|
||||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
@ -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"
|
||||||
|
|