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".
|
||||
3. On "OAuth", select "Create".
|
||||
4. Name your App.
|
||||
5. Choose "Account-level app" as the app type.
|
||||
5. Choose "User-managed app" as the app type.
|
||||
6. De-select the option to publish the app on the Zoom App Marketplace.
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<CALENDSO URL>/api/integrations/zoomvideo/callback` replacing CALENDSO URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as a whitelist URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". Search for and check the following scopes:
|
||||
1. account:write:admin
|
||||
2. meeting:write:admin
|
||||
3. user:write:admin
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
12. Click "Done".
|
||||
13. You're good to go. Now you can easily add your Zoom integration in the Calendso settings.
|
||||
|
||||
|
|
107
calendso.yaml
|
@ -8,7 +8,7 @@ info:
|
|||
license:
|
||||
name: MIT License
|
||||
url: https://opensource.org/licenses/MIT
|
||||
version: 0.1.0
|
||||
version: 1.0.0
|
||||
server:
|
||||
url: http://localhost:{port}
|
||||
description: Local Development Server
|
||||
|
@ -28,6 +28,8 @@ tags:
|
|||
description: Manage integrations
|
||||
- name: User
|
||||
description: Manage the user's profile and settings
|
||||
- name: Teams
|
||||
description: Manage teams
|
||||
paths:
|
||||
/api/auth/signin:
|
||||
get:
|
||||
|
@ -58,6 +60,12 @@ paths:
|
|||
summary: Handles signing out
|
||||
tags:
|
||||
- Authentication
|
||||
/api/auth/signup:
|
||||
post:
|
||||
description: Creates a new user from an invitation.
|
||||
summary: Create a new user
|
||||
tags:
|
||||
- Authentication
|
||||
/api/auth/session:
|
||||
get:
|
||||
description: Returns client-safe session object - or an empty object if there is no session. The contents of the session object that is returned are configurable with the session callback.
|
||||
|
@ -82,12 +90,40 @@ paths:
|
|||
summary: Changes the password for the currently logged in account
|
||||
tags:
|
||||
- Authentication
|
||||
/api/auth/forgot-password:
|
||||
post:
|
||||
description: Send a password reset email.
|
||||
summary: Send a password reset email
|
||||
tags:
|
||||
- Authentication
|
||||
/api/auth/reset-password:
|
||||
post:
|
||||
description: Reset a user's password with their password reset token.
|
||||
summary: Reset a user's password
|
||||
tags:
|
||||
- Authentication
|
||||
/api/availability/:user:
|
||||
get:
|
||||
description: Gets the busy times for a particular user, by username.
|
||||
summary: Gets the busy times for a user
|
||||
tags:
|
||||
- Availability
|
||||
/api/availability/calendar:
|
||||
get:
|
||||
description: Gets the user's selected calendars.
|
||||
summary: Gets the user's selected calendars
|
||||
tags:
|
||||
- Availability
|
||||
post:
|
||||
description: Adds a selected calendar for the user.
|
||||
summary: Adds a selected calendar for the user
|
||||
tags:
|
||||
- Availability
|
||||
delete:
|
||||
description: Removes a selected calendar for the user.
|
||||
summary: Removes a selected calendar for the user
|
||||
tags:
|
||||
- Availability
|
||||
/api/availability/day:
|
||||
patch:
|
||||
description: Updates the start and end times for a user's availability.
|
||||
|
@ -127,6 +163,12 @@ paths:
|
|||
summary: Creates a booking for a user
|
||||
tags:
|
||||
- Booking
|
||||
/api/book/confirm:
|
||||
post:
|
||||
description: Accepts an opt-in booking.
|
||||
summary: Accepts an opt-in booking
|
||||
tags:
|
||||
- Booking
|
||||
/api/integrations:
|
||||
get:
|
||||
description: Gets a list of the user's integrations.
|
||||
|
@ -150,9 +192,72 @@ paths:
|
|||
summary: Gets and stores the OAuth token
|
||||
tags:
|
||||
- Integrations
|
||||
/api/integrations/office365calendar/add:
|
||||
get:
|
||||
description: Gets the OAuth URL for a Microsoft 365/Outlook integration.
|
||||
summary: Gets the OAuth URL
|
||||
tags:
|
||||
- Integrations
|
||||
/api/integrations/office365calendar/callback:
|
||||
post:
|
||||
description: Gets and stores the OAuth token for a Microsoft 365/Outlook integration.
|
||||
summary: Gets and stores the OAuth token
|
||||
tags:
|
||||
- Integrations
|
||||
/api/integrations/zoomvideo/add:
|
||||
get:
|
||||
description: Gets the OAuth URL for a Zoom integration.
|
||||
summary: Gets the OAuth URL
|
||||
tags:
|
||||
- Integrations
|
||||
/api/integrations/zoomvideo/callback:
|
||||
post:
|
||||
description: Gets and stores the OAuth token for a Zoom integration.
|
||||
summary: Gets and stores the OAuth token
|
||||
tags:
|
||||
- Integrations
|
||||
/api/user/profile:
|
||||
patch:
|
||||
description: Updates a user's profile.
|
||||
summary: Updates a user's profile
|
||||
tags:
|
||||
- User
|
||||
/api/user/membership:
|
||||
get:
|
||||
description: Get a list of the teams the user has joined.
|
||||
summary: Get the teams a user is joined to
|
||||
tags:
|
||||
- User
|
||||
patch:
|
||||
description: Accept team invitation
|
||||
summary: Accept team invitation.
|
||||
tags:
|
||||
- User
|
||||
delete:
|
||||
description: Leave team or decline membership invite of current user
|
||||
summary: Leave team or decline team invite.
|
||||
tags:
|
||||
- User
|
||||
/api/:team:
|
||||
delete:
|
||||
description: Deletes a team
|
||||
summary: Deletes a team
|
||||
tags:
|
||||
- Teams
|
||||
/api/:team/invite:
|
||||
post:
|
||||
description: Invites someone to a team.
|
||||
summary: Invites someone to a team
|
||||
tags:
|
||||
- Teams
|
||||
/api/:team/membership:
|
||||
get:
|
||||
description: Lists the members of a team.
|
||||
summary: Lists members of a team
|
||||
tags:
|
||||
- Teams
|
||||
delete:
|
||||
description: Cancels a membership (invite) to a team
|
||||
summary: Cancels a membership
|
||||
tags:
|
||||
- Teams
|
||||
|
|
|
@ -13,12 +13,25 @@ export function Dialog({ children, ...props }) {
|
|||
export const DialogContent = React.forwardRef(({ children, ...props }, forwardedRef) => (
|
||||
<DialogPrimitive.Content
|
||||
{...props}
|
||||
className="fixed bg-white rounded top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-left overflow-hidden shadow-xl sm:align-middle sm:max-w-lg sm:w-full p-6"
|
||||
className="fixed bg-white min-w-[360px] rounded top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-left overflow-hidden shadow-xl sm:align-middle sm:max-w-lg sm:w-full p-6"
|
||||
ref={forwardedRef}>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
));
|
||||
|
||||
export function DialogHeader({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { GiftIcon } from "@heroicons/react/outline";
|
||||
export default function DonateBanner() {
|
||||
|
||||
if (location.hostname.endsWith(".calendso.com")) {
|
||||
return null;
|
||||
}
|
||||
if (location.hostname.endsWith(".calendso.com")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -17,21 +16,19 @@ return null;
|
|||
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="ml-3 font-medium text-white truncate">
|
||||
<span className="md:hidden">
|
||||
Support the ongoing development
|
||||
</span>
|
||||
<span className="md:hidden">Support the ongoing development</span>
|
||||
<span className="hidden md:inline">
|
||||
You're using the free self-hosted version. Support the
|
||||
ongoing development by making a donation.
|
||||
You're using the free self-hosted version. Support the ongoing development by making
|
||||
a donation.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://calendso.com/donate"
|
||||
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50"
|
||||
>
|
||||
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50">
|
||||
Donate
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
export default function Loader(){
|
||||
return <div className="loader"><span className="loader-inner"></span></div>
|
||||
export default function Loader() {
|
||||
return (
|
||||
<div className="loader border-black dark:border-white">
|
||||
<span className="loader-inner bg-black dark:bg-white"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,14 @@
|
|||
export default function Logo({small} : {small?: boolean}){
|
||||
return <h1 className="brand-logo inline">
|
||||
export default function Logo({ small }: { small?: boolean }) {
|
||||
return (
|
||||
<h1 className="brand-logo inline">
|
||||
<strong>
|
||||
<img className={small ? "h-4 w-auto" : "h-5 w-auto"} alt="Calendso" title="Calendso" src="/calendso-logo-white-word.svg" />
|
||||
<img
|
||||
className={small ? "h-4 w-auto" : "h-5 w-auto"}
|
||||
alt="Calendso"
|
||||
title="Calendso"
|
||||
src="/calendso-logo-white-word.svg"
|
||||
/>
|
||||
</strong>
|
||||
</h1>;
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import { Fragment } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { CheckIcon } from '@heroicons/react/outline'
|
||||
import { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
|
||||
export default function Modal(props) {
|
||||
return (
|
||||
<Transition.Root show={props.open} as={Fragment}>
|
||||
<Dialog as="div" static className="fixed z-50 inset-0 overflow-y-auto" open={props.open} onClose={props.handleClose}>
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
open={props.open}
|
||||
onClose={props.handleClose}>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
|
@ -14,8 +19,7 @@ export default function Modal(props) {
|
|||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
leaveTo="opacity-0">
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
|
@ -30,8 +34,7 @@ export default function Modal(props) {
|
|||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||
|
@ -42,18 +45,12 @@ export default function Modal(props) {
|
|||
{props.heading}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
{props.description}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-wide btn-primary"
|
||||
onClick={() => props.handleClose()}
|
||||
>
|
||||
<button type="button" className="btn-wide btn-primary" onClick={() => props.handleClose()}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
@ -62,5 +59,5 @@ export default function Modal(props) {
|
|||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import Link from "next/link";
|
||||
import { CreditCardIcon, UserIcon, CodeIcon, KeyIcon, UserGroupIcon } from "@heroicons/react/solid";
|
||||
import { CodeIcon, CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
|
@ -35,7 +35,7 @@ export default function SettingsShell(props) {
|
|||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
<div>
|
||||
<div className="sm:mx-auto">
|
||||
<nav className="-mb-px flex space-x-2 sm:space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
|
@ -60,8 +60,9 @@ export default function SettingsShell(props) {
|
|||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<hr />
|
||||
</div>
|
||||
<main>{props.children}</main>
|
||||
<main className="max-w-4xl">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,19 +7,20 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib
|
|||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
PuzzleIcon,
|
||||
CogIcon,
|
||||
ChatAltIcon,
|
||||
LogoutIcon,
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
PuzzleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import Logo from "./Logo";
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export default function Shell(props) {
|
||||
const router = useRouter();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
|
@ -43,7 +44,7 @@ export default function Shell(props) {
|
|||
current: router.pathname.startsWith("/availability"),
|
||||
},
|
||||
{
|
||||
name: "Integrations",
|
||||
name: "App Store",
|
||||
href: "/integrations",
|
||||
icon: PuzzleIcon,
|
||||
current: router.pathname.startsWith("/integrations"),
|
||||
|
@ -71,7 +72,7 @@ export default function Shell(props) {
|
|||
<div className="h-screen flex overflow-hidden bg-gray-100">
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col w-56">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
|
@ -105,7 +106,7 @@ export default function Shell(props) {
|
|||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex border-t border-gray-200 p-4">
|
||||
<div className="flex-shrink-0 flex p-4">
|
||||
<UserDropdown session={session} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -135,10 +136,10 @@ export default function Shell(props) {
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="py-6">
|
||||
<div className="py-8">
|
||||
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">{props.heading}</h1>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-bold text-gray-900">{props.heading}</h1>
|
||||
<p className="text-sm text-neutral-500 mr-4">{props.subtitle}</p>
|
||||
</div>
|
||||
<div className="mb-4 flex-shrink-0">{props.CTA}</div>
|
||||
|
@ -146,7 +147,7 @@ export default function Shell(props) {
|
|||
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
|
||||
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="md:hidden flex fixed bottom-0 bg-white w-full rounded-lg shadow">
|
||||
<nav className="bottom-nav md:hidden flex fixed bottom-0 bg-white w-full shadow">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
{navigation.flatMap((item, itemIdx) =>
|
||||
item.name === "Settings" ? (
|
||||
|
|
36
components/Tooltip.tsx
Normal file
|
@ -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 Slots from "./Slots";
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
const AvailableTimes = ({
|
||||
date,
|
||||
|
@ -26,8 +28,11 @@ const AvailableTimes = ({
|
|||
|
||||
return (
|
||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
||||
<div className="text-gray-600 font-light text-xl mb-4 text-left">
|
||||
<span className="w-1/2 dark:text-white text-gray-600">{date.format("dddd DD MMMM YYYY")}</span>
|
||||
<div className="text-gray-600 font-light text-lg mb-4 text-left">
|
||||
<span className="w-1/2 dark:text-white text-gray-600">
|
||||
<strong>{date.format("dddd")}</strong>
|
||||
<span className="text-gray-500">{date.format(", DD MMMM")}</span>
|
||||
</span>
|
||||
</div>
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => (
|
||||
|
@ -37,7 +42,7 @@ const AvailableTimes = ({
|
|||
`/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
|
||||
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
|
||||
}>
|
||||
<a className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">
|
||||
<a className="block font-medium mb-4 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-transparent rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black">
|
||||
{slot.format(timeFormat)}
|
||||
</a>
|
||||
</Link>
|
||||
|
@ -49,7 +54,7 @@ const AvailableTimes = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!isFullyBooked && slots.length === 0 && !hasErrors && <div className="loader" />}
|
||||
{!isFullyBooked && slots.length === 0 && !hasErrors && <Loader />}
|
||||
|
||||
{hasErrors && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
|
|
|
@ -147,12 +147,14 @@ const DatePicker = ({
|
|||
onClick={() => setSelectedDate(inviteeDate.date(day))}
|
||||
disabled={isDisabled(day)}
|
||||
className={
|
||||
"text-center w-10 h-10 rounded-full mx-auto" +
|
||||
(isDisabled(day) ? " text-gray-400 font-light" : " text-blue-600 font-medium") +
|
||||
"text-center w-14 h-14 mx-auto hover:border hover:border-black dark:hover:border-white" +
|
||||
(isDisabled(day)
|
||||
? " text-gray-400 font-light hover:border-0 cursor-default"
|
||||
: " dark:text-white text-primary-500 font-medium") +
|
||||
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
|
||||
? " bg-blue-600 text-white-important"
|
||||
? " bg-black text-white-important"
|
||||
: !isDisabled(day)
|
||||
? " bg-blue-50 dark:bg-gray-900 dark:bg-opacity-30"
|
||||
? " bg-gray-100 dark:bg-gray-600"
|
||||
: "")
|
||||
}>
|
||||
{day}
|
||||
|
@ -164,38 +166,43 @@ const DatePicker = ({
|
|||
return selectedMonth ? (
|
||||
<div
|
||||
className={
|
||||
"mt-8 sm:mt-0 " +
|
||||
(selectedDate ? "sm:w-1/3 sm:border-r sm:dark:border-gray-900 sm:px-4" : "sm:w-1/2 sm:pl-4")
|
||||
"mt-8 sm:mt-0 sm:min-w-[455px] " +
|
||||
(selectedDate
|
||||
? "w-full sm:w-1/2 md:w-1/3 sm:border-r sm:dark:border-gray-800 sm:pl-4 sm:pr-6 "
|
||||
: "sm:w-1/2 sm:pl-4")
|
||||
}>
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4">
|
||||
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||
{dayjs().month(selectedMonth).format("MMMM YYYY")}
|
||||
<strong className="text-gray-900 dark:text-white">
|
||||
{dayjs().month(selectedMonth).format("MMMM")}
|
||||
</strong>
|
||||
<span className="text-gray-500"> {dayjs().month(selectedMonth).format("YYYY")}</span>
|
||||
</span>
|
||||
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={
|
||||
"mr-4 " +
|
||||
"group mr-2 p-1" +
|
||||
(selectedMonth <= dayjs().tz(inviteeTimeZone).month() && "text-gray-400 dark:text-gray-600")
|
||||
}
|
||||
disabled={selectedMonth <= dayjs().tz(inviteeTimeZone).month()}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={incrementMonth}>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
<button className="group p-1" onClick={incrementMonth}>
|
||||
<ChevronRightIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||
<div className="grid grid-cols-7 gap-4 text-center border-b border-t sm:border-0">
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||
.map((weekDay) => (
|
||||
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
<div key={weekDay} className="uppercase text-gray-500 text-xs tracking-widest my-4">
|
||||
{weekDay}
|
||||
</div>
|
||||
))}
|
||||
{calendar}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-y-2 gap-x-4 text-center">{calendar}</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
|
|
@ -25,7 +25,7 @@ const TimeOptions = (props) => {
|
|||
|
||||
return (
|
||||
selectedTimeZone !== "" && (
|
||||
<div className="w-full rounded shadow border dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
|
@ -37,7 +37,7 @@ const TimeOptions = (props) => {
|
|||
checked={is24hClock}
|
||||
onChange={setIs24hClock}
|
||||
className={classNames(
|
||||
is24hClock ? "bg-blue-600" : "dark:bg-gray-600 bg-gray-200",
|
||||
is24hClock ? "bg-black" : "dark:bg-gray-600 bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
|
||||
)}>
|
||||
<span className="sr-only">Use setting</span>
|
||||
|
|
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,42 +1,52 @@
|
|||
import {useEffect, useState} from "react";
|
||||
import {UsersIcon,UserRemoveIcon} from "@heroicons/react/outline";
|
||||
import {useSession} from "next-auth/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UserRemoveIcon, UsersIcon } from "@heroicons/react/outline";
|
||||
import { useSession } from "next-auth/client";
|
||||
|
||||
export default function EditTeamModal(props) {
|
||||
const [session] = useSession();
|
||||
const [members, setMembers] = useState([]);
|
||||
const [checkedDisbandTeam, setCheckedDisbandTeam] = useState(false);
|
||||
|
||||
const [ session, loading ] = useSession();
|
||||
const [ members, setMembers ] = useState([]);
|
||||
const [ checkedDisbandTeam, setCheckedDisbandTeam ] = useState(false);
|
||||
const loadMembers = () =>
|
||||
fetch("/api/teams/" + props.team.id + "/membership")
|
||||
.then((res: any) => res.json())
|
||||
.then((data) => setMembers(data.members));
|
||||
|
||||
const loadMembers = () => fetch('/api/teams/' + props.team.id + '/membership')
|
||||
.then( (res: any) => res.json() ).then( (data) => setMembers(data.members) );
|
||||
|
||||
useEffect( () => {
|
||||
useEffect(() => {
|
||||
loadMembers();
|
||||
}, []);
|
||||
|
||||
const deleteTeam = (e) => {
|
||||
e.preventDefault();
|
||||
return fetch('/api/teams/' + props.team.id, {
|
||||
method: 'DELETE',
|
||||
return fetch("/api/teams/" + props.team.id, {
|
||||
method: "DELETE",
|
||||
}).then(props.onExit);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = (member) => {
|
||||
return fetch('/api/teams/' + props.team.id + '/membership', {
|
||||
method: 'DELETE',
|
||||
return fetch("/api/teams/" + props.team.id + "/membership", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ userId: member.id }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(loadMembers);
|
||||
}
|
||||
};
|
||||
|
||||
return (<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</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="sm:flex sm:items-start mb-4">
|
||||
|
@ -44,45 +54,56 @@ export default function EditTeamModal(props) {
|
|||
<UsersIcon className="h-6 w-6 text-black" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit the {props.team.name} team</h3>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
Edit the {props.team.name} team
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
Manage and delete your team.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Manage and delete your team.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
{members.length > 0 && <div>
|
||||
{members.length > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<h2 className="text-lg font-medium text-gray-900">Members</h2>
|
||||
</div>
|
||||
<table className="table-auto mb-2 w-full text-sm">
|
||||
<tbody>
|
||||
{members.map( (member) => <tr key={member.email}>
|
||||
<td className="p-1">{member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}</td>
|
||||
{members.map((member) => (
|
||||
<tr key={member.email}>
|
||||
<td className="p-1">
|
||||
{member.name} {member.name && "(" + member.email + ")"}
|
||||
{!member.name && member.email}
|
||||
</td>
|
||||
<td className="capitalize">{member.role.toLowerCase()}</td>
|
||||
<td className="text-right py-2 px-1">
|
||||
{member.email !== session.user.email &&
|
||||
{member.email !== session.user.email && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => removeMember(member)}
|
||||
onClick={() => removeMember(member)}
|
||||
className="btn-sm text-xs bg-transparent px-3 py-1 rounded ml-2">
|
||||
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>
|
||||
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline" />
|
||||
</button>
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
</tr>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 border border-red-400 rounded p-2 px-4">
|
||||
<p className="block text-sm font-medium text-gray-700">Tick the box to disband this team.</p>
|
||||
<label className="mt-1">
|
||||
<input type="checkbox" onChange={(e) => setCheckedDisbandTeam(e.target.checked)} className="shadow-sm mr-2 focus:ring-black focus:border-black sm:text-sm border-gray-300 rounded-md" />
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => setCheckedDisbandTeam(e.target.checked)}
|
||||
className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
Disband this team
|
||||
</label>
|
||||
</div>
|
||||
|
@ -91,9 +112,13 @@ export default function EditTeamModal(props) {
|
|||
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
|
||||
Update
|
||||
</button>*/}
|
||||
{checkedDisbandTeam && <button onClick={deleteTeam} className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
|
||||
{checkedDisbandTeam && (
|
||||
<button
|
||||
onClick={deleteTeam}
|
||||
className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
|
||||
Disband Team
|
||||
</button>}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||
Close
|
||||
</button>
|
||||
|
@ -101,5 +126,6 @@ export default function EditTeamModal(props) {
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -2,11 +2,9 @@ import { UsersIcon } from "@heroicons/react/outline";
|
|||
import { useState } from "react";
|
||||
|
||||
export default function MemberInvitationModal(props) {
|
||||
|
||||
const [ errorMessage, setErrorMessage ] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleError = async (res) => {
|
||||
|
||||
const responseData = await res.json();
|
||||
|
||||
if (res.ok === false) {
|
||||
|
@ -18,22 +16,24 @@ export default function MemberInvitationModal(props) {
|
|||
};
|
||||
|
||||
const inviteMember = (e) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
role: e.target.elements['role'].value,
|
||||
usernameOrEmail: e.target.elements['inviteUser'].value,
|
||||
sendEmailInvitation: e.target.elements['sendInviteEmail'].checked,
|
||||
}
|
||||
role: e.target.elements["role"].value,
|
||||
usernameOrEmail: e.target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
|
||||
};
|
||||
|
||||
return fetch('/api/teams/' + props.team.id + '/invite', {
|
||||
method: 'POST',
|
||||
return fetch("/api/teams/" + props.team.id + "/invite", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(handleError).then(props.onExit).catch( (e) => {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleError)
|
||||
.then(props.onExit)
|
||||
.catch(() => {
|
||||
// do nothing.
|
||||
});
|
||||
};
|
||||
|
@ -45,7 +45,9 @@ export default function MemberInvitationModal(props) {
|
|||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
|
|
|
@ -1,42 +1,49 @@
|
|||
import {useEffect, useState} from "react";
|
||||
import { useState } from "react";
|
||||
import TeamListItem from "./TeamListItem";
|
||||
import EditTeamModal from "./EditTeamModal";
|
||||
import MemberInvitationModal from "./MemberInvitationModal";
|
||||
|
||||
export default function TeamList(props) {
|
||||
|
||||
const [ showMemberInvitationModal, setShowMemberInvitationModal ] = useState(false);
|
||||
const [ showEditTeamModal, setShowEditTeamModal ] = useState(false);
|
||||
const [ team, setTeam ] = useState(null);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
|
||||
const [team, setTeam] = useState(null);
|
||||
|
||||
const selectAction = (action: string, team: any) => {
|
||||
setTeam(team);
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
case "edit":
|
||||
setShowEditTeamModal(true);
|
||||
break;
|
||||
case 'invite':
|
||||
case "invite":
|
||||
setShowMemberInvitationModal(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (<div>
|
||||
return (
|
||||
<div>
|
||||
<ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200">
|
||||
{props.teams.map(
|
||||
(team: any) => <TeamListItem onChange={props.onChange} key={team.id} team={team} onActionSelect={
|
||||
(action: string) => selectAction(action, team)
|
||||
}></TeamListItem>
|
||||
)}
|
||||
{props.teams.map((team: any) => (
|
||||
<TeamListItem
|
||||
onChange={props.onChange}
|
||||
key={team.id}
|
||||
team={team}
|
||||
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
|
||||
))}
|
||||
</ul>
|
||||
{showEditTeamModal && <EditTeamModal team={team} onExit={() => {
|
||||
{showEditTeamModal && (
|
||||
<EditTeamModal
|
||||
team={team}
|
||||
onExit={() => {
|
||||
props.onChange();
|
||||
setShowEditTeamModal(false);
|
||||
}}></EditTeamModal>}
|
||||
{showMemberInvitationModal &&
|
||||
}}></EditTeamModal>
|
||||
)}
|
||||
{showMemberInvitationModal && (
|
||||
<MemberInvitationModal
|
||||
team={team}
|
||||
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
|
||||
}
|
||||
</div>);
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,59 +1,87 @@
|
|||
import {CogIcon, TrashIcon, UserAddIcon, UsersIcon} from "@heroicons/react/outline";
|
||||
import { CogIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline";
|
||||
import Dropdown from "../ui/Dropdown";
|
||||
import {useState} from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function TeamListItem(props) {
|
||||
|
||||
const [ team, setTeam ] = useState(props.team);
|
||||
const [team, setTeam] = useState(props.team);
|
||||
|
||||
const acceptInvite = () => invitationResponse(true);
|
||||
const declineInvite = () => invitationResponse(false);
|
||||
|
||||
const invitationResponse = (accept: boolean) => fetch('/api/user/membership', {
|
||||
method: accept ? 'PATCH' : 'DELETE',
|
||||
const invitationResponse = (accept: boolean) =>
|
||||
fetch("/api/user/membership", {
|
||||
method: accept ? "PATCH" : "DELETE",
|
||||
body: JSON.stringify({ teamId: props.team.id }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then( () => {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(() => {
|
||||
// success
|
||||
setTeam(null);
|
||||
props.onChange();
|
||||
});
|
||||
|
||||
return (team && <li className="mb-2 mt-2 divide-y">
|
||||
return (
|
||||
team && (
|
||||
<li className="mb-2 mt-2 divide-y">
|
||||
<div className="flex justify-between mb-2 mt-2">
|
||||
<div>
|
||||
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline"/>
|
||||
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline" />
|
||||
<div className="inline-block -mt-1">
|
||||
<span className="font-bold text-neutral-700 text-sm">{props.team.name}</span>
|
||||
<span className="text-xs text-gray-400 -mt-1 block capitalize">{props.team.role.toLowerCase()}</span>
|
||||
<span className="text-xs text-gray-400 -mt-1 block capitalize">
|
||||
{props.team.role.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{props.team.role === 'INVITEE' && <div>
|
||||
<button className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2" onClick={acceptInvite}>Accept invitation</button>
|
||||
{props.team.role === "INVITEE" && (
|
||||
<div>
|
||||
<button
|
||||
className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2"
|
||||
onClick={acceptInvite}>
|
||||
Accept invitation
|
||||
</button>
|
||||
<button className="btn-sm bg-transparent px-2 py-1 ml-1">
|
||||
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" onClick={declineInvite} />
|
||||
</button>
|
||||
</div>}
|
||||
{props.team.role === 'MEMBER' && <div>
|
||||
<button onClick={declineInvite} className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm ml-2">Leave</button>
|
||||
</div>}
|
||||
{props.team.role === 'OWNER' && <div>
|
||||
</div>
|
||||
)}
|
||||
{props.team.role === "MEMBER" && (
|
||||
<div>
|
||||
<button
|
||||
onClick={declineInvite}
|
||||
className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm ml-2">
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{props.team.role === "OWNER" && (
|
||||
<div>
|
||||
<Dropdown className="relative inline-block text-left">
|
||||
<button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded-sm ml-2">
|
||||
<CogIcon className="h-6 w-6 inline text-gray-400" />
|
||||
</button>
|
||||
<ul role="menu" className="z-10 origin-top-right absolute right-0 w-36 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
|
||||
<button className="block px-4 py-2" onClick={() => props.onActionSelect('invite')}>Invite members</button>
|
||||
<ul
|
||||
role="menu"
|
||||
className="z-10 origin-top-right absolute right-0 w-36 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<li
|
||||
className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
role="menuitem">
|
||||
<button className="block px-4 py-2" onClick={() => props.onActionSelect("invite")}>
|
||||
Invite members
|
||||
</button>
|
||||
</li>
|
||||
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
|
||||
<button className="block px-4 py-2" onClick={() => props.onActionSelect('edit')}>Manage team</button>
|
||||
<li
|
||||
className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
role="menuitem">
|
||||
<button className="block px-4 py-2" onClick={() => props.onActionSelect("edit")}>
|
||||
Manage team
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Dropdown>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
|
||||
{props.team.members.length > 0 && <div>
|
||||
|
@ -73,5 +101,7 @@ export default function TeamListItem(props) {
|
|||
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm"><UserAddIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 h-6 w-6 inline"/> Invite member</button>
|
||||
<button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2">Disband</button>
|
||||
</div>}*/}
|
||||
</li>);
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,15 +1,26 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
export default function Button(props) {
|
||||
return(
|
||||
<button type="submit" className="btn btn-primary">
|
||||
return (
|
||||
<button type="submit" className="btn btn-primary dark:btn-white">
|
||||
{!props.loading && props.children}
|
||||
{props.loading &&
|
||||
<svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
{props.loading && (
|
||||
<svg
|
||||
className="animate-spin mx-4 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
}
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Link from "next/link";
|
||||
|
||||
const PoweredByCalendso = () => (
|
||||
<div className="text-xs text-center sm:text-right pt-1">
|
||||
<div className="text-xs text-center sm:text-right p-1">
|
||||
<Link href={`https://calendso.com?utm_source=embed&utm_medium=powered-by-button`}>
|
||||
<a target="_blank" className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
|
||||
powered by{" "}
|
||||
|
|
|
@ -91,7 +91,7 @@ export const Scheduler = ({
|
|||
type="button"
|
||||
onClick={() => removeScheduleAt(idx)}
|
||||
className="btn-sm bg-transparent px-2 py-1 ml-1">
|
||||
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" />
|
||||
<TrashIcon className="h-5 w-5 inline text-gray-400 -mt-1" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
|
41
components/ui/Switch.tsx
Normal file
|
@ -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"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
|
|
|
@ -6,10 +6,12 @@ import { stripHtml } from "./emails/helpers";
|
|||
const translator = short();
|
||||
|
||||
export default class CalEventParser {
|
||||
calEvent: CalendarEvent;
|
||||
protected calEvent: CalendarEvent;
|
||||
protected maybeUid: string;
|
||||
|
||||
constructor(calEvent: CalendarEvent) {
|
||||
constructor(calEvent: CalendarEvent, maybeUid: string = null) {
|
||||
this.calEvent = calEvent;
|
||||
this.maybeUid = maybeUid;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -30,14 +32,14 @@ export default class CalEventParser {
|
|||
* Returns a unique identifier for the given calendar event.
|
||||
*/
|
||||
public getUid(): string {
|
||||
return translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
|
||||
return this.maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a footer section with links to change the event (as HTML).
|
||||
*/
|
||||
public getChangeEventFooterHtml(): string {
|
||||
return `<p style="color: #4b5563; margin-top: 20px;">Need to make a change? <a href="${this.getCancelLink()}" style="color: #161e2e;">Cancel</a> or <a href="${this.getRescheduleLink()}" style="color: #161e2e;">reschedule</a>.</p>`;
|
||||
return `<p style="color: #4b5563; margin-top: 20px;">Need to make a change? <a href="${this.getCancelLink()}" style="color: #161e2e;">Cancel</a> or <a href="${this.getRescheduleLink()}" style="color: #161e2e;">reschedule</a></p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,6 +5,10 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"
|
|||
import prisma from "./prisma";
|
||||
import { Credential } from "@prisma/client";
|
||||
import CalEventParser from "./CalEventParser";
|
||||
import { EventResult } from "@lib/events/EventManager";
|
||||
import logger from "@lib/logger";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { google } = require("googleapis");
|
||||
|
@ -206,7 +210,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
|||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
|
||||
const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'";
|
||||
const filter = "?startdatetime=" + dateFrom + "&enddatetime=" + dateTo;
|
||||
return auth
|
||||
.getToken()
|
||||
.then((accessToken) => {
|
||||
|
@ -229,7 +233,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
|||
headers: {
|
||||
Prefer: 'outlook.timezone="Etc/GMT"',
|
||||
},
|
||||
url: `/me/calendars/${calendarId}/events${filter}`,
|
||||
url: `/me/calendars/${calendarId}/calendarView${filter}`,
|
||||
}));
|
||||
|
||||
return fetch("https://graph.microsoft.com/v1.0/$batch", {
|
||||
|
@ -309,7 +313,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
|
||||
new Promise((resolve, reject) =>
|
||||
auth.getToken().then((myGoogleAuth) => {
|
||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
const selectedCalendarIds = selectedCalendars
|
||||
.filter((e) => e.integration === integrationType)
|
||||
.map((e) => e.externalId);
|
||||
|
@ -375,7 +382,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
payload["conferenceData"] = event.conferenceData;
|
||||
}
|
||||
|
||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.events.insert(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
|
@ -418,7 +428,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
payload["location"] = event.location;
|
||||
}
|
||||
|
||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.events.update(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
|
@ -441,7 +454,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
deleteEvent: (uid: string) =>
|
||||
new Promise((resolve, reject) =>
|
||||
auth.getToken().then((myGoogleAuth) => {
|
||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.events.delete(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
|
@ -463,7 +479,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
listCalendars: () =>
|
||||
new Promise((resolve, reject) =>
|
||||
auth.getToken().then((myGoogleAuth) => {
|
||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.calendarList
|
||||
.list()
|
||||
.then((cals) => {
|
||||
|
@ -515,8 +534,13 @@ const listCalendars = (withCredentials) =>
|
|||
results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||
);
|
||||
|
||||
const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<unknown> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||
const createEvent = async (
|
||||
credential: Credential,
|
||||
calEvent: CalendarEvent,
|
||||
noMail = false,
|
||||
maybeUid: string = null
|
||||
): Promise<EventResult> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
|
||||
const uid: string = parser.getUid();
|
||||
/*
|
||||
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
|
||||
|
@ -525,12 +549,22 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro
|
|||
*/
|
||||
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
||||
|
||||
const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null;
|
||||
let success = true;
|
||||
|
||||
const creationResult = credential
|
||||
? await calendars([credential])[0]
|
||||
.createEvent(richEvent)
|
||||
.catch((e) => {
|
||||
log.error("createEvent failed", e, calEvent);
|
||||
success = false;
|
||||
})
|
||||
: null;
|
||||
|
||||
const maybeHangoutLink = creationResult?.hangoutLink;
|
||||
const maybeEntryPoints = creationResult?.entryPoints;
|
||||
const maybeConferenceData = creationResult?.conferenceData;
|
||||
|
||||
if (!noMail) {
|
||||
const organizerMail = new EventOrganizerMail(calEvent, uid, {
|
||||
hangoutLink: maybeHangoutLink,
|
||||
conferenceData: maybeConferenceData,
|
||||
|
@ -556,26 +590,39 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro
|
|||
console.error("attendeeMail.sendEmail failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid,
|
||||
createdEvent: creationResult,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
};
|
||||
|
||||
const updateEvent = async (
|
||||
credential: Credential,
|
||||
uidToUpdate: string,
|
||||
calEvent: CalendarEvent
|
||||
): Promise<unknown> => {
|
||||
calEvent: CalendarEvent,
|
||||
noMail = false
|
||||
): Promise<EventResult> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||
const newUid: string = parser.getUid();
|
||||
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
||||
|
||||
let success = true;
|
||||
|
||||
const updateResult = credential
|
||||
? await calendars([credential])[0].updateEvent(uidToUpdate, richEvent)
|
||||
? await calendars([credential])[0]
|
||||
.updateEvent(uidToUpdate, richEvent)
|
||||
.catch((e) => {
|
||||
log.error("updateEvent failed", e, calEvent);
|
||||
success = false;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!noMail) {
|
||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||
try {
|
||||
|
@ -591,10 +638,14 @@ const updateEvent = async (
|
|||
console.error("attendeeMail.sendEmail failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid: newUid,
|
||||
updatedEvent: updateResult,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -606,12 +657,4 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
|||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
export {
|
||||
getBusyCalendarTimes,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
CalendarEvent,
|
||||
listCalendars,
|
||||
IntegrationCalendar,
|
||||
};
|
||||
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };
|
||||
|
|
|
@ -4,7 +4,7 @@ import { CalendarEvent, ConferenceData } from "../calendarClient";
|
|||
import { serverConfig } from "../serverConfig";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
interface EntryPoint {
|
||||
export interface EntryPoint {
|
||||
entryPointType?: string;
|
||||
uri?: string;
|
||||
label?: string;
|
||||
|
@ -15,7 +15,7 @@ interface EntryPoint {
|
|||
password?: string;
|
||||
}
|
||||
|
||||
interface AdditionInformation {
|
||||
export interface AdditionInformation {
|
||||
conferenceData?: ConferenceData;
|
||||
entryPoints?: EntryPoint[];
|
||||
hangoutLink?: string;
|
||||
|
@ -34,11 +34,12 @@ export default abstract class EventMail {
|
|||
*
|
||||
* @param calEvent
|
||||
* @param uid
|
||||
* @param additionInformation
|
||||
*/
|
||||
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
|
||||
this.calEvent = calEvent;
|
||||
this.uid = uid;
|
||||
this.parser = new CalEventParser(calEvent);
|
||||
this.parser = new CalEventParser(calEvent, uid);
|
||||
this.additionInformation = additionInformation;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import {CalendarEvent} from "../calendarClient";
|
||||
import { CalendarEvent } from "../calendarClient";
|
||||
import EventAttendeeMail from "./EventAttendeeMail";
|
||||
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
|
||||
import {VideoCallData} from "../videoClient";
|
||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||
import { VideoCallData } from "../videoClient";
|
||||
import { AdditionInformation } from "@lib/emails/EventMail";
|
||||
|
||||
export default class VideoEventAttendeeMail extends EventAttendeeMail {
|
||||
videoCallData: VideoCallData;
|
||||
|
||||
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
|
||||
constructor(
|
||||
calEvent: CalendarEvent,
|
||||
uid: string,
|
||||
videoCallData: VideoCallData,
|
||||
additionInformation: AdditionInformation = null
|
||||
) {
|
||||
super(calEvent, uid);
|
||||
this.videoCallData = videoCallData;
|
||||
this.additionInformation = additionInformation;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,13 +2,20 @@ import { CalendarEvent } from "../calendarClient";
|
|||
import EventOrganizerMail from "./EventOrganizerMail";
|
||||
import { VideoCallData } from "../videoClient";
|
||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||
import { AdditionInformation } from "@lib/emails/EventMail";
|
||||
|
||||
export default class VideoEventOrganizerMail extends EventOrganizerMail {
|
||||
videoCallData: VideoCallData;
|
||||
|
||||
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
|
||||
constructor(
|
||||
calEvent: CalendarEvent,
|
||||
uid: string,
|
||||
videoCallData: VideoCallData,
|
||||
additionInformation: AdditionInformation = null
|
||||
) {
|
||||
super(calEvent, uid);
|
||||
this.videoCallData = videoCallData;
|
||||
this.additionInformation = additionInformation;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
306
lib/events/EventManager.ts
Normal file
|
@ -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 {CalendarEvent} from "./calendarClient";
|
||||
import { CalendarEvent } from "./calendarClient";
|
||||
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
|
||||
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
||||
import {v5 as uuidv5} from 'uuid';
|
||||
import short from 'short-uuid';
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import short from "short-uuid";
|
||||
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
||||
import { EventResult } from "@lib/events/EventManager";
|
||||
import logger from "@lib/logger";
|
||||
import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail";
|
||||
import { getIntegrationName } from "@lib/emails/helpers";
|
||||
import CalEventParser from "@lib/CalEventParser";
|
||||
import { Credential } from "@prisma/client";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
|
||||
|
||||
const translator = short();
|
||||
|
||||
|
@ -33,54 +41,58 @@ function handleErrorsRaw(response) {
|
|||
}
|
||||
|
||||
const zoomAuth = (credential) => {
|
||||
const isExpired = (expiryDate) => expiryDate < +new Date();
|
||||
const authHeader =
|
||||
"Basic " +
|
||||
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
|
||||
|
||||
const isExpired = (expiryDate) => expiryDate < +(new Date());
|
||||
const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64');
|
||||
|
||||
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', {
|
||||
method: 'POST',
|
||||
const refreshAccessToken = (refreshToken) =>
|
||||
fetch("https://zoom.us/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
'refresh_token': refreshToken,
|
||||
'grant_type': 'refresh_token',
|
||||
})
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then(async (responseBody) => {
|
||||
// Store new tokens in database.
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
key: responseBody
|
||||
}
|
||||
key: responseBody,
|
||||
},
|
||||
});
|
||||
credential.key.access_token = responseBody.access_token;
|
||||
credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
|
||||
credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||
return credential.key.access_token;
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
|
||||
getToken: () =>
|
||||
!isExpired(credential.key.expires_in)
|
||||
? Promise.resolve(credential.key.access_token)
|
||||
: refreshAccessToken(credential.key.refresh_token),
|
||||
};
|
||||
};
|
||||
|
||||
interface VideoApiAdapter {
|
||||
createMeeting(event: CalendarEvent): Promise<any>;
|
||||
|
||||
updateMeeting(uid: String, event: CalendarEvent);
|
||||
updateMeeting(uid: string, event: CalendarEvent);
|
||||
|
||||
deleteMeeting(uid: String);
|
||||
deleteMeeting(uid: string): Promise<unknown>;
|
||||
|
||||
getAvailability(dateFrom, dateTo): Promise<any>;
|
||||
}
|
||||
|
||||
const ZoomVideo = (credential): VideoApiAdapter => {
|
||||
|
||||
const auth = zoomAuth(credential);
|
||||
|
||||
const translateEvent = (event: CalendarEvent) => {
|
||||
|
@ -89,7 +101,7 @@ const ZoomVideo = (credential): VideoApiAdapter => {
|
|||
topic: event.title,
|
||||
type: 2, // Means that this is a scheduled meeting
|
||||
start_time: event.startTime,
|
||||
duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000,
|
||||
duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000,
|
||||
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
|
||||
timezone: event.attendees[0].timeZone,
|
||||
//password: "string", TODO: Should we use a password? Maybe generate a random one?
|
||||
|
@ -107,82 +119,112 @@ const ZoomVideo = (credential): VideoApiAdapter => {
|
|||
audio: "both",
|
||||
auto_recording: "none",
|
||||
enforce_login: false,
|
||||
registrants_email_notification: true
|
||||
}
|
||||
registrants_email_notification: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo) => {
|
||||
return auth.getToken().then(
|
||||
getAvailability: () => {
|
||||
return auth
|
||||
.getToken()
|
||||
.then(
|
||||
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
|
||||
(accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', {
|
||||
method: 'get',
|
||||
(accessToken) =>
|
||||
fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", {
|
||||
method: "get",
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
}
|
||||
Authorization: "Bearer " + accessToken,
|
||||
},
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then(responseBody => {
|
||||
.then((responseBody) => {
|
||||
return responseBody.meetings.map((meeting) => ({
|
||||
start: meeting.start_time,
|
||||
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString()
|
||||
}))
|
||||
end: new Date(
|
||||
new Date(meeting.start_time).getTime() + meeting.duration * 60000
|
||||
).toISOString(),
|
||||
}));
|
||||
})
|
||||
).catch((err) => {
|
||||
)
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
},
|
||||
createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', {
|
||||
method: 'POST',
|
||||
createMeeting: (event: CalendarEvent) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://api.zoom.us/v2/users/me/meetings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event))
|
||||
}).then(handleErrorsJson)),
|
||||
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
}).then(handleErrorsJson)
|
||||
),
|
||||
deleteMeeting: (uid: string) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
}
|
||||
}).then(handleErrorsRaw)),
|
||||
updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Content-Type': 'application/json'
|
||||
Authorization: "Bearer " + accessToken,
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event))
|
||||
}).then(handleErrorsRaw)),
|
||||
}
|
||||
}).then(handleErrorsRaw)
|
||||
),
|
||||
updateMeeting: (uid: string, event: CalendarEvent) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
}).then(handleErrorsRaw)
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// factory
|
||||
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => {
|
||||
const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
|
||||
withCredentials
|
||||
.map((cred) => {
|
||||
switch (cred.type) {
|
||||
case 'zoom_video':
|
||||
case "zoom_video":
|
||||
return ZoomVideo(cred);
|
||||
default:
|
||||
return; // unknown credential, could be legacy? In any case, ignore
|
||||
}
|
||||
}).filter(Boolean);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredentials) =>
|
||||
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
|
||||
results.reduce((acc, availability) => acc.concat(availability), [])
|
||||
);
|
||||
|
||||
const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
|
||||
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
|
||||
).then(
|
||||
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
|
||||
);
|
||||
|
||||
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => {
|
||||
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||
const createMeeting = async (
|
||||
credential: Credential,
|
||||
calEvent: CalendarEvent,
|
||||
maybeUid: string = null
|
||||
): Promise<EventResult> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
|
||||
const uid: string = parser.getUid();
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
|
||||
throw new Error(
|
||||
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
|
||||
);
|
||||
}
|
||||
|
||||
const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent);
|
||||
let success = true;
|
||||
|
||||
const creationResult = await videoIntegrations([credential])[0]
|
||||
.createMeeting(calEvent)
|
||||
.catch((e) => {
|
||||
log.error("createMeeting failed", e, calEvent);
|
||||
success = false;
|
||||
});
|
||||
|
||||
const videoCallData: VideoCallData = {
|
||||
type: credential.type,
|
||||
|
@ -191,60 +233,92 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any>
|
|||
url: creationResult.join_url,
|
||||
};
|
||||
|
||||
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
|
||||
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
|
||||
const entryPoint: EntryPoint = {
|
||||
entryPointType: getIntegrationName(videoCallData),
|
||||
uri: videoCallData.url,
|
||||
label: "Enter Meeting",
|
||||
pin: videoCallData.password,
|
||||
};
|
||||
|
||||
const additionInformation: AdditionInformation = {
|
||||
entryPoints: [entryPoint],
|
||||
};
|
||||
|
||||
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation);
|
||||
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation);
|
||||
try {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("organizerMail.sendEmail failed", e)
|
||||
console.error("organizerMail.sendEmail failed", e);
|
||||
}
|
||||
|
||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
||||
try {
|
||||
await attendeeMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("attendeeMail.sendEmail failed", e)
|
||||
console.error("attendeeMail.sendEmail failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid,
|
||||
createdEvent: creationResult
|
||||
createdEvent: creationResult,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
};
|
||||
|
||||
const updateMeeting = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => {
|
||||
const updateMeeting = async (
|
||||
credential: Credential,
|
||||
uidToUpdate: string,
|
||||
calEvent: CalendarEvent
|
||||
): Promise<EventResult> => {
|
||||
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
|
||||
throw new Error(
|
||||
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
|
||||
);
|
||||
}
|
||||
|
||||
const updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null;
|
||||
let success = true;
|
||||
|
||||
const updateResult = credential
|
||||
? await videoIntegrations([credential])[0]
|
||||
.updateMeeting(uidToUpdate, calEvent)
|
||||
.catch((e) => {
|
||||
log.error("updateMeeting failed", e, calEvent);
|
||||
success = false;
|
||||
})
|
||||
: null;
|
||||
|
||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||
try {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("organizerMail.sendEmail failed", e)
|
||||
console.error("organizerMail.sendEmail failed", e);
|
||||
}
|
||||
|
||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
||||
try {
|
||||
await attendeeMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("attendeeMail.sendEmail failed", e)
|
||||
console.error("attendeeMail.sendEmail failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid: newUid,
|
||||
updatedEvent: updateResult
|
||||
updatedEvent: updateResult,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteMeeting = (credential, uid: String): Promise<any> => {
|
||||
const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
|
||||
if (credential) {
|
||||
return videoIntegrations([credential])[0].deleteMeeting(uid);
|
||||
}
|
||||
|
@ -252,4 +326,4 @@ const deleteMeeting = (credential, uid: String): Promise<any> => {
|
|||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting};
|
||||
export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
|
||||
const withTM = require('next-transpile-modules')(['react-timezone-select']);
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const withTM = require("next-transpile-modules")(["react-timezone-select"]);
|
||||
|
||||
// TODO: Revisit this later with getStaticProps in App
|
||||
if (process.env.NEXTAUTH_URL) {
|
||||
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace('/api/auth', '');
|
||||
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace("/api/auth", "");
|
||||
}
|
||||
|
||||
if ( ! process.env.EMAIL_FROM ) {
|
||||
console.warn('\x1b[33mwarn', '\x1b[0m', 'EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file.');
|
||||
if (!process.env.EMAIL_FROM) {
|
||||
console.warn(
|
||||
"\x1b[33mwarn",
|
||||
"\x1b[0m",
|
||||
"EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file."
|
||||
);
|
||||
}
|
||||
if (process.env.BASE_URL) {
|
||||
process.env.NEXTAUTH_URL = process.env.BASE_URL + '/api/auth';
|
||||
process.env.NEXTAUTH_URL = process.env.BASE_URL + "/api/auth";
|
||||
}
|
||||
|
||||
const validJson = (jsonString) => {
|
||||
|
@ -19,13 +23,18 @@ const validJson = (jsonString) => {
|
|||
if (o && typeof o === "object") {
|
||||
return o;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
catch (e) { console.error(e); }
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env.GOOGLE_API_CREDENTIALS && ! validJson(process.env.GOOGLE_API_CREDENTIALS)) {
|
||||
console.warn('\x1b[33mwarn', '\x1b[0m', "- Disabled 'Google Calendar' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {\"web\":{\"client_id\":\"<clid>\",\"client_secret\":\"<secret>\",\"redirect_uris\":[\"<yourhost>/api/integrations/googlecalendar/callback>\"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials.");
|
||||
if (process.env.GOOGLE_API_CREDENTIALS && !validJson(process.env.GOOGLE_API_CREDENTIALS)) {
|
||||
console.warn(
|
||||
"\x1b[33mwarn",
|
||||
"\x1b[0m",
|
||||
'- Disabled \'Google Calendar\' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {"web":{"client_id":"<clid>","client_secret":"<secret>","redirect_uris":["<yourhost>/api/integrations/googlecalendar/callback>"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = withTM({
|
||||
|
@ -42,10 +51,10 @@ module.exports = withTM({
|
|||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/settings',
|
||||
destination: '/settings/profile',
|
||||
source: "/settings",
|
||||
destination: "/settings/profile",
|
||||
permanent: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
|
@ -19,6 +19,9 @@
|
|||
"@prisma/client": "^2.23.0",
|
||||
"@radix-ui/react-collapsible": "^0.0.16",
|
||||
"@radix-ui/react-dialog": "^0.0.19",
|
||||
"@radix-ui/react-slot": "^0.0.12",
|
||||
"@radix-ui/react-switch": "^0.0.15",
|
||||
"@radix-ui/react-tooltip": "^0.0.21",
|
||||
"@tailwindcss/forms": "^0.2.1",
|
||||
"async": "^3.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"react": "17.0.1",
|
||||
"react-dates": "^21.8.0",
|
||||
"react-dom": "17.0.1",
|
||||
"react-multi-email": "^0.5.3",
|
||||
"react-phone-number-input": "^3.1.21",
|
||||
"react-select": "^4.3.0",
|
||||
"react-timezone-select": "^1.0.2",
|
||||
|
|
154
pages/404.tsx
Normal file
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
110
pages/[user].tsx
|
@ -4,53 +4,126 @@ import Link from "next/link";
|
|||
import prisma, { whereAndSelect } from "@lib/prisma";
|
||||
import Avatar from "../components/Avatar";
|
||||
import Theme from "@components/Theme";
|
||||
import { ClockIcon, InformationCircleIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
|
||||
export default function User(props): User {
|
||||
const { isReady } = Theme(props.user.theme);
|
||||
|
||||
const eventTypes = props.eventTypes.map((type) => (
|
||||
<li
|
||||
<div
|
||||
key={type.id}
|
||||
className="dark:bg-gray-800 dark:opacity-90 dark:hover:opacity-100 dark:hover:bg-gray-800 bg-white hover:bg-gray-50 ">
|
||||
className="group relative dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 bg-white hover:bg-gray-50 border border-neutral-200 hover:border-black rounded-sm">
|
||||
<ArrowRightIcon className="absolute transition-opacity h-4 w-4 right-3 top-3 text-black dark:text-white opacity-0 group-hover:opacity-100" />
|
||||
<Link href={`/${props.user.username}/${type.slug}`}>
|
||||
<a className="block px-6 py-4">
|
||||
<div
|
||||
className="inline-block w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: getRandomColorCode() }}></div>
|
||||
<h2 className="inline-block font-medium dark:text-white">{type.title}</h2>
|
||||
<p className="inline-block text-gray-400 dark:text-gray-100 ml-2">{type.description}</p>
|
||||
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
|
||||
<div className="mt-2 flex space-x-4">
|
||||
<div className="flex text-sm text-neutral-500">
|
||||
<ClockIcon
|
||||
className="flex-shrink-0 mt-0.5 mr-1.5 h-4 w-4 text-neutral-400 dark:text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="dark:text-white">{type.length}m</p>
|
||||
</div>
|
||||
<div className="flex text-sm min-w-16 text-neutral-500">
|
||||
<UserIcon
|
||||
className="flex-shrink-0 mt-0.5 mr-1.5 h-4 w-4 text-neutral-400 dark:text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="dark:text-white">1-on-1</p>
|
||||
</div>
|
||||
<div className="flex text-sm text-neutral-500">
|
||||
<InformationCircleIcon
|
||||
className="flex-shrink-0 mt-0.5 mr-1.5 h-4 w-4 text-neutral-400 dark:text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="dark:text-white">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</div>
|
||||
));
|
||||
return (
|
||||
isReady && (
|
||||
<div>
|
||||
<>
|
||||
<Head>
|
||||
<title>{props.user.name || props.user.username} | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main className="max-w-2xl mx-auto my-24">
|
||||
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
|
||||
<meta name="description" content={"Book a time with " + (props.user.name || props.user.username)} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://calendso/" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content={"Book a time with " + (props.user.name || props.user.username)}
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content={
|
||||
"https://og-image-one-pi.vercel.app/" +
|
||||
encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>").replace(
|
||||
/'/g,
|
||||
"%27"
|
||||
) +
|
||||
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
||||
encodeURIComponent(props.user.avatar)
|
||||
}
|
||||
/>
|
||||
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://calendso/" />
|
||||
<meta
|
||||
property="twitter:title"
|
||||
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
||||
/>
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content={"Book a time with " + (props.user.name || props.user.username)}
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content={
|
||||
"https://og-image-one-pi.vercel.app/" +
|
||||
encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>").replace(
|
||||
/'/g,
|
||||
"%27"
|
||||
) +
|
||||
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
||||
encodeURIComponent(props.user.avatar)
|
||||
}
|
||||
/>
|
||||
</Head>
|
||||
{isReady && (
|
||||
<div className="bg-neutral-50 dark:bg-black h-screen">
|
||||
<main className="max-w-3xl mx-auto py-24 px-4">
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
|
||||
<h1 className="text-3xl font-semibold text-gray-800 dark:text-white mb-1">
|
||||
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-1">
|
||||
{props.user.name || props.user.username}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-white">{props.user.bio}</p>
|
||||
<p className="text-neutral-500 dark:text-white">{props.user.bio}</p>
|
||||
</div>
|
||||
<div className="shadow overflow-hidden rounded-md">
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-900">{eventTypes}</ul>
|
||||
<div className="space-y-6">{eventTypes}</div>
|
||||
{eventTypes.length == 0 && (
|
||||
<div className="shadow overflow-hidden rounded-sm">
|
||||
<div className="p-8 text-center text-gray-400 dark:text-white">
|
||||
<h2 className="font-semibold text-3xl text-gray-600">Uh oh!</h2>
|
||||
<p className="max-w-md mx-auto">This user hasn't set up any event types yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -76,6 +149,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||
select: {
|
||||
slug: true,
|
||||
title: true,
|
||||
length: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { GetServerSideProps, GetServerSidePropsContext } from "next";
|
||||
import Head from "next/head";
|
||||
import { ChevronDownIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
|
||||
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
|
@ -49,9 +49,15 @@ export default function Type(props): Type {
|
|||
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
date: formattedDate,
|
||||
query: Object.assign(
|
||||
{},
|
||||
{
|
||||
...router.query,
|
||||
},
|
||||
{
|
||||
date: formattedDate,
|
||||
}
|
||||
),
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
|
@ -72,12 +78,11 @@ export default function Type(props): Type {
|
|||
};
|
||||
|
||||
return (
|
||||
isReady && (
|
||||
<div>
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username}{" "}
|
||||
| Calendso
|
||||
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
|
||||
Calendso
|
||||
</title>
|
||||
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
|
||||
<meta name="description" content={props.eventType.description} />
|
||||
|
@ -120,16 +125,38 @@ export default function Type(props): Type {
|
|||
}
|
||||
/>
|
||||
</Head>
|
||||
|
||||
{isReady && (
|
||||
<div>
|
||||
<main
|
||||
className={
|
||||
"mx-auto my-0 sm:my-24 transition-max-width ease-in-out duration-500 " +
|
||||
(selectedDate ? "max-w-6xl" : "max-w-3xl")
|
||||
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
}>
|
||||
<div className="dark:bg-gray-800 bg-white sm:shadow sm:rounded-lg">
|
||||
<div className="sm:flex px-4 py-5 sm:p-4">
|
||||
<div className="sm:dark:border-gray-600 dark:bg-gray-900 bg-white md:border border-gray-200 rounded-sm">
|
||||
{/* mobile: details */}
|
||||
<div className="p-4 sm:p-8 block md:hidden">
|
||||
<div className="flex items-center">
|
||||
<Avatar user={props.user} className="inline-block h-9 w-9 rounded-full" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium dark:text-gray-300 text-black">{props.user.name}</p>
|
||||
<div className="flex gap-2 text-xs font-medium text-gray-600">
|
||||
{props.eventType.title}
|
||||
<div>
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{props.eventType.length} minutes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="dark:text-gray-200 text-gray-600 mt-3">{props.eventType.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:flex px-4 sm:py-5 sm:p-4">
|
||||
<div
|
||||
className={
|
||||
"pr-8 sm:border-r sm:dark:border-gray-900 " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||
"hidden md:block pr-8 sm:border-r sm:dark:border-gray-800 " +
|
||||
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||
}>
|
||||
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
||||
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.user.name}</h2>
|
||||
|
@ -141,19 +168,7 @@ export default function Type(props): Type {
|
|||
{props.eventType.length} minutes
|
||||
</p>
|
||||
|
||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Collapsible.Trigger className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
||||
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{timeZone()}
|
||||
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<TimeOptions
|
||||
onSelectTimeZone={handleSelectTimeZone}
|
||||
onToggle24hClock={handleToggle24hClock}
|
||||
/>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
<TimezoneDropdown />
|
||||
|
||||
<p className="dark:text-gray-200 text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
|
||||
</div>
|
||||
|
@ -172,6 +187,11 @@ export default function Type(props): Type {
|
|||
eventLength={props.eventType.length}
|
||||
minimumBookingNotice={props.eventType.minimumBookingNotice}
|
||||
/>
|
||||
|
||||
<div className="ml-1 mt-4 block sm:hidden">
|
||||
<TimezoneDropdown />
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
workingHours={props.workingHours}
|
||||
|
@ -189,8 +209,28 @@ export default function Type(props): Type {
|
|||
{!props.user.hideBranding && <PoweredByCalendso />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function TimezoneDropdown() {
|
||||
return (
|
||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Collapsible.Trigger className="text-gray-500 mb-1 px-2 py-1 -ml-2 text-left min-w-32">
|
||||
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{timeZone()}
|
||||
{isTimeOptionsOpen ? (
|
||||
<ChevronUpIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
||||
) : (
|
||||
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
||||
)}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
|
|
|
@ -15,6 +15,8 @@ import Avatar from "../../components/Avatar";
|
|||
import Button from "../../components/ui/Button";
|
||||
import { EventTypeCustomInputType } from "../../lib/eventTypeInput";
|
||||
import Theme from "@components/Theme";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
import "react-multi-email/style.css";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
@ -27,7 +29,8 @@ export default function Book(props: any): JSX.Element {
|
|||
const [preferredTimeZone, setPreferredTimeZone] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const [guestToggle, setGuestToggle] = useState(false);
|
||||
const [guestEmails, setGuestEmails] = useState([]);
|
||||
const locations = props.eventType.locations || [];
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState<LocationType>(
|
||||
|
@ -44,6 +47,10 @@ export default function Book(props: any): JSX.Element {
|
|||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
|
||||
});
|
||||
|
||||
function toggleGuestEmailInput() {
|
||||
setGuestToggle(!guestToggle);
|
||||
}
|
||||
|
||||
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
|
||||
|
||||
// TODO: Move to translations
|
||||
|
@ -85,6 +92,7 @@ export default function Book(props: any): JSX.Element {
|
|||
name: event.target.name.value,
|
||||
email: event.target.email.value,
|
||||
notes: notes,
|
||||
guests: guestEmails,
|
||||
timeZone: preferredTimeZone,
|
||||
eventTypeId: props.eventType.id,
|
||||
rescheduleUid: rescheduleUid,
|
||||
|
@ -153,9 +161,9 @@ export default function Book(props: any): JSX.Element {
|
|||
</Head>
|
||||
|
||||
<main className="max-w-3xl mx-auto my-0 sm:my-24">
|
||||
<div className="dark:bg-gray-800 bg-white overflow-hidden sm:shadow sm:rounded-lg">
|
||||
<div className="dark:bg-neutral-900 bg-white overflow-hidden border border-gray-200 dark:border-0 sm:rounded-sm">
|
||||
<div className="sm:flex px-4 py-5 sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-900">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-black">
|
||||
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
||||
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.user.name}</h2>
|
||||
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
|
||||
|
@ -171,7 +179,7 @@ export default function Book(props: any): JSX.Element {
|
|||
{locationInfo(selectedLocation).address}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-blue-600 mb-4">
|
||||
<p className="text-green-500 mb-4">
|
||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{preferredTimeZone &&
|
||||
dayjs(date)
|
||||
|
@ -192,7 +200,7 @@ export default function Book(props: any): JSX.Element {
|
|||
name="name"
|
||||
id="name"
|
||||
required
|
||||
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="John Doe"
|
||||
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
|
||||
/>
|
||||
|
@ -210,7 +218,7 @@ export default function Book(props: any): JSX.Element {
|
|||
name="email"
|
||||
id="email"
|
||||
required
|
||||
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
|
||||
/>
|
||||
|
@ -252,7 +260,7 @@ export default function Book(props: any): JSX.Element {
|
|||
placeholder="Enter phone number"
|
||||
id="phone"
|
||||
required
|
||||
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
onChange={() => {
|
||||
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
|
||||
}}
|
||||
|
@ -278,7 +286,7 @@ export default function Book(props: any): JSX.Element {
|
|||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
rows={3}
|
||||
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder=""
|
||||
/>
|
||||
)}
|
||||
|
@ -288,7 +296,7 @@ export default function Book(props: any): JSX.Element {
|
|||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder=""
|
||||
/>
|
||||
)}
|
||||
|
@ -298,7 +306,7 @@ export default function Book(props: any): JSX.Element {
|
|||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder=""
|
||||
/>
|
||||
)}
|
||||
|
@ -308,7 +316,7 @@ export default function Book(props: any): JSX.Element {
|
|||
type="checkbox"
|
||||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
className="focus:ring-black h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
|
||||
className="focus:ring-black h-4 w-4 text-black border-gray-300 rounded mr-2"
|
||||
placeholder=""
|
||||
/>
|
||||
<label
|
||||
|
@ -320,6 +328,42 @@ export default function Book(props: any): JSX.Element {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="mb-4">
|
||||
{!guestToggle && (
|
||||
<label
|
||||
onClick={toggleGuestEmailInput}
|
||||
htmlFor="guests"
|
||||
className="block text-sm font-medium dark:text-white text-blue-500 mb-1 hover:cursor-pointer">
|
||||
+ Additional Guests
|
||||
</label>
|
||||
)}
|
||||
{guestToggle && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="guests"
|
||||
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
|
||||
Guests
|
||||
</label>
|
||||
<ReactMultiEmail
|
||||
placeholder="guest@example.com"
|
||||
emails={guestEmails}
|
||||
onChange={(_emails: string[]) => {
|
||||
setGuestEmails(_emails);
|
||||
}}
|
||||
getLabel={(email: string, index: number, removeEmail: (index: number) => void) => {
|
||||
return (
|
||||
<div data-tag key={index}>
|
||||
{email}
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="notes"
|
||||
|
@ -330,13 +374,14 @@ export default function Book(props: any): JSX.Element {
|
|||
name="notes"
|
||||
id="notes"
|
||||
rows={3}
|
||||
className="shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="Please share anything that will help prepare for our meeting."
|
||||
defaultValue={props.booking ? props.booking.description : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<Button type="submit" loading={loading} className="btn btn-primary">
|
||||
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
|
||||
<Button type="submit" loading={loading}>
|
||||
{rescheduleUid ? "Reschedule" : "Confirm"}
|
||||
</Button>
|
||||
<Link
|
||||
|
@ -347,7 +392,7 @@ export default function Book(props: any): JSX.Element {
|
|||
props.eventType.slug +
|
||||
(rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")
|
||||
}>
|
||||
<a className="ml-2 btn btn-white">Cancel</a>
|
||||
<a className="ml-2 text-sm dark:text-white p-2">Cancel</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -408,10 +453,13 @@ export async function getServerSideProps(context) {
|
|||
},
|
||||
});
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
});
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
...e,
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
};
|
||||
})[0];
|
||||
|
||||
let booking = null;
|
||||
|
||||
|
|
|
@ -9,10 +9,7 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
<TelemetryProvider value={createTelemetryClient()}>
|
||||
<Provider session={pageProps.session}>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</Provider>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Document, { Html, Head, Main, NextScript } from "next/document";
|
||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx) {
|
||||
|
@ -18,7 +18,7 @@ class MyDocument extends Document {
|
|||
<meta name="msapplication-TileColor" content="#ff0000" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</Head>
|
||||
<body className="dark:bg-gray-900 bg-white">
|
||||
<body className="dark:bg-black bg-gray-100">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import prisma from "../../../lib/prisma";
|
||||
import { getBusyCalendarTimes } from "../../../lib/calendarClient";
|
||||
import { getBusyVideoTimes } from "../../../lib/videoClient";
|
||||
import prisma from "@lib/prisma";
|
||||
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
||||
import { getBusyVideoTimes } from "@lib/videoClient";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
@ -25,39 +25,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
},
|
||||
});
|
||||
|
||||
const hasCalendarIntegrations =
|
||||
currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
|
||||
const hasVideoIntegrations =
|
||||
currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0;
|
||||
|
||||
const calendarAvailability = await getBusyCalendarTimes(
|
||||
const calendarBusyTimes = await getBusyCalendarTimes(
|
||||
currentUser.credentials,
|
||||
req.query.dateFrom,
|
||||
req.query.dateTo,
|
||||
selectedCalendars
|
||||
);
|
||||
const videoAvailability = await getBusyVideoTimes(
|
||||
const videoBusyTimes = await getBusyVideoTimes(
|
||||
currentUser.credentials,
|
||||
req.query.dateFrom,
|
||||
req.query.dateTo
|
||||
);
|
||||
calendarBusyTimes.push(...videoBusyTimes);
|
||||
|
||||
let commonAvailability = [];
|
||||
|
||||
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
||||
commonAvailability = calendarAvailability.filter((availability) =>
|
||||
videoAvailability.includes(availability)
|
||||
);
|
||||
} else if (hasVideoIntegrations) {
|
||||
commonAvailability = videoAvailability;
|
||||
} else if (hasCalendarIntegrations) {
|
||||
commonAvailability = calendarAvailability;
|
||||
}
|
||||
|
||||
commonAvailability = commonAvailability.map((a) => ({
|
||||
const bufferedBusyTimes = calendarBusyTimes.map((a) => ({
|
||||
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
||||
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
||||
}));
|
||||
|
||||
res.status(200).json(commonAvailability);
|
||||
res.status(200).json(bufferedBusyTimes);
|
||||
}
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import prisma from "@lib/prisma";
|
||||
import { EventType } from "@prisma/client";
|
||||
import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient";
|
||||
import async from "async";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import short from "short-uuid";
|
||||
import { createMeeting, getBusyVideoTimes, updateMeeting } from "../../../lib/videoClient";
|
||||
import { getBusyVideoTimes } from "@lib/videoClient";
|
||||
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
|
||||
import { getEventName } from "../../../lib/event";
|
||||
import { LocationType } from "../../../lib/location";
|
||||
import merge from "lodash.merge";
|
||||
import { getEventName } from "@lib/event";
|
||||
import dayjs from "dayjs";
|
||||
import logger from "../../../lib/logger";
|
||||
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
|
||||
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
@ -37,11 +35,6 @@ function isAvailable(busyTimes, time, length) {
|
|||
const startTime = dayjs(busyTime.start);
|
||||
const endTime = dayjs(busyTime.end);
|
||||
|
||||
// Check if start times are the same
|
||||
if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) {
|
||||
t = false;
|
||||
}
|
||||
|
||||
// Check if time is between start and end times
|
||||
if (dayjs(time).isBetween(startTime, endTime)) {
|
||||
t = false;
|
||||
|
@ -88,167 +81,8 @@ function isOutOfBounds(
|
|||
}
|
||||
}
|
||||
|
||||
interface GetLocationRequestFromIntegrationRequest {
|
||||
location: string;
|
||||
}
|
||||
|
||||
const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromIntegrationRequest) => {
|
||||
if (location === LocationType.GoogleMeet.valueOf()) {
|
||||
const requestId = uuidv5(location, uuidv5.URL);
|
||||
|
||||
return {
|
||||
conferenceData: {
|
||||
createRequest: {
|
||||
requestId: requestId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
async function rescheduleEvent(
|
||||
rescheduleUid: string | string[],
|
||||
results: unknown[],
|
||||
calendarCredentials: unknown[],
|
||||
evt: CalendarEvent,
|
||||
videoCredentials: unknown[],
|
||||
referencesToCreate: { type: string; uid: string }[]
|
||||
): Promise<{
|
||||
referencesToCreate: { type: string; uid: string }[];
|
||||
results: unknown[];
|
||||
error: { errorCode: string; message: string } | null;
|
||||
}> {
|
||||
// Reschedule event
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: rescheduleUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
references: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
uid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Use all integrations
|
||||
results = results.concat(
|
||||
await async.mapLimit(calendarCredentials, 5, async (credential) => {
|
||||
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
|
||||
return updateEvent(credential, bookingRefUid, evt)
|
||||
.then((response) => ({ type: credential.type, success: true, response }))
|
||||
.catch((e) => {
|
||||
log.error("updateEvent failed", e, evt);
|
||||
return { type: credential.type, success: false };
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
results = results.concat(
|
||||
await async.mapLimit(videoCredentials, 5, async (credential) => {
|
||||
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
|
||||
return updateMeeting(credential, bookingRefUid, evt)
|
||||
.then((response) => ({ type: credential.type, success: true, response }))
|
||||
.catch((e) => {
|
||||
log.error("updateMeeting failed", e, evt);
|
||||
return { type: credential.type, success: false };
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
|
||||
return { referencesToCreate: [], results: [], error: error };
|
||||
}
|
||||
|
||||
// Clone elements
|
||||
referencesToCreate = [...booking.references];
|
||||
|
||||
// Now we can delete the old booking and its references.
|
||||
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||
where: {
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
const bookingDeletes = prisma.booking.delete({
|
||||
where: {
|
||||
uid: rescheduleUid,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
|
||||
return { error: undefined, results, referencesToCreate };
|
||||
}
|
||||
|
||||
export async function scheduleEvent(
|
||||
results: unknown[],
|
||||
calendarCredentials: unknown[],
|
||||
evt: CalendarEvent,
|
||||
videoCredentials: unknown[],
|
||||
referencesToCreate: { type: string; uid: string }[]
|
||||
): Promise<{
|
||||
referencesToCreate: { type: string; uid: string }[];
|
||||
results: unknown[];
|
||||
error: { errorCode: string; message: string } | null;
|
||||
}> {
|
||||
// Schedule event
|
||||
results = results.concat(
|
||||
await async.mapLimit(calendarCredentials, 5, async (credential) => {
|
||||
return createEvent(credential, evt)
|
||||
.then((response) => ({ type: credential.type, success: true, response }))
|
||||
.catch((e) => {
|
||||
log.error("createEvent failed", e, evt);
|
||||
return { type: credential.type, success: false };
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
results = results.concat(
|
||||
await async.mapLimit(videoCredentials, 5, async (credential) => {
|
||||
return createMeeting(credential, evt)
|
||||
.then((response) => ({ type: credential.type, success: true, response }))
|
||||
.catch((e) => {
|
||||
log.error("createMeeting failed", e, evt);
|
||||
return { type: credential.type, success: false };
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingCreatingMeetingFailed",
|
||||
message: "Booking failed",
|
||||
};
|
||||
|
||||
return { referencesToCreate: [], results: [], error: error };
|
||||
}
|
||||
|
||||
referencesToCreate = results.map((result) => {
|
||||
return {
|
||||
type: result.type,
|
||||
uid: result.response.createdEvent.id.toString(),
|
||||
};
|
||||
});
|
||||
return { error: undefined, results, referencesToCreate };
|
||||
}
|
||||
|
||||
export async function handleLegacyConfirmationMail(
|
||||
results: unknown[],
|
||||
results: Array<EventResult>,
|
||||
eventType: EventType,
|
||||
evt: CalendarEvent,
|
||||
hashUID: string
|
||||
|
@ -285,7 +119,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(400).json(error);
|
||||
}
|
||||
|
||||
let currentUser = await prisma.user.findFirst({
|
||||
let currentUser: User = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: user,
|
||||
},
|
||||
|
@ -315,11 +149,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
dayjs(req.body.end).endOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
|
||||
selectedCalendars
|
||||
);
|
||||
const videoAvailability = await getBusyVideoTimes(
|
||||
currentUser.credentials,
|
||||
dayjs(req.body.start).startOf("day").utc().format(),
|
||||
dayjs(req.body.end).endOf("day").utc().format()
|
||||
);
|
||||
const videoAvailability = await getBusyVideoTimes(currentUser.credentials);
|
||||
let commonAvailability = [];
|
||||
|
||||
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
||||
|
@ -346,8 +176,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
},
|
||||
});
|
||||
|
||||
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
||||
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||
// Initialize EventManager with credentials
|
||||
const eventManager = new EventManager(currentUser.credentials);
|
||||
const rescheduleUid = req.body.rescheduleUid;
|
||||
|
||||
const eventType: EventType = await prisma.eventType.findFirst({
|
||||
|
@ -369,35 +199,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
},
|
||||
});
|
||||
|
||||
const rawLocation = req.body.location;
|
||||
const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }];
|
||||
const guests = req.body.guests.map((guest) => {
|
||||
const g = {
|
||||
email: guest,
|
||||
name: "",
|
||||
timeZone: req.body.timeZone,
|
||||
};
|
||||
return g;
|
||||
});
|
||||
const attendeesList = [...invitee, ...guests];
|
||||
|
||||
let evt: CalendarEvent = {
|
||||
const evt: CalendarEvent = {
|
||||
type: eventType.title,
|
||||
title: getEventName(req.body.name, eventType.title, eventType.eventName),
|
||||
description: req.body.notes,
|
||||
startTime: req.body.start,
|
||||
endTime: req.body.end,
|
||||
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
||||
attendees: [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }],
|
||||
attendees: attendeesList,
|
||||
location: req.body.location, // Will be processed by the EventManager later.
|
||||
};
|
||||
|
||||
// If phone or inPerson use raw location
|
||||
// set evt.location to req.body.location
|
||||
if (!rawLocation?.includes("integration")) {
|
||||
evt.location = rawLocation;
|
||||
}
|
||||
|
||||
// If location is set to an integration location
|
||||
// Build proper transforms for evt object
|
||||
// Extend evt object with those transformations
|
||||
if (rawLocation?.includes("integration")) {
|
||||
const maybeLocationRequestObject = getLocationRequestFromIntegration({
|
||||
location: rawLocation,
|
||||
});
|
||||
|
||||
evt = merge(evt, maybeLocationRequestObject);
|
||||
}
|
||||
|
||||
let isAvailableToBeBooked = true;
|
||||
|
||||
try {
|
||||
|
@ -445,44 +268,47 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(400).json(error);
|
||||
}
|
||||
|
||||
let results = [];
|
||||
let results: Array<EventResult> = [];
|
||||
let referencesToCreate = [];
|
||||
|
||||
if (rescheduleUid) {
|
||||
const __ret = await rescheduleEvent(
|
||||
rescheduleUid,
|
||||
results,
|
||||
calendarCredentials,
|
||||
evt,
|
||||
videoCredentials,
|
||||
referencesToCreate
|
||||
);
|
||||
if (__ret.error) {
|
||||
log.error(`Booking ${user} failed`, __ret.error, results);
|
||||
return res.status(500).json(__ret.error);
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
|
||||
log.error(`Booking ${user} failed`, error, results);
|
||||
return res.status(500).json(error);
|
||||
}
|
||||
results = __ret.results;
|
||||
referencesToCreate = __ret.referencesToCreate;
|
||||
|
||||
// Forward results
|
||||
results = updateResults.results;
|
||||
referencesToCreate = updateResults.referencesToCreate;
|
||||
} else if (!eventType.requiresConfirmation) {
|
||||
const __ret = await scheduleEvent(
|
||||
results,
|
||||
calendarCredentials,
|
||||
evt,
|
||||
videoCredentials,
|
||||
referencesToCreate
|
||||
);
|
||||
if (__ret.error) {
|
||||
log.error(`Booking ${user} failed`, __ret.error, results);
|
||||
return res.status(500).json(__ret.error);
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const createResults: CreateUpdateResult = await eventManager.create(evt);
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingCreatingMeetingFailed",
|
||||
message: "Booking failed",
|
||||
};
|
||||
|
||||
log.error(`Booking ${user} failed`, error, results);
|
||||
return res.status(500).json(error);
|
||||
}
|
||||
results = __ret.results;
|
||||
referencesToCreate = __ret.referencesToCreate;
|
||||
|
||||
// Forward results
|
||||
results = createResults.results;
|
||||
referencesToCreate = createResults.referencesToCreate;
|
||||
}
|
||||
|
||||
const hashUID =
|
||||
results.length > 0
|
||||
? results[0].response.uid
|
||||
: translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
|
||||
results.length > 0 ? results[0].uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
|
||||
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
|
||||
// UID generation should happen in the integration itself, not here.
|
||||
const legacyMailError = await handleLegacyConfirmationMail(results, eventType, evt, hashUID);
|
||||
|
@ -510,6 +336,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
create: evt.attendees,
|
||||
},
|
||||
confirmed: !eventType.requiresConfirmation,
|
||||
location: evt.location,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/client";
|
||||
import prisma from "../../../lib/prisma";
|
||||
import { handleLegacyConfirmationMail, scheduleEvent } from "./[user]";
|
||||
import { handleLegacyConfirmationMail } from "./[user]";
|
||||
import { CalendarEvent } from "@lib/calendarClient";
|
||||
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
||||
import EventManager from "@lib/events/EventManager";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||
const session = await getSession({ req: req });
|
||||
|
@ -41,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
endTime: true,
|
||||
confirmed: true,
|
||||
attendees: true,
|
||||
location: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
|
@ -54,9 +56,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(400).json({ message: "booking already confirmed" });
|
||||
}
|
||||
|
||||
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
||||
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
|
@ -65,10 +64,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
endTime: booking.endTime.toISOString(),
|
||||
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
||||
attendees: booking.attendees,
|
||||
location: booking.location,
|
||||
};
|
||||
|
||||
if (req.body.confirmed) {
|
||||
const scheduleResult = await scheduleEvent([], calendarCredentials, evt, videoCredentials, []);
|
||||
const eventManager = new EventManager(currentUser.credentials);
|
||||
const scheduleResult = await eventManager.create(evt, booking.uid);
|
||||
|
||||
await handleLegacyConfirmationMail(
|
||||
scheduleResult.results,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import prisma from '../../lib/prisma';
|
||||
import {deleteEvent} from "../../lib/calendarClient";
|
||||
import async from 'async';
|
||||
import {deleteMeeting} from "../../lib/videoClient";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { deleteEvent } from "../../lib/calendarClient";
|
||||
import async from "async";
|
||||
import { deleteMeeting } from "../../lib/videoClient";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method == "POST") {
|
||||
|
@ -15,36 +15,38 @@ export default async function handler(req, res) {
|
|||
id: true,
|
||||
user: {
|
||||
select: {
|
||||
credentials: true
|
||||
}
|
||||
credentials: true,
|
||||
},
|
||||
},
|
||||
attendees: true,
|
||||
references: {
|
||||
select: {
|
||||
uid: true,
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
|
||||
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid;
|
||||
if(credential.type.endsWith("_calendar")) {
|
||||
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
|
||||
if (bookingRefUid) {
|
||||
if (credential.type.endsWith("_calendar")) {
|
||||
return await deleteEvent(credential, bookingRefUid);
|
||||
} else if(credential.type.endsWith("_video")) {
|
||||
} else if (credential.type.endsWith("_video")) {
|
||||
return await deleteMeeting(credential, bookingRefUid);
|
||||
}
|
||||
}
|
||||
});
|
||||
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||
where: {
|
||||
bookingId: bookingToDelete.id
|
||||
}
|
||||
bookingId: bookingToDelete.id,
|
||||
},
|
||||
});
|
||||
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
bookingId: bookingToDelete.id
|
||||
}
|
||||
bookingId: bookingToDelete.id,
|
||||
},
|
||||
});
|
||||
const bookingDeletes = prisma.booking.delete({
|
||||
where: {
|
||||
|
@ -52,17 +54,12 @@ export default async function handler(req, res) {
|
|||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
apiDeletes,
|
||||
attendeeDeletes,
|
||||
bookingReferenceDeletes,
|
||||
bookingDeletes
|
||||
]);
|
||||
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes, bookingDeletes]);
|
||||
|
||||
//TODO Perhaps send emails to user and client to tell about the cancellation
|
||||
|
||||
res.status(200).json({message: 'Booking successfully deleted.'});
|
||||
res.status(200).json({ message: "Booking successfully deleted." });
|
||||
} else {
|
||||
res.status(405).json({message: 'This endpoint only accepts POST requests.'});
|
||||
res.status(405).json({ message: "This endpoint only accepts POST requests." });
|
||||
}
|
||||
}
|
|
@ -1,20 +1,26 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { XIcon } from '@heroicons/react/outline';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from "next/router";
|
||||
import { XIcon } from "@heroicons/react/outline";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Error() {
|
||||
const router = useRouter();
|
||||
const { error } = router.query;
|
||||
|
||||
return (
|
||||
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<Head>
|
||||
<title>{error} - Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
|
|
|
@ -2,11 +2,10 @@ import { getCsrfToken } from "next-auth/client";
|
|||
import prisma from "../../../lib/prisma";
|
||||
|
||||
import Head from "next/head";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import debounce from "lodash.debounce";
|
||||
import dayjs from "dayjs";
|
||||
import { ResetPasswordRequest } from "@prisma/client";
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { CheckIcon } from '@heroicons/react/outline';
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
|
||||
export default function Logout() {
|
||||
return (
|
||||
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<Head>
|
||||
<title>Logged out - Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||
|
@ -21,15 +27,13 @@ export default function Logout() {
|
|||
You've been logged out
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
We hope to see you again soon!
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">We hope to see you again soon!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<Link href="/auth/login">
|
||||
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
|
||||
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-black text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
|
||||
Go back to the login page
|
||||
</a>
|
||||
</Link>
|
||||
|
|
|
@ -1,27 +1,25 @@
|
|||
import Head from 'next/head';
|
||||
import {useRouter} from "next/router";
|
||||
import {signIn} from 'next-auth/client'
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { signIn } from "next-auth/client";
|
||||
import ErrorAlert from "../../components/ui/alerts/Error";
|
||||
import {useState} from "react";
|
||||
import {UsernameInput} from "../../components/ui/UsernameInput";
|
||||
import { useState } from "react";
|
||||
import { UsernameInput } from "../../components/ui/UsernameInput";
|
||||
import prisma from "../../lib/prisma";
|
||||
|
||||
export default function Signup(props) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [ hasErrors, setHasErrors ] = useState(false);
|
||||
const [ errorMessage, setErrorMessage ] = useState('');
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleErrors = async (resp) => {
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = (e) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (e.target.password.value !== e.target.passwordcheck.value) {
|
||||
|
@ -31,39 +29,37 @@ export default function Signup(props) {
|
|||
const email: string = e.target.email.value;
|
||||
const password: string = e.target.password.value;
|
||||
|
||||
fetch('/api/auth/signup',
|
||||
{
|
||||
fetch("/api/auth/signup", {
|
||||
body: JSON.stringify({
|
||||
username: e.target.username.value,
|
||||
password,
|
||||
email,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
method: "POST",
|
||||
})
|
||||
.then(handleErrors)
|
||||
.then(
|
||||
() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })
|
||||
)
|
||||
.catch( (err) => {
|
||||
.then(() => signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string }))
|
||||
.catch((err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<Head>
|
||||
<title>Sign up</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<h2 className="text-center text-3xl font-extrabold text-gray-900">Create your account</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow mx-2 sm:rounded-lg sm:px-10">
|
||||
|
@ -74,23 +70,60 @@ export default function Signup(props) {
|
|||
<UsernameInput required />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input type="email" name="email" id="email" placeholder="jdoe@example.com" disabled={!!props.email} readOnly={!!props.email} value={props.email} className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="jdoe@example.com"
|
||||
disabled={!!props.email}
|
||||
readOnly={!!props.email}
|
||||
value={props.email}
|
||||
className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input type="password" name="password" id="password" required placeholder="•••••••••••••" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
placeholder="•••••••••••••"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">Confirm password</label>
|
||||
<input type="password" name="passwordcheck" id="passwordcheck" required placeholder="•••••••••••••" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
|
||||
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="passwordcheck"
|
||||
id="passwordcheck"
|
||||
required
|
||||
placeholder="•••••••••••••"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 sm:mt-4 flex">
|
||||
<input type="submit" value="Create Account"
|
||||
className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm" />
|
||||
<a onClick={() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })}
|
||||
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">Login instead</a>
|
||||
<input
|
||||
type="submit"
|
||||
value="Create Account"
|
||||
className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm"
|
||||
/>
|
||||
<a
|
||||
onClick={() =>
|
||||
signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string })
|
||||
}
|
||||
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">
|
||||
Login instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -103,38 +136,40 @@ export async function getServerSideProps(ctx) {
|
|||
if (!ctx.query.token) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
const verificationRequest = await prisma.verificationRequest.findUnique({
|
||||
where: {
|
||||
token: ctx.query.token,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// for now, disable if no verificationRequestToken given or token expired
|
||||
if ( ! verificationRequest || verificationRequest.expires < new Date() ) {
|
||||
if (!verificationRequest || verificationRequest.expires < new Date()) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: verificationRequest.identifier
|
||||
email: verificationRequest.identifier,
|
||||
},
|
||||
{
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return { redirect: { permanent: false, destination: '/auth/login?callbackUrl=' + ctx.query.callbackUrl } };
|
||||
return {
|
||||
redirect: { permanent: false, destination: "/auth/login?callbackUrl=" + ctx.query.callbackUrl },
|
||||
};
|
||||
}
|
||||
|
||||
return { props: { email: verificationRequest.identifier } };
|
||||
|
|
|
@ -7,9 +7,10 @@ import { useRouter } from "next/router";
|
|||
import { useRef, useState } from "react";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import { ClockIcon } from "@heroicons/react/outline";
|
||||
import Loader from '@components/Loader';
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Availability(props) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
const router = useRouter();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
@ -29,7 +30,7 @@ export default function Availability(props) {
|
|||
const bufferMinsRef = useRef<HTMLInputElement>();
|
||||
|
||||
if (loading) {
|
||||
return <Loader/>;
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
function toggleAddModal() {
|
||||
|
@ -52,7 +53,7 @@ export default function Availability(props) {
|
|||
m = m < 10 ? "0" + m : m;
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function createEventTypeHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
|
@ -63,7 +64,7 @@ export default function Availability(props) {
|
|||
const enteredIsHidden = isHiddenRef.current.checked;
|
||||
|
||||
// TODO: Add validation
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const response = await fetch("/api/availability/eventtype", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
@ -99,7 +100,7 @@ export default function Availability(props) {
|
|||
const bufferMins = enteredBufferHours * 60 + enteredBufferMins;
|
||||
|
||||
// TODO: Add validation
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const response = await fetch("/api/availability/day", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }),
|
||||
|
@ -124,7 +125,7 @@ export default function Availability(props) {
|
|||
|
||||
">
|
||||
<div className="flex">
|
||||
<div className="w-1/2 mr-2 bg-white shadow rounded-sm">
|
||||
<div className="w-1/2 mr-2 bg-white border border-gray-200 rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Change the start and end times of your day
|
||||
|
@ -143,7 +144,7 @@ export default function Availability(props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 ml-2 bg-white shadow rounded-sm">
|
||||
<div className="w-1/2 ml-2 border border-gray-200 rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Something doesn't look right?
|
||||
|
@ -153,7 +154,7 @@ export default function Availability(props) {
|
|||
</div>
|
||||
<div className="mt-5">
|
||||
<Link href="/availability/troubleshoot">
|
||||
<a className="btn btn-primary">Launch troubleshooter</a>
|
||||
<a className="btn btn-white">Launch troubleshooter</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,16 +6,19 @@ import dayjs from "dayjs";
|
|||
import utc from "dayjs/plugin/utc";
|
||||
import { GetServerSideProps } from "next";
|
||||
import prisma from "@lib/prisma";
|
||||
import Loader from '@components/Loader';
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export default function Troubleshoot({ user }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
const [availability, setAvailability] = useState([]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
|
||||
if (loading) {
|
||||
return <Loader/>;
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
function convertMinsToHrsMins(mins) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Menu, Transition } from "@headlessui/react";
|
|||
import { DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import classNames from "@lib/classNames";
|
||||
import { ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Bookings({ bookings }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
@ -17,7 +18,7 @@ export default function Bookings({ bookings }) {
|
|||
const router = useRouter();
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
async function confirmBookingHandler(booking, confirm: boolean) {
|
||||
|
@ -43,25 +44,8 @@ export default function Bookings({ bookings }) {
|
|||
<div className="-mx-4 sm:mx-auto flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-sm">
|
||||
<div className="border border-gray-200 overflow-hidden border-b rounded-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Event
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="hidden sm:table-cell px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th scope="col" className="relative px-6 py-3">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{bookings
|
||||
.filter((booking) => !booking.confirmed && !booking.rejected)
|
||||
|
@ -70,7 +54,7 @@ export default function Bookings({ bookings }) {
|
|||
<tr key={booking.id}>
|
||||
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Unconfirmed
|
||||
</span>
|
||||
)}
|
||||
|
@ -140,7 +124,7 @@ export default function Bookings({ bookings }) {
|
|||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="text-neutral-400 mt-1">
|
||||
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
|
||||
<span className="sr-only">Open options</span>
|
||||
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import {useState} from 'react';
|
||||
import Head from 'next/head';
|
||||
import prisma from '../../lib/prisma';
|
||||
import {useRouter} from 'next/router';
|
||||
import dayjs from 'dayjs';
|
||||
import {CalendarIcon, ClockIcon, XIcon} from '@heroicons/react/solid';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
|
||||
import { useState } from "react";
|
||||
import Head from "next/head";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { useRouter } from "next/router";
|
||||
import dayjs from "dayjs";
|
||||
import { CalendarIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isBetween);
|
||||
|
@ -20,58 +20,61 @@ export default function Type(props) {
|
|||
const router = useRouter();
|
||||
const { uid } = router.query;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [is24h, setIs24h] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const cancellationHandler = async (event) => {
|
||||
setLoading(true);
|
||||
|
||||
let payload = {
|
||||
uid: uid
|
||||
const payload = {
|
||||
uid: uid,
|
||||
};
|
||||
|
||||
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters()));
|
||||
const res = await fetch(
|
||||
'/api/cancel',
|
||||
{
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())
|
||||
);
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: 'POST'
|
||||
}
|
||||
);
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if(res.status >= 200 && res.status < 300) {
|
||||
router.push('/cancel/success?user=' + props.user.username + '&title=' + props.eventType.title);
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
router.push("/cancel/success?user=" + props.user.username + "&title=" + props.eventType.title);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError("An error with status code " + res.status + " occurred. Please try again later.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>
|
||||
Cancel {props.booking.title} | {props.user.name || props.user.username} |
|
||||
Calendso
|
||||
Cancel {props.booking.title} | {props.user.name || props.user.username} | Calendso
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico"/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className="max-w-3xl mx-auto my-24">
|
||||
<div className="fixed z-50 inset-0 overflow-y-auto">
|
||||
<div
|
||||
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true">​</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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
|
||||
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
|
||||
{error && <div>
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
{error && (
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<XIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
|
@ -80,41 +83,49 @@ export default function Type(props) {
|
|||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>}
|
||||
{!error && <div>
|
||||
<div
|
||||
className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<XIcon className="h-6 w-6 text-red-600"/>
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<XIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
|
||||
Really cancel your booking?
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Instead, you could also reschedule it.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Instead, you could also reschedule it.</p>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-b py-4">
|
||||
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.booking.title}</h2>
|
||||
<p className="text-gray-500 mb-1">
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{props.eventType.length} minutes
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
|
||||
{dayjs.utc(props.booking.startTime).format((is24h ? 'H:mm' : 'h:mma') + ", dddd DD MMMM YYYY")}
|
||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{dayjs
|
||||
.utc(props.booking.startTime)
|
||||
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 sm:mt-6 text-center">
|
||||
<div className="mt-5">
|
||||
<button onClick={cancellationHandler} disabled={loading} type="button"
|
||||
<button
|
||||
onClick={cancellationHandler}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={() => router.push('/reschedule/' + uid)} disabled={loading} type="button"
|
||||
<button
|
||||
onClick={() => router.push("/reschedule/" + uid)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
|
||||
Reschedule
|
||||
</button>
|
||||
|
@ -147,22 +158,22 @@ export async function getServerSideProps(context) {
|
|||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993)
|
||||
const bookingObj = Object.assign({}, booking, {
|
||||
startTime: booking.startTime.toString(),
|
||||
endTime: booking.endTime.toString()
|
||||
endTime: booking.endTime.toString(),
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
user: booking.user,
|
||||
eventType: booking.eventType,
|
||||
booking: bookingObj
|
||||
booking: bookingObj,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import Head from 'next/head';
|
||||
import prisma from '../../lib/prisma';
|
||||
import {useRouter} from 'next/router';
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import {CheckIcon} from "@heroicons/react/outline";
|
||||
import Head from "next/head";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { useRouter } from "next/router";
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isBetween);
|
||||
|
@ -21,21 +21,22 @@ export default function Type(props) {
|
|||
<div>
|
||||
<Head>
|
||||
<title>
|
||||
Cancelled {props.title} | {props.user.name || props.user.username} |
|
||||
Calendso
|
||||
Cancelled {props.title} | {props.user.name || props.user.username} | Calendso
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico"/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className="max-w-3xl mx-auto my-24">
|
||||
<div className="fixed z-50 inset-0 overflow-y-auto">
|
||||
<div
|
||||
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true">​</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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
|
||||
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||
<CheckIcon className="h-6 w-6 text-green-600" />
|
||||
|
@ -45,15 +46,15 @@ export default function Type(props) {
|
|||
Cancellation successful
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Feel free to pick another event anytime.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Feel free to pick another event anytime.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6 text-center">
|
||||
<div className="mt-5">
|
||||
<button onClick={() => router.push('/' + props.user.username)} type="button"
|
||||
<button
|
||||
onClick={() => router.push("/" + props.user.username)}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
|
||||
Pick another
|
||||
</button>
|
||||
|
@ -78,14 +79,14 @@ export async function getServerSideProps(context) {
|
|||
name: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
eventTypes: true
|
||||
}
|
||||
eventTypes: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
title: context.query.title
|
||||
title: context.query.title,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -2,21 +2,19 @@ import { GetServerSideProps } from "next";
|
|||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Select, { OptionBase } from "react-select";
|
||||
import prisma from "@lib/prisma";
|
||||
import { LocationType } from "@lib/location";
|
||||
import Shell from "@components/Shell";
|
||||
import { getSession } from "next-auth/client";
|
||||
import { Scheduler } from "@components/ui/Scheduler";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
|
||||
import { Disclosure, RadioGroup } from "@headlessui/react";
|
||||
import { PhoneIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
|
||||
import {
|
||||
LocationMarkerIcon,
|
||||
LinkIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
DocumentIcon,
|
||||
ChevronRightIcon,
|
||||
|
@ -30,12 +28,14 @@ import utc from "dayjs/plugin/utc";
|
|||
import timezone from "dayjs/plugin/timezone";
|
||||
import { Availability, EventType, User } from "@prisma/client";
|
||||
import { validJson } from "@lib/jsonUtils";
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import classnames from "classnames";
|
||||
import throttle from "lodash.throttle";
|
||||
import "react-dates/initialize";
|
||||
import "react-dates/lib/css/_datepicker.css";
|
||||
import { DateRangePicker, OrientationShape, toMomentObject } from "react-dates";
|
||||
import Switch from "@components/ui/Switch";
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
@ -59,7 +59,17 @@ type DateOverride = {
|
|||
endTime: number;
|
||||
};
|
||||
|
||||
type EventTypeInput = {
|
||||
type AdvancedOptions = {
|
||||
eventName?: string;
|
||||
periodType?: string;
|
||||
periodDays?: number;
|
||||
periodStartDate?: Date | string;
|
||||
periodEndDate?: Date | string;
|
||||
periodCountCalendarDays?: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
};
|
||||
|
||||
type EventTypeInput = AdvancedOptions & {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
|
@ -67,16 +77,9 @@ type EventTypeInput = {
|
|||
length: number;
|
||||
hidden: boolean;
|
||||
locations: unknown;
|
||||
eventName: string;
|
||||
customInputs: EventTypeCustomInput[];
|
||||
timeZone: string;
|
||||
availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
|
||||
periodType?: string;
|
||||
periodDays?: number;
|
||||
periodStartDate?: Date | string;
|
||||
periodEndDate?: Date | string;
|
||||
periodCountCalendarDays?: boolean;
|
||||
enteredRequiresConfirmation: boolean;
|
||||
};
|
||||
|
||||
const PERIOD_TYPES = [
|
||||
|
@ -102,7 +105,6 @@ export default function EventTypePage({
|
|||
}: Props): JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
console.log(eventType);
|
||||
const inputOptions: OptionBase[] = [
|
||||
{ value: EventTypeCustomInputType.Text, label: "Text" },
|
||||
{ value: EventTypeCustomInputType.TextLong, label: "Multiline Text" },
|
||||
|
@ -178,11 +180,11 @@ export default function EventTypePage({
|
|||
);
|
||||
});
|
||||
|
||||
const [hidden, setHidden] = useState<boolean>(eventType.hidden);
|
||||
const titleRef = useRef<HTMLInputElement>();
|
||||
const slugRef = useRef<HTMLInputElement>();
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>();
|
||||
const lengthRef = useRef<HTMLInputElement>();
|
||||
const isHiddenRef = useRef<HTMLInputElement>();
|
||||
const requiresConfirmationRef = useRef<HTMLInputElement>();
|
||||
const eventNameRef = useRef<HTMLInputElement>();
|
||||
const periodDaysRef = useRef<HTMLInputElement>();
|
||||
|
@ -199,26 +201,17 @@ export default function EventTypePage({
|
|||
const enteredSlug: string = slugRef.current.value;
|
||||
const enteredDescription: string = descriptionRef.current.value;
|
||||
const enteredLength: number = parseInt(lengthRef.current.value);
|
||||
const enteredIsHidden: boolean = isHiddenRef.current.checked;
|
||||
const enteredRequiresConfirmation: boolean = requiresConfirmationRef.current.checked;
|
||||
const enteredEventName: string = eventNameRef.current.value;
|
||||
|
||||
const type = periodType.type;
|
||||
const enteredPeriodDays = parseInt(periodDaysRef?.current?.value);
|
||||
const enteredPeriodDaysType = Boolean(parseInt(periodDaysTypeRef?.current.value));
|
||||
|
||||
const enteredPeriodStartDate = periodStartDate ? periodStartDate.toDate() : null;
|
||||
const enteredPeriodEndDate = periodEndDate ? periodEndDate.toDate() : null;
|
||||
|
||||
console.log("values", {
|
||||
type,
|
||||
periodDaysTypeRef,
|
||||
enteredPeriodDays,
|
||||
enteredPeriodDaysType,
|
||||
enteredPeriodStartDate,
|
||||
enteredPeriodEndDate,
|
||||
});
|
||||
// TODO: Add validation
|
||||
const advancedOptionsPayload: AdvancedOptions = {};
|
||||
if (requiresConfirmationRef.current) {
|
||||
advancedOptionsPayload.requiresConfirmation = requiresConfirmationRef.current.checked;
|
||||
advancedOptionsPayload.eventName = eventNameRef.current.value;
|
||||
advancedOptionsPayload.periodType = periodType.type;
|
||||
advancedOptionsPayload.periodDays = parseInt(periodDaysRef?.current?.value);
|
||||
advancedOptionsPayload.periodCountCalendarDays = Boolean(parseInt(periodDaysTypeRef?.current.value));
|
||||
advancedOptionsPayload.periodStartDate = periodStartDate ? periodStartDate.toDate() : null;
|
||||
advancedOptionsPayload.periodEndDate = periodEndDate ? periodEndDate.toDate() : null;
|
||||
}
|
||||
|
||||
const payload: EventTypeInput = {
|
||||
id: eventType.id,
|
||||
|
@ -226,23 +219,14 @@ export default function EventTypePage({
|
|||
slug: enteredSlug,
|
||||
description: enteredDescription,
|
||||
length: enteredLength,
|
||||
hidden: enteredIsHidden,
|
||||
hidden,
|
||||
locations,
|
||||
eventName: enteredEventName,
|
||||
customInputs,
|
||||
timeZone: selectedTimeZone,
|
||||
periodType: type,
|
||||
periodDays: enteredPeriodDays,
|
||||
periodStartDate: enteredPeriodStartDate,
|
||||
periodEndDate: enteredPeriodEndDate,
|
||||
periodCountCalendarDays: enteredPeriodDaysType,
|
||||
requiresConfirmation: enteredRequiresConfirmation,
|
||||
availability: enteredAvailability || null,
|
||||
...advancedOptionsPayload,
|
||||
};
|
||||
|
||||
if (enteredAvailability) {
|
||||
payload.availability = enteredAvailability;
|
||||
}
|
||||
|
||||
await fetch("/api/availability/eventtype", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
|
@ -251,7 +235,7 @@ export default function EventTypePage({
|
|||
},
|
||||
});
|
||||
|
||||
router.push("/availability");
|
||||
router.push("/event-types");
|
||||
}
|
||||
|
||||
async function deleteEventTypeHandler(event) {
|
||||
|
@ -265,7 +249,7 @@ export default function EventTypePage({
|
|||
},
|
||||
});
|
||||
|
||||
router.push("/availability");
|
||||
router.push("/event-types");
|
||||
}
|
||||
|
||||
const openLocationModal = (type: LocationType) => {
|
||||
|
@ -388,35 +372,28 @@ export default function EventTypePage({
|
|||
<title>{eventType.title} | Event Type | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Shell heading={"Event Type: " + eventType.title} subtitle={eventType.description}>
|
||||
<div className="block sm:flex">
|
||||
<div className="w-full sm:w-10/12 mr-2">
|
||||
<div className="bg-white rounded-sm border border-neutral-200 -mx-4 sm:mx-0 p-4 sm:p-8">
|
||||
<form onSubmit={updateEventTypeHandler} className="space-y-4">
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<label htmlFor="title" className="flex font-medium text-neutral-700 mt-1">
|
||||
<PencilIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
|
||||
Title
|
||||
</label>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Shell
|
||||
heading={
|
||||
<input
|
||||
ref={titleRef}
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
required
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-neutral-300 rounded-sm"
|
||||
className="pl-0 text-xl font-bold text-gray-900 cursor-pointer border-none focus:ring-0 bg-transparent focus:outline-none"
|
||||
placeholder="Quick Chat"
|
||||
defaultValue={eventType.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
subtitle={eventType.description}>
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<label htmlFor="slug" className="flex font-medium text-neutral-700 mt-1">
|
||||
<LinkIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
|
||||
<div className="w-full sm:w-10/12 mr-2">
|
||||
<div className="bg-white rounded-sm border border-neutral-200 -mx-4 sm:mx-0 p-4 sm:p-8">
|
||||
<form onSubmit={updateEventTypeHandler} className="space-y-4">
|
||||
<div className="block sm:flex items-center">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label htmlFor="slug" className="text-sm flex font-medium text-neutral-700 mt-0">
|
||||
<LinkIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
|
||||
URL
|
||||
</label>
|
||||
</div>
|
||||
|
@ -437,10 +414,41 @@ export default function EventTypePage({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<label htmlFor="location" className="flex font-medium text-neutral-700 mt-1">
|
||||
<LocationMarkerIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
|
||||
|
||||
<div className="block sm:flex items-center">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label htmlFor="length" className="text-sm flex font-medium text-neutral-700 mt-0">
|
||||
<ClockIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
|
||||
Duration
|
||||
</label>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="mt-1 relative rounded-sm shadow-sm">
|
||||
<input
|
||||
ref={lengthRef}
|
||||
type="number"
|
||||
name="length"
|
||||
id="length"
|
||||
required
|
||||
className="focus:ring-primary-500 focus:border-primary-500 block w-full pl-2 pr-12 sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="15"
|
||||
defaultValue={eventType.length}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm" id="duration">
|
||||
mins
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="block sm:flex items-center">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label htmlFor="location" className="text-sm flex font-medium text-neutral-700 mt-0">
|
||||
<LocationMarkerIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
|
||||
Location
|
||||
</label>
|
||||
</div>
|
||||
|
@ -468,19 +476,19 @@ export default function EventTypePage({
|
|||
className="mb-2 p-2 border border-neutral-300 rounded-sm shadow-sm">
|
||||
<div className="flex justify-between">
|
||||
{location.type === LocationType.InPerson && (
|
||||
<div className="flex-grow flex">
|
||||
<div className="flex-grow flex items-center">
|
||||
<LocationMarkerIcon className="h-6 w-6" />
|
||||
<span className="ml-2 text-sm">{location.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{location.type === LocationType.Phone && (
|
||||
<div className="flex-grow flex">
|
||||
<div className="flex-grow flex items-center">
|
||||
<PhoneIcon className="h-6 w-6" />
|
||||
<span className="ml-2 text-sm">Phone call</span>
|
||||
</div>
|
||||
)}
|
||||
{location.type === LocationType.GoogleMeet && (
|
||||
<div className="flex-grow flex">
|
||||
<div className="flex-grow flex items-center">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
viewBox="0 0 64 54"
|
||||
|
@ -511,7 +519,7 @@ export default function EventTypePage({
|
|||
</div>
|
||||
)}
|
||||
{location.type === LocationType.Zoom && (
|
||||
<div className="flex-grow flex">
|
||||
<div className="flex-grow flex items-center">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
viewBox="0 0 64 64"
|
||||
|
@ -555,10 +563,12 @@ export default function EventTypePage({
|
|||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="sm:flex sm:items-start text-sm text-primary-600"
|
||||
className="bg-neutral-100 rounded-sm py-2 px-3 flex"
|
||||
onClick={() => setShowLocationModal(true)}>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
<span className="font-medium">Add another location option</span>
|
||||
<PlusIcon className="h-4 w-4 mt-0.5 text-neutral-900" />
|
||||
<span className="ml-1 text-neutral-700 text-sm font-medium">
|
||||
Add another location
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
@ -566,37 +576,13 @@ export default function EventTypePage({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<label htmlFor="length" className="flex font-medium text-neutral-700 mt-1">
|
||||
<ClockIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
|
||||
Duration
|
||||
</label>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="mt-1 relative rounded-sm shadow-sm">
|
||||
<input
|
||||
ref={lengthRef}
|
||||
type="number"
|
||||
name="length"
|
||||
id="length"
|
||||
required
|
||||
className="focus:ring-primary-500 focus:border-primary-500 block w-full pl-2 pr-12 sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="15"
|
||||
defaultValue={eventType.length}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm" id="duration">
|
||||
mins
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<label htmlFor="description" className="flex font-medium text-neutral-700 mt-1">
|
||||
<DocumentIcon className="w-4 h-4 mr-2 mt-1 text-neutral-500" />
|
||||
|
||||
<hr className="border-neutral-200" />
|
||||
|
||||
<div className="block sm:flex items-center">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label htmlFor="description" className="text-sm flex font-medium text-neutral-700 mt-0">
|
||||
<DocumentIcon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
|
||||
Description
|
||||
</label>
|
||||
</div>
|
||||
|
@ -620,9 +606,11 @@ export default function EventTypePage({
|
|||
<span className="text-neutral-700 text-sm font-medium">Show advanced settings</span>
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel className="space-y-4">
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<label htmlFor="eventName" className="flex font-medium text-neutral-700 mt-2">
|
||||
<div className="block sm:flex items-center">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label
|
||||
htmlFor="eventName"
|
||||
className="text-sm flex font-medium text-neutral-700 mt-2">
|
||||
Event name
|
||||
</label>
|
||||
</div>
|
||||
|
@ -640,11 +628,11 @@ export default function EventTypePage({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<div className="block sm:flex items-center">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label
|
||||
htmlFor="additionalFields"
|
||||
className="flex font-medium text-neutral-700 mt-2">
|
||||
className="text-sm flex font-medium text-neutral-700 mt-2">
|
||||
Additional inputs
|
||||
</label>
|
||||
</div>
|
||||
|
@ -694,38 +682,11 @@ export default function EventTypePage({
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<label htmlFor="hidden" className="flex font-medium text-neutral-700">
|
||||
Hide event type
|
||||
</label>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
ref={isHiddenRef}
|
||||
id="ishidden"
|
||||
name="ishidden"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
|
||||
defaultChecked={eventType.hidden}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<p className="text-neutral-900">
|
||||
Hide the event type from your page, so it can only be booked through its
|
||||
URL.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<div className="block sm:flex items-center">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label
|
||||
htmlFor="requiresConfirmation"
|
||||
className="flex font-medium text-neutral-700">
|
||||
className="text-sm flex font-medium text-neutral-700">
|
||||
Opt-in booking
|
||||
</label>
|
||||
</div>
|
||||
|
@ -750,11 +711,14 @@ export default function EventTypePage({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-neutral-200" />
|
||||
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label
|
||||
htmlFor="inviteesCanSchedule"
|
||||
className="flex font-medium text-neutral-700 mt-2">
|
||||
className="text-sm flex font-medium text-neutral-700 mt-2">
|
||||
Invitees can schedule
|
||||
</label>
|
||||
</div>
|
||||
|
@ -849,9 +813,14 @@ export default function EventTypePage({
|
|||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-neutral-200" />
|
||||
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-32 mb-4 sm:mb-0">
|
||||
<label htmlFor="availability" className="flex font-medium text-neutral-700 mt-2">
|
||||
<div className="min-w-44 mb-4 sm:mb-0">
|
||||
<label
|
||||
htmlFor="availability"
|
||||
className="text-sm flex font-medium text-neutral-700 mt-2">
|
||||
Availability
|
||||
</label>
|
||||
</div>
|
||||
|
@ -885,6 +854,12 @@ export default function EventTypePage({
|
|||
</div>
|
||||
<div className="w-full sm:w-2/12 ml-2 px-4 mt-8 sm:mt-0 min-w-32">
|
||||
<div className="space-y-4">
|
||||
<Switch
|
||||
name="isHidden"
|
||||
defaultChecked={hidden}
|
||||
onCheckedChange={setHidden}
|
||||
label="Hide event type"
|
||||
/>
|
||||
<a
|
||||
href={"/" + user.username + "/" + eventType.slug}
|
||||
target="_blank"
|
||||
|
@ -904,13 +879,20 @@ export default function EventTypePage({
|
|||
<LinkIcon className="w-4 h-4 mt-1 mr-2 text-neutral-500" />
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteEventTypeHandler}
|
||||
type="button"
|
||||
className="flex text-md font-medium text-neutral-700">
|
||||
<Dialog>
|
||||
<DialogTrigger className="flex text-md font-medium text-neutral-700">
|
||||
<TrashIcon className="w-4 h-4 mt-1 mr-2 text-neutral-500" />
|
||||
Delete
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
alert="danger"
|
||||
title="Delete Event Type"
|
||||
confirmBtnText="Yes, delete event type"
|
||||
onConfirm={deleteEventTypeHandler}>
|
||||
Are you sure you want to delete this event type? Anyone who you've shared this link
|
||||
with will no longer be able to book using it.
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import prisma from "../../lib/prisma";
|
||||
import Shell from "../../components/Shell";
|
||||
import { useRouter } from "next/router";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import React, { Fragment, useRef } from "react";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import Loader from "@components/Loader";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
|
||||
import {
|
||||
ClockIcon,
|
||||
DotsHorizontalIcon,
|
||||
|
@ -16,9 +11,14 @@ import {
|
|||
PlusIcon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import Loader from "@components/Loader";
|
||||
import classNames from "@lib/classNames";
|
||||
import { Dialog, DialogContent, DialogTrigger, DialogClose } from "@components/Dialog";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { Fragment, useRef } from "react";
|
||||
import Shell from "../../components/Shell";
|
||||
import prisma from "../../lib/prisma";
|
||||
|
||||
export default function Availability({ user, types }) {
|
||||
const [session, loading] = useSession();
|
||||
|
@ -73,8 +73,8 @@ export default function Availability({ user, types }) {
|
|||
New event type
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
|
||||
Add a new event type
|
||||
</h3>
|
||||
<div>
|
||||
|
@ -153,7 +153,7 @@ export default function Availability({ user, types }) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<div className="mt-8 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Continue
|
||||
</button>
|
||||
|
@ -176,15 +176,15 @@ export default function Availability({ user, types }) {
|
|||
heading="Event Types"
|
||||
subtitle="Create events to share for people to book on your calendar."
|
||||
CTA={types.length !== 0 && <CreateNewEventDialog />}>
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-sm -mx-4 sm:mx-0">
|
||||
<div className="bg-white border border-gray-200 rounded-sm overflow-hidden -mx-4 sm:mx-0">
|
||||
<ul className="divide-y divide-neutral-200">
|
||||
{types.map((type) => (
|
||||
<li key={type.id}>
|
||||
<Link href={"/event-types/" + type.id}>
|
||||
<a className="block hover:bg-neutral-50">
|
||||
<div className="hover:bg-neutral-50">
|
||||
<div className="px-4 py-4 flex items-center sm:px-6">
|
||||
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="truncate">
|
||||
<Link href={"/event-types/" + type.id}>
|
||||
<a className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<span className="truncate ">
|
||||
<div className="flex text-sm">
|
||||
<p className="font-medium text-neutral-900 truncate">{type.title}</p>
|
||||
{type.hidden && (
|
||||
|
@ -196,52 +196,63 @@ export default function Availability({ user, types }) {
|
|||
<div className="mt-2 flex space-x-4">
|
||||
<div className="flex items-center text-sm text-neutral-500">
|
||||
<ClockIcon
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-neutral-400"
|
||||
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>{type.length}m</p>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-neutral-500">
|
||||
<UserIcon
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-neutral-400"
|
||||
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>1-on-1</p>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-neutral-500">
|
||||
<InformationCircleIcon
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-neutral-400"
|
||||
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>{type.description.substring(0, 100)}</p>
|
||||
<div className="max-w-32 sm:max-w-full truncate">
|
||||
{type.description.substring(0, 100)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
|
||||
<div className="flex overflow-hidden space-x-5">
|
||||
<Link href={"/" + session.user.username + "/" + type.slug}>
|
||||
<a className="text-neutral-400">
|
||||
<ExternalLinkIcon className="w-5 h-5" />
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="hidden sm:flex mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
|
||||
<div className="flex overflow-hidden space-x-5">
|
||||
<Tooltip content="Preview">
|
||||
<a
|
||||
href={"/" + session.user.username + "/" + type.slug}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group cursor-pointer text-neutral-400 p-2 border border-transparent hover:border-gray-200">
|
||||
<ExternalLinkIcon className="group-hover:text-black w-5 h-5" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Copy link">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
window.location.hostname + "/" + session.user.username + "/" + type.slug
|
||||
);
|
||||
}}
|
||||
className="text-neutral-400">
|
||||
<LinkIcon className="w-5 h-5" />
|
||||
className="group text-neutral-400 p-2 border border-transparent hover:border-gray-200">
|
||||
<LinkIcon className="group-hover:text-black w-5 h-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 flex-shrink-0">
|
||||
<div className="flex sm:hidden ml-5 flex-shrink-0">
|
||||
<Menu as="div" className="inline-block text-left">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="text-neutral-400 mt-1">
|
||||
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
|
||||
<span className="sr-only">Open options</span>
|
||||
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
|
@ -271,7 +282,7 @@ export default function Availability({ user, types }) {
|
|||
"group flex items-center px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<ExternalLinkIcon
|
||||
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
|
||||
className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Preview
|
||||
|
@ -295,7 +306,7 @@ export default function Availability({ user, types }) {
|
|||
"group flex items-center px-4 py-2 text-sm w-full font-medium"
|
||||
)}>
|
||||
<LinkIcon
|
||||
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
|
||||
className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Copy link to event
|
||||
|
@ -311,7 +322,7 @@ export default function Availability({ user, types }) {
|
|||
{/* "group flex items-center px-4 py-2 text-sm font-medium"*/}
|
||||
{/* )}>*/}
|
||||
{/* <DuplicateIcon*/}
|
||||
{/* className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"*/}
|
||||
{/* className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"*/}
|
||||
{/* aria-hidden="true"*/}
|
||||
{/* />*/}
|
||||
{/* Duplicate*/}
|
||||
|
@ -344,8 +355,7 @@ export default function Availability({ user, types }) {
|
|||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
@ -7,9 +7,7 @@ function RedirectPage() {
|
|||
router.push("/event-types");
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<Loader/>
|
||||
);
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
RedirectPage.getInitialProps = (ctx) => {
|
||||
|
|
|
@ -4,16 +4,18 @@ import { getIntegrationName, getIntegrationType } from "../../lib/integrations";
|
|||
import Shell from "../../components/Shell";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useSession, getSession } from "next-auth/client";
|
||||
import Loader from '@components/Loader';
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function integration(props) {
|
||||
export default function Integration(props) {
|
||||
const router = useRouter();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
|
||||
const [showAPIKey, setShowAPIKey] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return <Loader/>;
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
function toggleShowAPIKey() {
|
||||
|
@ -23,6 +25,7 @@ export default function integration(props) {
|
|||
async function deleteIntegrationHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
/*eslint-disable */
|
||||
const response = await fetch("/api/integrations", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.integration.id }),
|
||||
|
@ -30,6 +33,7 @@ export default function integration(props) {
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
/*eslint-enable */
|
||||
|
||||
router.push("/integrations");
|
||||
}
|
||||
|
@ -37,27 +41,27 @@ export default function integration(props) {
|
|||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{getIntegrationName(props.integration.type)} | Integrations | Calendso</title>
|
||||
<title>{getIntegrationName(props.integration.type)} App | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Shell heading={getIntegrationName(props.integration.type)} subtitle="Manage and delete integrations.">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 bg-white shadow overflow-hidden rounded-sm">
|
||||
<Shell heading={getIntegrationName(props.integration.type)} subtitle="Manage and delete this app.">
|
||||
<div className="block sm:grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 bg-white border border-gray-200 mb-6 overflow-hidden rounded-sm">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Integration Details</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Information about your {getIntegrationName(props.integration.type)} integration.
|
||||
Information about your {getIntegrationName(props.integration.type)} App.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<dl className="grid gap-y-8">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Integration name</dt>
|
||||
<dt className="text-sm font-medium text-gray-500">App name</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{getIntegrationName(props.integration.type)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Integration type</dt>
|
||||
<dt className="text-sm font-medium text-gray-500">App Category</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{getIntegrationType(props.integration.type)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -87,18 +91,18 @@ export default function integration(props) {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-white shadow rounded-sm">
|
||||
<div className="bg-white border border-gray-200 mb-6 rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete this integration</h3>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete this app</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>Once you delete this integration, it will be permanently removed.</p>
|
||||
<p>Once you delete this app, it will be permanently removed.</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<button
|
||||
onClick={deleteIntegrationHandler}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-sm text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm">
|
||||
Delete integration
|
||||
Delete App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -111,6 +115,7 @@ export default function integration(props) {
|
|||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const session = await getSession(context);
|
||||
|
||||
const integration = await prisma.credential.findFirst({
|
||||
|
|
|
@ -4,33 +4,17 @@ import prisma from "../../lib/prisma";
|
|||
import Shell from "../../components/Shell";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
PlusIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid";
|
||||
import { InformationCircleIcon } from "@heroicons/react/outline";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import Loader from '@components/Loader';
|
||||
import classNames from "@lib/classNames";
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
|
||||
import Switch from "@components/ui/Switch";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Home({ integrations }) {
|
||||
export default function IntegrationHome({ integrations }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showSelectCalendarModal, setShowSelectCalendarModal] = useState(false);
|
||||
const [selectableCalendars, setSelectableCalendars] = useState([]);
|
||||
|
||||
function toggleAddModal() {
|
||||
setShowAddModal(!showAddModal);
|
||||
}
|
||||
|
||||
function toggleShowCalendarModal() {
|
||||
setShowSelectCalendarModal(!showSelectCalendarModal);
|
||||
}
|
||||
|
||||
function loadCalendars() {
|
||||
fetch("api/availability/calendar")
|
||||
.then((response) => response.json())
|
||||
|
@ -47,17 +31,15 @@ export default function Home({ integrations }) {
|
|||
|
||||
function calendarSelectionHandler(calendar) {
|
||||
return (selected) => {
|
||||
const cals = [...selectableCalendars];
|
||||
const i = cals.findIndex((c) => c.externalId === calendar.externalId);
|
||||
cals[i].selected = selected;
|
||||
setSelectableCalendars(cals);
|
||||
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
|
||||
selectableCalendars[i].selected = selected;
|
||||
if (selected) {
|
||||
fetch("api/availability/calendar", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(cals[i]),
|
||||
body: JSON.stringify(selectableCalendars[i]),
|
||||
}).then((response) => response.json());
|
||||
} else {
|
||||
fetch("api/availability/calendar", {
|
||||
|
@ -65,7 +47,7 @@ export default function Home({ integrations }) {
|
|||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(cals[i]),
|
||||
body: JSON.stringify(selectableCalendars[i]),
|
||||
}).then((response) => response.json());
|
||||
}
|
||||
};
|
||||
|
@ -82,40 +64,117 @@ export default function Home({ integrations }) {
|
|||
}
|
||||
}
|
||||
|
||||
function onCloseSelectCalendar() {
|
||||
setSelectableCalendars([...selectableCalendars]);
|
||||
}
|
||||
|
||||
useEffect(loadCalendars, [integrations]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Loader/>
|
||||
);
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const ConnectNewAppDialog = () => (
|
||||
<Dialog>
|
||||
<DialogTrigger className="py-2 px-4 mt-6 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
<PlusIcon className="w-5 h-5 mr-1 inline" />
|
||||
Connect a new App
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader title="Connect a new App" subtitle="Connect a new app to your account." />
|
||||
<div className="my-4">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{integrations
|
||||
.filter((integration) => integration.installed)
|
||||
.map((integration) => (
|
||||
<li key={integration.type} className="flex py-4">
|
||||
<div className="w-1/12 mr-4 pt-2">
|
||||
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
|
||||
</div>
|
||||
<div className="w-10/12">
|
||||
<h2 className="text-gray-800 font-medium">{integration.title}</h2>
|
||||
<p className="text-gray-400 text-sm">{integration.description}</p>
|
||||
</div>
|
||||
<div className="w-2/12 text-right pt-2">
|
||||
<button
|
||||
onClick={() => integrationHandler(integration.type)}
|
||||
className="font-medium text-neutral-900 hover:text-neutral-500">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogClose as="button" className="btn btn-white mx-2">
|
||||
Cancel
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const SelectCalendarDialog = () => (
|
||||
<Dialog onOpenChange={(open) => !open && onCloseSelectCalendar()}>
|
||||
<DialogTrigger className="py-2 px-4 mt-6 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
Select calendars
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Select calendars"
|
||||
subtitle="If no entry is selected, all calendars will be checked"
|
||||
/>
|
||||
<div className="my-4">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{selectableCalendars.map((calendar) => (
|
||||
<li key={calendar.name} className="flex py-4">
|
||||
<div className="w-1/12 mr-4 pt-2">
|
||||
<img
|
||||
className="h-8 w-8 mr-2"
|
||||
src={getCalendarIntegrationImage(calendar.integration)}
|
||||
alt={calendar.integration}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-10/12 pt-3">
|
||||
<h2 className="text-gray-800 font-medium">{calendar.name}</h2>
|
||||
</div>
|
||||
<div className="w-2/12 text-right pt-3">
|
||||
<Switch
|
||||
defaultChecked={calendar.selected}
|
||||
onCheckedChange={calendarSelectionHandler(calendar)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogClose as="button" className="btn btn-white mx-2">
|
||||
Cancel
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Integrations | Calendso</title>
|
||||
<title>App Store | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Shell
|
||||
heading="Integrations"
|
||||
subtitle="Connect your favourite apps."
|
||||
CTA={
|
||||
<button
|
||||
onClick={toggleAddModal}
|
||||
type="button"
|
||||
className="flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
<PlusIcon className="w-5 h-5 mr-1" />
|
||||
Add new integration
|
||||
</button>
|
||||
}>
|
||||
<div className="bg-white shadow overflow-hidden rounded-sm mb-8">
|
||||
<Shell heading="App Store" subtitle="Connect your favourite apps." CTA={<ConnectNewAppDialog />}>
|
||||
<div className="bg-white border border-gray-200 overflow-hidden rounded-sm mb-8">
|
||||
{integrations.filter((ig) => ig.credential).length !== 0 ? (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{integrations
|
||||
.filter((ig) => ig.credential)
|
||||
.map((ig) => (
|
||||
<li key={ig.id}>
|
||||
<li key={ig.credential.id}>
|
||||
<Link href={"/integrations/" + ig.credential.id}>
|
||||
<a className="block hover:bg-gray-50">
|
||||
<div className="flex items-center px-4 py-4 sm:px-6">
|
||||
|
@ -168,224 +227,41 @@ export default function Home({ integrations }) {
|
|||
</div>
|
||||
<div className="py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
You don't have any integrations added.
|
||||
You don't have any apps connected.
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
You currently do not have any integrations set up. Add your first integration to get
|
||||
started.
|
||||
You currently do not have any apps connected. Connect your first app to get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 text-sm">
|
||||
<button
|
||||
onClick={toggleAddModal}
|
||||
className="font-medium text-neutral-900 hover:text-neutral-500">
|
||||
{" "}
|
||||
Add your first integration <span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
<ConnectNewAppDialog />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* <!--
|
||||
Background overlay, show/hide based on modal state.
|
||||
|
||||
Entering: "ease-out duration-300"
|
||||
From: "opacity-0"
|
||||
To: "opacity-100"
|
||||
Leaving: "ease-in duration-200"
|
||||
From: "opacity-100"
|
||||
To: "opacity-0"
|
||||
--> */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
{/* <!--
|
||||
Modal panel, show/hide based on modal state.
|
||||
|
||||
Entering: "ease-out duration-300"
|
||||
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
To: "opacity-100 translate-y-0 sm:scale-100"
|
||||
Leaving: "ease-in duration-200"
|
||||
From: "opacity-100 translate-y-0 sm:scale-100"
|
||||
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
--> */}
|
||||
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<PlusIcon className="h-6 w-6 text-neutral-900" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
Add a new integration
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Link a new integration to your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-4">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{integrations
|
||||
.filter((integration) => integration.installed)
|
||||
.map((integration) => (
|
||||
<li key={integration.type} className="flex py-4">
|
||||
<div className="w-1/12 mr-4 pt-2">
|
||||
<img
|
||||
className="h-8 w-8 mr-2"
|
||||
src={integration.imageSrc}
|
||||
alt={integration.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-10/12">
|
||||
<h2 className="text-gray-800 font-medium">{integration.title}</h2>
|
||||
<p className="text-gray-400 text-sm">{integration.description}</p>
|
||||
</div>
|
||||
<div className="w-2/12 text-right pt-2">
|
||||
<button
|
||||
onClick={() => integrationHandler(integration.type)}
|
||||
className="font-medium text-neutral-900 hover:text-neutral-500">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
onClick={toggleAddModal}
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-sm border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:mt-0 sm:w-auto sm:text-sm">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white shadow rounded-sm">
|
||||
<div className="bg-white border border-gray-200 rounded-sm mb-8">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Select calendars</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>Select which calendars are checked for availability to prevent double bookings.</p>
|
||||
</div>
|
||||
<SelectCalendarDialog />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Launch your own App</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>If you want to add your own App here, get in touch with us.</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<button type="button" onClick={toggleShowCalendarModal} className="btn btn-primary">
|
||||
Select calendars
|
||||
</button>
|
||||
<a href="mailto:apps@calendso.com" className="btn btn-white">
|
||||
Contact us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showSelectCalendarModal && (
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* <!--
|
||||
Background overlay, show/hide based on modal state.
|
||||
|
||||
Entering: "ease-out duration-300"
|
||||
From: "opacity-0"
|
||||
To: "opacity-100"
|
||||
Leaving: "ease-in duration-200"
|
||||
From: "opacity-100"
|
||||
To: "opacity-0"
|
||||
--> */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
{/* <!--
|
||||
Modal panel, show/hide based on modal state.
|
||||
|
||||
Entering: "ease-out duration-300"
|
||||
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
To: "opacity-100 translate-y-0 sm:scale-100"
|
||||
Leaving: "ease-in duration-200"
|
||||
From: "opacity-100 translate-y-0 sm:scale-100"
|
||||
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
--> */}
|
||||
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<CalendarIcon className="h-6 w-6 text-neutral-900" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
Select calendars
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
If no entry is selected, all calendars will be checked
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-4">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{selectableCalendars.map((calendar) => (
|
||||
<li key={calendar.name} className="flex py-4">
|
||||
<div className="w-1/12 mr-4 pt-2">
|
||||
<img
|
||||
className="h-8 w-8 mr-2"
|
||||
src={getCalendarIntegrationImage(calendar.integration)}
|
||||
alt={calendar.integration}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-10/12 pt-3">
|
||||
<h2 className="text-gray-800 font-medium">{calendar.name}</h2>
|
||||
</div>
|
||||
<div className="w-2/12 text-right pt-3">
|
||||
<Switch
|
||||
checked={calendar.selected}
|
||||
onChange={calendarSelectionHandler(calendar)}
|
||||
className={classNames(
|
||||
calendar.selected ? "bg-neutral-900" : "bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500"
|
||||
)}>
|
||||
<span className="sr-only">Select calendar</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
calendar.selected ? "translate-x-5" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
onClick={toggleShowCalendarModal}
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-sm border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:mt-0 sm:w-auto sm:text-sm">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,18 +1,16 @@
|
|||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import prisma from "../../lib/prisma";
|
||||
import Shell from "../../components/Shell";
|
||||
import SettingsShell from "../../components/Settings";
|
||||
import { useSession, getSession } from "next-auth/client";
|
||||
import Loader from '@components/Loader';
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Embed(props) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
|
||||
if (loading) {
|
||||
return <Loader/>;
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -4,7 +4,7 @@ import prisma from "../../lib/prisma";
|
|||
import Modal from "../../components/Modal";
|
||||
import Shell from "../../components/Shell";
|
||||
import SettingsShell from "../../components/Settings";
|
||||
import { useSession, getSession } from "next-auth/client";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Settings() {
|
||||
|
|
|
@ -8,6 +8,7 @@ import { getSession, useSession } from "next-auth/client";
|
|||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import TeamList from "../../components/team/TeamList";
|
||||
import TeamListItem from "../../components/team/TeamListItem";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Teams() {
|
||||
const [, loading] = useSession();
|
||||
|
@ -38,7 +39,7 @@ export default function Teams() {
|
|||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const createTeam = (e) => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import prisma, { whereAndSelect } from "../lib/prisma";
|
|||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
import { CalendarIcon, ClockIcon, LocationMarkerIcon } from "@heroicons/react/solid";
|
||||
import { ClockIcon } from "@heroicons/react/solid";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
|
@ -60,7 +60,7 @@ export default function Success(props) {
|
|||
|
||||
return (
|
||||
isReady && (
|
||||
<div>
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900 h-screen">
|
||||
<Head>
|
||||
<title>
|
||||
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"} | {eventName} |
|
||||
|
@ -68,7 +68,7 @@ export default function Success(props) {
|
|||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className="max-w-3xl mx-auto my-24">
|
||||
<main className="max-w-3xl mx-auto py-24">
|
||||
<div className="fixed z-50 inset-0 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
||||
|
@ -76,61 +76,61 @@ export default function Success(props) {
|
|||
​
|
||||
</span>
|
||||
<div
|
||||
className="inline-block align-bottom dark:bg-gray-800 bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
|
||||
className="inline-block align-bottom dark:bg-gray-800 bg-white rounded-sm px-8 pt-5 pb-4 text-left overflow-hidden border border-neutral-200 dark:border-neutral-700 transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:py-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||
{!props.eventType.requiresConfirmation && (
|
||||
<CheckIcon className="h-6 w-6 text-green-600" />
|
||||
<CheckIcon className="h-8 w-8 text-green-600" />
|
||||
)}
|
||||
{props.eventType.requiresConfirmation && (
|
||||
<ClockIcon className="h-6 w-6 text-green-600" />
|
||||
<ClockIcon className="h-8 w-8 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3
|
||||
className="text-lg leading-6 font-medium dark:text-white text-gray-900"
|
||||
className="text-2xl leading-6 font-semibold dark:text-white text-neutral-900"
|
||||
id="modal-headline">
|
||||
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"}
|
||||
{props.eventType.requiresConfirmation ? "Submitted" : "This meeting is scheduled"}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{props.eventType.requiresConfirmation
|
||||
? `${
|
||||
props.user.name || props.user.username
|
||||
} still needs to confirm or reject the booking.`
|
||||
: `You are scheduled in with ${props.user.name || props.user.username}.`}
|
||||
: `We emailed you and the other attendees a calendar invitation with all the details.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-b dark:border-gray-900 py-4">
|
||||
<h2 className="text-lg font-medium text-gray-600 dark:text-gray-100 mb-2">
|
||||
{eventName}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-50 mb-1">
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{props.eventType.length} minutes
|
||||
</p>
|
||||
<div className="mt-4 text-gray-700 dark:text-gray-300 border-t border-b dark:border-gray-900 py-4 grid grid-cols-3 text-left">
|
||||
<div className="font-medium">What</div>
|
||||
<div className="mb-6 col-span-2">{eventName}</div>
|
||||
<div className="font-medium">When</div>
|
||||
<div className="mb-6 col-span-2">
|
||||
{date.format("dddd, DD MMMM YYYY")}
|
||||
<br />
|
||||
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
|
||||
<span className="text-gray-500">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</div>
|
||||
{location && (
|
||||
<p className="text-gray-500 mb-1">
|
||||
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{location}
|
||||
</p>
|
||||
<>
|
||||
<div className="font-medium">Where</div>
|
||||
<div className="col-span-2">{location}</div>
|
||||
</>
|
||||
)}
|
||||
<p className="text-gray-500 dark:text-gray-50">
|
||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{date.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!props.eventType.requiresConfirmation && (
|
||||
<div className="mt-5 sm:mt-0 pt-2 text-center">
|
||||
<span className="font-medium text-gray-500 dark:text-gray-50">
|
||||
Add to your calendar
|
||||
<div className="mt-5 sm:mt-0 sm:pt-4 pt-2 text-center flex">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-50 flex self-center mr-6">
|
||||
Add to calendar
|
||||
</span>
|
||||
<div className="flex mt-2">
|
||||
<div className="flex">
|
||||
<Link
|
||||
href={
|
||||
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||
|
@ -142,9 +142,9 @@ export default function Success(props) {
|
|||
props.eventType.description
|
||||
}` + (location ? "&location=" + encodeURIComponent(location) : "")
|
||||
}>
|
||||
<a className="mx-2 btn-wide btn-white">
|
||||
<a className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3">
|
||||
<svg
|
||||
className="inline-block w-4 h-4 mr-1 -mt-1"
|
||||
className="inline-block w-4 h-4 -mt-1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
|
@ -166,7 +166,9 @@ export default function Success(props) {
|
|||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}>
|
||||
<a className="mx-2 btn-wide btn-white" target="_blank">
|
||||
<a
|
||||
className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="inline-block w-4 h-4 mr-1 -mt-1"
|
||||
fill="currentColor"
|
||||
|
@ -190,7 +192,9 @@ export default function Success(props) {
|
|||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}>
|
||||
<a className="mx-2 btn-wide btn-white" target="_blank">
|
||||
<a
|
||||
className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="inline-block w-4 h-4 mr-1 -mt-1"
|
||||
fill="currentColor"
|
||||
|
@ -202,7 +206,9 @@ export default function Success(props) {
|
|||
</a>
|
||||
</Link>
|
||||
<Link href={"data:text/calendar," + eventLink()}>
|
||||
<a className="mx-2 btn-wide btn-white" download={props.eventType.title + ".ics"}>
|
||||
<a
|
||||
className="mx-2 rounded-sm border border-neutral-200 dark:border-neutral-700 py-2 px-3"
|
||||
download={props.eventType.title + ".ics"}>
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "location" TEXT;
|
|
@ -133,6 +133,7 @@ model Booking {
|
|||
endTime DateTime
|
||||
|
||||
attendees Attendee[]
|
||||
location String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime?
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;}
|
||||
|
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;}
|
||||
|
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
@ -1,9 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#104D86;}
|
||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;}
|
||||
</style>
|
||||
<path class="st0" d="M27.5,88.2c-4.9,0-9.7-1.2-14-3.6c-4.2-2.4-7.6-5.8-9.9-10c-4.8-8.8-4.8-19.4,0-28.2c2.3-4.2,5.8-7.7,10-10
|
||||
c4.3-2.4,9.1-3.7,14-3.6c6-0.1,11.8,1.7,16.5,5.3s8,8.7,9.9,15.4H42.8c-1.3-3-3.4-5.5-6.2-7.2c-2.6-1.6-5.6-2.5-8.7-2.5
|
||||
|
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#F68D2E;}
|
||||
|
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -10,8 +10,8 @@
|
|||
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
|
||||
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
|
||||
]>
|
||||
<svg version="1.1" id="Livello_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2228.833 2073.333"
|
||||
<svg version="1.1" id="Livello_1"
|
||||
xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 2228.833 2073.333"
|
||||
enable-background="new 0 0 2228.833 2073.333" xml:space="preserve">
|
||||
<metadata>
|
||||
<sfw xmlns="&ns_sfw;">
|
||||
|
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
@ -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 */
|
||||
.react-select__value-container{
|
||||
border: 0 !important;
|
||||
|
@ -140,7 +236,8 @@
|
|||
height: 30px;
|
||||
margin: 60px auto;
|
||||
position: relative;
|
||||
border: 4px solid #000;
|
||||
border-width: 4px;
|
||||
border-style: solid;
|
||||
animation: loader 2s infinite ease;
|
||||
}
|
||||
|
||||
|
@ -148,7 +245,6 @@
|
|||
vertical-align: top;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
background-color: #000;
|
||||
animation: loader-inner 2s infinite ease-in;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,19 @@ module.exports = {
|
|||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
black: "#111111",
|
||||
gray: {
|
||||
50: "#F8F8F8",
|
||||
100: "#F5F5F5",
|
||||
200: "#E1E1E1",
|
||||
300: "#CFCFCF",
|
||||
400: "#ACACAC",
|
||||
500: "#888888",
|
||||
600: "#494949",
|
||||
700: "#3E3E3E",
|
||||
800: "#313131",
|
||||
900: "#292929",
|
||||
},
|
||||
neutral: {
|
||||
50: "#F7F8F9",
|
||||
100: "#F4F5F6",
|
||||
|
|
162
yarn.lock
|
@ -753,6 +753,14 @@
|
|||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.26.0-23.9b816b3aa13cc270074f172f30d6eda8a8ce867d.tgz#cfdacfad3acc0f3bf1d7710aa8f3852fd85ac6d9"
|
||||
integrity sha512-a0jIhLvw9rFh6nZTr5Y3uzP28I2xNDu3pqxANvwMNnmIoYr1wYEcO1pMXn/36BGXldDdAWMmAbhfloHA3IB8DA==
|
||||
|
||||
"@radix-ui/popper@0.0.10":
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/popper/-/popper-0.0.10.tgz#9f707d9cec8762423f81acaf8e650e40a554cb73"
|
||||
integrity sha512-YFKuPqQPKscreQid7NuB4it3PMzSwGg03vgrud6sVliHkI43QNAOHyrHyMNo015jg6QK5GVDn+7J2W5uygqSGA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
csstype "^3.0.4"
|
||||
|
||||
"@radix-ui/primitive@0.0.5":
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-0.0.5.tgz#8464fb4db04401bde72d36e27e05714080668d40"
|
||||
|
@ -760,6 +768,15 @@
|
|||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-arrow@0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-0.0.15.tgz#2fb7e4cab626f87d4f7a403672c57bce74b0a7b4"
|
||||
integrity sha512-lw3/3nPmEeK67IgndT764w/65EMm5psXnr2efCeo0eWOERTnFAswNka2bKJUSKY02FHECkH4qVzhwupFyeYv0g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-polymorphic" "0.0.13"
|
||||
"@radix-ui/react-primitive" "0.0.15"
|
||||
|
||||
"@radix-ui/react-collapsible@^0.0.16":
|
||||
version "0.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-0.0.16.tgz#6a99068f70bb85a60f8cbd43f093bd3053ab61cc"
|
||||
|
@ -850,11 +867,43 @@
|
|||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-label@0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-label/-/react-label-0.0.15.tgz#ab70d7cd93d6ebaf2e1007cca70e9b1858bcb932"
|
||||
integrity sha512-p1nM6z2rLkstfHVsqSxcDMn0eAGXkx/G5e4XIGmOCxYa/7EkOQ+lBz0+/7sk+Ut+8B37h7d0bfxnzr3ILVxJUw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "0.0.5"
|
||||
"@radix-ui/react-id" "0.0.6"
|
||||
"@radix-ui/react-polymorphic" "0.0.13"
|
||||
"@radix-ui/react-primitive" "0.0.15"
|
||||
|
||||
"@radix-ui/react-polymorphic@0.0.12":
|
||||
version "0.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-polymorphic/-/react-polymorphic-0.0.12.tgz#bf4ae516669b68e059549538104d97322f7c876b"
|
||||
integrity sha512-/GYNMicBnGzjD1d2fCAuzql1VeFrp8mqM3xfzT1kxhnV85TKdURO45jBfMgqo17XNXoNhWIAProUsCO4qFAAIg==
|
||||
|
||||
"@radix-ui/react-polymorphic@0.0.13":
|
||||
version "0.0.13"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-polymorphic/-/react-polymorphic-0.0.13.tgz#d010d48281626191c9513f11db5d82b37662418a"
|
||||
integrity sha512-0sGqBp+v9/yrsMhPfAejxcem2MwAFgaSAxF3Sieaklm6ZVYM/hTZxxWI5NVOLGV+482GwW0wIqwpVUzREjmh+w==
|
||||
|
||||
"@radix-ui/react-popper@0.0.18":
|
||||
version "0.0.18"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-0.0.18.tgz#e85ec077c18ffca92ce97cc19586dcc6f022fffb"
|
||||
integrity sha512-j8nPqX5scAmeGuyW9VQv+M4MkKsV/ulR1Yt0eu13LyGLT3L7FM2YBMt3KlbpUxrT3mrNGC0eEQAiVgm/G3/fGQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/popper" "0.0.10"
|
||||
"@radix-ui/react-arrow" "0.0.15"
|
||||
"@radix-ui/react-compose-refs" "0.0.5"
|
||||
"@radix-ui/react-context" "0.0.5"
|
||||
"@radix-ui/react-polymorphic" "0.0.13"
|
||||
"@radix-ui/react-primitive" "0.0.15"
|
||||
"@radix-ui/react-use-rect" "0.0.7"
|
||||
"@radix-ui/react-use-size" "0.0.6"
|
||||
"@radix-ui/rect" "0.0.5"
|
||||
|
||||
"@radix-ui/react-portal@0.0.14":
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-0.0.14.tgz#31513d8777cf5e50a3a30ebc9deb34821e890e9e"
|
||||
|
@ -865,6 +914,16 @@
|
|||
"@radix-ui/react-primitive" "0.0.14"
|
||||
"@radix-ui/react-use-layout-effect" "0.0.5"
|
||||
|
||||
"@radix-ui/react-portal@0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-0.0.15.tgz#833bccb192aafb9420bd037d5827e88caf429dc4"
|
||||
integrity sha512-qMESsdqph1gbRGzy9oSzUoeZYXnR2egXVcEZDqmesfn8w/o1rC1wadKkyBf7qo/YyjUX4mvXknAA+ftp1aQp+w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-polymorphic" "0.0.13"
|
||||
"@radix-ui/react-primitive" "0.0.15"
|
||||
"@radix-ui/react-use-layout-effect" "0.0.5"
|
||||
|
||||
"@radix-ui/react-presence@0.0.14":
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-0.0.14.tgz#6a86058bbbf46234dd8840dacd620b3ac5797025"
|
||||
|
@ -873,6 +932,15 @@
|
|||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "0.0.5"
|
||||
|
||||
"@radix-ui/react-presence@0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-0.0.15.tgz#4ff12feb436f1499148feb11c3a63a5d8fab568a"
|
||||
integrity sha512-+5+ePKUdTkqN1ze7nYmcoeHSsmKCcREwt0NhvNgDocPaqEUoZSkK9Mq6eMiMXSj02NkXH9P+bK32VCClYFnMBQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "0.0.5"
|
||||
"@radix-ui/react-use-layout-effect" "0.0.5"
|
||||
|
||||
"@radix-ui/react-primitive@0.0.14":
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-0.0.14.tgz#752a967cb05d4c5643634fe20274e7dc905d1cce"
|
||||
|
@ -881,7 +949,15 @@
|
|||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-polymorphic" "0.0.12"
|
||||
|
||||
"@radix-ui/react-slot@0.0.12":
|
||||
"@radix-ui/react-primitive@0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-0.0.15.tgz#c0cf609ee565a32969d20943e2697b42a04fbdf3"
|
||||
integrity sha512-Y7JLnen/G3AT0cQXXkBo3A1OuWaKGerkd2gKs0Fuqxv+kTxEmYoqSp/soo0Mm3Ccw61LKLQAjPiE37GK9/Zqwg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-polymorphic" "0.0.13"
|
||||
|
||||
"@radix-ui/react-slot@0.0.12", "@radix-ui/react-slot@^0.0.12":
|
||||
version "0.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-0.0.12.tgz#c4d8a75fffca561aeeca2ed9603384d86757f60a"
|
||||
integrity sha512-Em8P/xYyh3O/32IhrmARJNH+J/XCAVnw6h2zGu6oeReliIX7ktU67pMSeyyIZiU2hNXzaXYB/xDdixizQe/DGA==
|
||||
|
@ -889,6 +965,45 @@
|
|||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "0.0.5"
|
||||
|
||||
"@radix-ui/react-switch@^0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-0.0.15.tgz#675e0abd509ac211f6c9193fab786f17bd335de3"
|
||||
integrity sha512-2f2fhxvZSb21N+Va1lV4wvyY+zgPkJoKZOiK3rEH9zAmkyQ1nIDeI6eKwipeRO9WcGMeftOZBgVQTZhWSK0Rag==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "0.0.5"
|
||||
"@radix-ui/react-compose-refs" "0.0.5"
|
||||
"@radix-ui/react-context" "0.0.5"
|
||||
"@radix-ui/react-label" "0.0.15"
|
||||
"@radix-ui/react-polymorphic" "0.0.13"
|
||||
"@radix-ui/react-primitive" "0.0.15"
|
||||
"@radix-ui/react-use-controllable-state" "0.0.6"
|
||||
"@radix-ui/react-use-previous" "0.0.5"
|
||||
"@radix-ui/react-use-size" "0.0.6"
|
||||
|
||||
"@radix-ui/react-tooltip@^0.0.21":
|
||||
version "0.0.21"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-0.0.21.tgz#86160645cf0441fa7f465c8aaa265887cc3ff9b4"
|
||||
integrity sha512-+QLMXclfX0XM3inY5LEAvmKsomQ+S0cqzo1v/oS8CiIcawg01RDLV9mzjDYLnpE4eKokn30d+gk4r1YAtWIbZA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "0.0.5"
|
||||
"@radix-ui/react-compose-refs" "0.0.5"
|
||||
"@radix-ui/react-context" "0.0.5"
|
||||
"@radix-ui/react-id" "0.0.6"
|
||||
"@radix-ui/react-polymorphic" "0.0.13"
|
||||
"@radix-ui/react-popper" "0.0.18"
|
||||
"@radix-ui/react-portal" "0.0.15"
|
||||
"@radix-ui/react-presence" "0.0.15"
|
||||
"@radix-ui/react-primitive" "0.0.15"
|
||||
"@radix-ui/react-slot" "0.0.12"
|
||||
"@radix-ui/react-use-controllable-state" "0.0.6"
|
||||
"@radix-ui/react-use-escape-keydown" "0.0.6"
|
||||
"@radix-ui/react-use-layout-effect" "0.0.5"
|
||||
"@radix-ui/react-use-previous" "0.0.5"
|
||||
"@radix-ui/react-use-rect" "0.0.7"
|
||||
"@radix-ui/react-visually-hidden" "0.0.15"
|
||||
|
||||
"@radix-ui/react-use-body-pointer-events@0.0.6":
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.0.6.tgz#30b21301880417e7dbb345871ff5a83f2abe0d8d"
|
||||
|
@ -927,6 +1042,44 @@
|
|||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-use-previous@0.0.5":
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-0.0.5.tgz#75191d1fa0ac24c560fe8cfbaa2f1174858cbb2f"
|
||||
integrity sha512-GjtJlWlDAEMqCm2RDnVdWI6tk4/ZQfRq/VlP05Xy5rFZj6lD37VZWVWUELMBasRPzd2AS/9wPmphOgjH0VnE5A==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-use-rect@0.0.7":
|
||||
version "0.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-0.0.7.tgz#e3a55fa7183ef436042198787bf38f8c9befcc14"
|
||||
integrity sha512-OmaeFTgyiGNAchaxzDu+kFLz4Ly8RUcT5nwfoz4Nddd86I8Zdq93iNFnOpVLoVYqBnFEmvR6zexHXNFATrMbbQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/rect" "0.0.5"
|
||||
|
||||
"@radix-ui/react-use-size@0.0.6":
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-0.0.6.tgz#998eaf6e8871b868f81f3b7faac06c3e896c37a0"
|
||||
integrity sha512-kP4RIb2I5oHQzwzXJ21Hu8htNqf+sdaRzywxQpbj+hmqeUhpvIkhoq+ShNWV9wE/3c1T7gPnka8/nKYsKaKdCg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-visually-hidden@0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-0.0.15.tgz#7bd18af3fb5da1349f9b04006d22c3d6e9ce0453"
|
||||
integrity sha512-8J13Nzu9MfT2z+mDTGRfBukPi5L9LXLV7w1HvNZPVqxGLK8p7/CoXnt8XdS1HKSFm6akZmWJXMZVNVBUsONOcA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-polymorphic" "0.0.13"
|
||||
"@radix-ui/react-primitive" "0.0.15"
|
||||
|
||||
"@radix-ui/rect@0.0.5":
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-0.0.5.tgz#6000d8d800288114af4bbc5863e6b58755d7d978"
|
||||
integrity sha512-gXw171KbjyttA7K1DRIvPguLmKsg8raitB67MIcsdZwcquy+a1O2w3xY21NIKEqGhJwqJkECPUmMJDXgMNYuAg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@sinonjs/commons@^1.7.0":
|
||||
version "1.8.3"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
|
||||
|
@ -2187,7 +2340,7 @@ cssstyle@^2.3.0:
|
|||
dependencies:
|
||||
cssom "~0.3.6"
|
||||
|
||||
csstype@^3.0.2:
|
||||
csstype@^3.0.2, csstype@^3.0.4:
|
||||
version "3.0.8"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340"
|
||||
integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==
|
||||
|
@ -5409,6 +5562,11 @@ react-moment-proptypes@^1.6.0:
|
|||
dependencies:
|
||||
moment ">=1.6.0"
|
||||
|
||||
react-multi-email@^0.5.3:
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/react-multi-email/-/react-multi-email-0.5.3.tgz#734a0d4d1af23feef5cb5e635bde23963b0a9e8b"
|
||||
integrity sha512-1AneeJlAwjvzkPV740q2SXes/kW3HKOzR3gs+U7whrHN5nz+yH5Unosf/rvz8kRj/eFwBf6fTzMqlJiupu7S5Q==
|
||||
|
||||
react-outside-click-handler@^1.2.4:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/react-outside-click-handler/-/react-outside-click-handler-1.3.0.tgz#3831d541ac059deecd38ec5423f81e80ad60e115"
|
||||
|
|