Merged with main, fixed issue that caused multiple AvailableTimes refreshes.
commit
7030851efb
|
@ -1,152 +1,273 @@
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {signOut, useSession} from 'next-auth/client';
|
import { signOut, useSession } from "next-auth/client";
|
||||||
import {MenuIcon, XIcon} from '@heroicons/react/outline';
|
import { MenuIcon, XIcon } from "@heroicons/react/outline";
|
||||||
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib/telemetry";
|
||||||
|
|
||||||
export default function Shell(props) {
|
export default function Shell(props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [ session, loading ] = useSession();
|
const [session, loading] = useSession();
|
||||||
const [ profileDropdownExpanded, setProfileDropdownExpanded ] = useState(false);
|
const [profileDropdownExpanded, setProfileDropdownExpanded] = useState(false);
|
||||||
const [ mobileMenuExpanded, setMobileMenuExpanded ] = useState(false);
|
const [mobileMenuExpanded, setMobileMenuExpanded] = useState(false);
|
||||||
let telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
telemetry.withJitsu((jitsu) => {
|
telemetry.withJitsu((jitsu) => {
|
||||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname))
|
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname));
|
||||||
});
|
});
|
||||||
}, [telemetry])
|
}, [telemetry]);
|
||||||
|
|
||||||
const toggleProfileDropdown = () => {
|
const toggleProfileDropdown = () => {
|
||||||
setProfileDropdownExpanded(!profileDropdownExpanded);
|
setProfileDropdownExpanded(!profileDropdownExpanded);
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleMobileMenu = () => {
|
const toggleMobileMenu = () => {
|
||||||
setMobileMenuExpanded(!mobileMenuExpanded);
|
setMobileMenuExpanded(!mobileMenuExpanded);
|
||||||
}
|
};
|
||||||
|
|
||||||
const logoutHandler = () => {
|
const logoutHandler = () => {
|
||||||
signOut({ redirect: false }).then( () => router.push('/auth/logout') );
|
signOut({ redirect: false }).then(() => router.push("/auth/logout"));
|
||||||
}
|
};
|
||||||
|
|
||||||
if ( ! loading && ! session ) {
|
if (!loading && !session) {
|
||||||
router.replace('/auth/login');
|
router.replace("/auth/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
return session && (
|
return session ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-gradient-to-b from-blue-600 via-blue-600 to-blue-300 pb-32">
|
<div className="bg-gradient-to-b from-blue-600 via-blue-600 to-blue-300 pb-32">
|
||||||
<nav className="bg-blue-600">
|
<nav className="bg-blue-600">
|
||||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
<div className="border-b border-blue-500">
|
<div className="border-b border-blue-500">
|
||||||
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
|
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<img className="h-6" src="/calendso-white.svg" alt="Calendso" />
|
<img className="h-6" src="/calendso-white.svg" alt="Calendso" />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<div className="ml-10 flex items-baseline space-x-4">
|
<div className="ml-10 flex items-baseline space-x-4">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<a className={router.pathname == "/" ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Dashboard</a>
|
<a
|
||||||
</Link>
|
className={
|
||||||
{/* <Link href="/">
|
router.pathname == "/"
|
||||||
<a className={router.pathname.startsWith("/bookings") ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Bookings</a>
|
? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
</Link> */}
|
: "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
<Link href="/availability">
|
}>
|
||||||
<a className={router.pathname.startsWith("/availability") ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Availability</a>
|
Dashboard
|
||||||
</Link>
|
</a>
|
||||||
<Link href="/integrations">
|
</Link>
|
||||||
<a className={router.pathname.startsWith("/integrations") ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Integrations</a>
|
<Link href="/bookings">
|
||||||
</Link>
|
<a
|
||||||
<Link href="/settings/profile">
|
className={
|
||||||
<a className={router.pathname.startsWith("/settings") ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Settings</a>
|
router.pathname.startsWith("/bookings")
|
||||||
</Link>
|
? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
</div>
|
: "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
</div>
|
}>
|
||||||
</div>
|
Bookings
|
||||||
<div className="hidden md:block">
|
</a>
|
||||||
<div className="ml-4 flex items-center md:ml-6">
|
</Link>
|
||||||
<div className="ml-3 relative">
|
<Link href="/availability">
|
||||||
<div>
|
<a
|
||||||
<button onClick={toggleProfileDropdown} type="button" className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" id="user-menu" aria-expanded="false" aria-haspopup="true">
|
className={
|
||||||
<span className="sr-only">Open user menu</span>
|
router.pathname.startsWith("/availability")
|
||||||
<img className="h-8 w-8 rounded-full" src={session.user.image ? session.user.image : "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" + encodeURIComponent(session.user.name || "")} alt="" />
|
? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
</button>
|
: "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
</div>
|
}>
|
||||||
{
|
Availability
|
||||||
profileDropdownExpanded && (
|
</a>
|
||||||
<div className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50" role="menu" aria-orientation="vertical" aria-labelledby="user-menu">
|
</Link>
|
||||||
<Link href={"/" + session.user.username}><a target="_blank" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Your Public Page</a></Link>
|
<Link href="/integrations">
|
||||||
<Link href="/settings/profile"><a className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Your Profile</a></Link>
|
<a
|
||||||
<Link href="/settings/password"><a className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Login & Security</a></Link>
|
className={
|
||||||
<button onClick={logoutHandler} className="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Sign out</button>
|
router.pathname.startsWith("/integrations")
|
||||||
</div>
|
? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
)
|
: "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
}
|
}>
|
||||||
</div>
|
Integrations
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</Link>
|
||||||
<div className="-mr-2 flex md:hidden">
|
<Link href="/settings/profile">
|
||||||
<button onClick={toggleMobileMenu} type="button" className=" inline-flex items-center justify-center p-2 rounded-md text-white focus:outline-none" aria-controls="mobile-menu" aria-expanded="false">
|
<a
|
||||||
<span className="sr-only">Open main menu</span>
|
className={
|
||||||
{ !mobileMenuExpanded && <MenuIcon className="block h-6 w-6" /> }
|
router.pathname.startsWith("/settings")
|
||||||
{ mobileMenuExpanded && <XIcon className="block h-6 w-6" /> }
|
? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
</button>
|
: "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||||
</div>
|
}>
|
||||||
</div>
|
Settings
|
||||||
</div>
|
</a>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{ mobileMenuExpanded && <div className="border-b border-blue-500 md:hidden bg-blue-600" id="mobile-menu">
|
|
||||||
<div className="px-2 py-3 space-y-1 sm:px-3">
|
|
||||||
<Link href="/">
|
|
||||||
<a className={router.pathname == "/" ? "bg-blue-500 text-white block px-3 py-2 rounded-md text-base font-medium" : "text-gray-100 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"}>Dashboard</a>
|
|
||||||
</Link>
|
|
||||||
<Link href="/availability">
|
|
||||||
<a className={router.pathname.startsWith("/availability") ? "bg-blue-500 text-white block px-3 py-2 rounded-md text-base font-medium" : "text-gray-100 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"}>Availability</a>
|
|
||||||
</Link>
|
|
||||||
<Link href="/integrations">
|
|
||||||
<a className={router.pathname.startsWith("/integrations") ? "bg-blue-500 text-white block px-3 py-2 rounded-md text-base font-medium" : "text-gray-100 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"}>Integrations</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="pt-4 pb-3 border-t border-blue-500">
|
|
||||||
<div className="flex items-center px-5">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<img className="h-10 w-10 rounded-full" src={"https://eu.ui-avatars.com/api/?background=039be5&color=fff&name=" + encodeURIComponent(session.user.name || session.user.username)} alt="" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<div className="text-base font-medium leading-none text-white">{session.user.name || session.user.username}</div>
|
|
||||||
<div className="text-sm font-medium leading-none text-gray-200">{session.user.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 px-2 space-y-1">
|
|
||||||
<Link href="/settings/profile">
|
|
||||||
<a className="block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-white hover:bg-gray-700">Your Profile</a>
|
|
||||||
</Link>
|
|
||||||
<Link href="/settings">
|
|
||||||
<a className={router.pathname.startsWith("/settings") ? "bg-blue-500 text-white block px-3 py-2 rounded-md text-base font-medium" : "text-gray-100 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"}>Settings</a>
|
|
||||||
</Link>
|
|
||||||
<button onClick={logoutHandler} className="block w-full text-left px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-white hover:bg-gray-700">Sign out</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</nav>
|
|
||||||
<header className={props.noPaddingBottom ? "pt-10" : "py-10"}>
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<h1 className="text-3xl font-bold text-white">
|
|
||||||
{props.heading}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="-mt-32">
|
|
||||||
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
{props.children}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div className="hidden md:block">
|
||||||
</div>
|
<div className="ml-4 flex items-center md:ml-6">
|
||||||
);
|
<div className="ml-3 relative">
|
||||||
}
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={toggleProfileDropdown}
|
||||||
|
type="button"
|
||||||
|
className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white"
|
||||||
|
id="user-menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="true">
|
||||||
|
<span className="sr-only">Open user menu</span>
|
||||||
|
<img
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
src={
|
||||||
|
session.user.image
|
||||||
|
? session.user.image
|
||||||
|
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||||
|
encodeURIComponent(session.user.name || "")
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{profileDropdownExpanded && (
|
||||||
|
<div
|
||||||
|
className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50"
|
||||||
|
role="menu"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-labelledby="user-menu">
|
||||||
|
<Link href={"/" + session.user.username}>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
role="menuitem">
|
||||||
|
Your Public Page
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings/profile">
|
||||||
|
<a
|
||||||
|
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
role="menuitem">
|
||||||
|
Your Profile
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings/password">
|
||||||
|
<a
|
||||||
|
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
role="menuitem">
|
||||||
|
Login & Security
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={logoutHandler}
|
||||||
|
className="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
role="menuitem">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="-mr-2 flex md:hidden">
|
||||||
|
<button
|
||||||
|
onClick={toggleMobileMenu}
|
||||||
|
type="button"
|
||||||
|
className=" inline-flex items-center justify-center p-2 rounded-md text-white focus:outline-none"
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
aria-expanded="false">
|
||||||
|
<span className="sr-only">Open main menu</span>
|
||||||
|
{!mobileMenuExpanded && <MenuIcon className="block h-6 w-6" />}
|
||||||
|
{mobileMenuExpanded && <XIcon className="block h-6 w-6" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mobileMenuExpanded && (
|
||||||
|
<div className="border-b border-blue-500 md:hidden bg-blue-600" id="mobile-menu">
|
||||||
|
<div className="px-2 py-3 space-y-1 sm:px-3">
|
||||||
|
<Link href="/">
|
||||||
|
<a
|
||||||
|
className={
|
||||||
|
router.pathname == "/"
|
||||||
|
? "bg-blue-500 text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
: "text-gray-100 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
}>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<Link href="/availability">
|
||||||
|
<a
|
||||||
|
className={
|
||||||
|
router.pathname.startsWith("/availability")
|
||||||
|
? "bg-blue-500 text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
: "text-gray-100 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
}>
|
||||||
|
Availability
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<Link href="/integrations">
|
||||||
|
<a
|
||||||
|
className={
|
||||||
|
router.pathname.startsWith("/integrations")
|
||||||
|
? "bg-blue-500 text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
: "text-gray-100 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
}>
|
||||||
|
Integrations
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 pb-3 border-t border-blue-500">
|
||||||
|
<div className="flex items-center px-5">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
className="h-10 w-10 rounded-full"
|
||||||
|
src={
|
||||||
|
"https://eu.ui-avatars.com/api/?background=039be5&color=fff&name=" +
|
||||||
|
encodeURIComponent(session.user.name || session.user.username)
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<div className="text-base font-medium leading-none text-white">
|
||||||
|
{session.user.name || session.user.username}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium leading-none text-gray-200">{session.user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 px-2 space-y-1">
|
||||||
|
<Link href="/settings/profile">
|
||||||
|
<a className="block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-white hover:bg-gray-700">
|
||||||
|
Your Profile
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings">
|
||||||
|
<a
|
||||||
|
className={
|
||||||
|
router.pathname.startsWith("/settings")
|
||||||
|
? "bg-blue-500 text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
: "text-gray-100 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
}>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={logoutHandler}
|
||||||
|
className="block w-full text-left px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-white hover:bg-gray-700">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<header className={props.noPaddingBottom ? "pt-10" : "py-10"}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white">{props.heading}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="-mt-32">
|
||||||
|
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">{props.children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) =>
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDate) onDatePicked(selectedDate);
|
if (selectedDate) onDatePicked(selectedDate);
|
||||||
}, [selectedDate, onDatePicked]);
|
}, [selectedDate]);
|
||||||
|
|
||||||
// Handle month changes
|
// Handle month changes
|
||||||
const incrementMonth = () => {
|
const incrementMonth = () => {
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export default function Button(props) {
|
export default function Button(props) {
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
return(
|
return(
|
||||||
<button type="submit" className="btn btn-primary" onClick={setLoading}>
|
<button type="submit" className="btn btn-primary">
|
||||||
{!loading && props.children}
|
{!props.loading && props.children}
|
||||||
{loading &&
|
{props.loading &&
|
||||||
<svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg 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>
|
<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>
|
<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>
|
||||||
|
@ -13,4 +12,4 @@ export default function Button(props) {
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export const UsernameInput = React.forwardRef( (props, ref) => (
|
const UsernameInput = React.forwardRef((props, ref) => (
|
||||||
// todo, check if username is already taken here?
|
// todo, check if username is already taken here?
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||||
|
@ -10,8 +10,20 @@ export const UsernameInput = React.forwardRef( (props, ref) => (
|
||||||
<span className="bg-gray-50 border border-r-0 border-gray-300 rounded-l-md px-3 inline-flex items-center text-gray-500 sm:text-sm">
|
<span className="bg-gray-50 border border-r-0 border-gray-300 rounded-l-md px-3 inline-flex items-center text-gray-500 sm:text-sm">
|
||||||
{typeof window !== "undefined" && window.location.hostname}/
|
{typeof window !== "undefined" && window.location.hostname}/
|
||||||
</span>
|
</span>
|
||||||
<input ref={ref} type="text" name="username" id="username" autoComplete="username" required {...props}
|
<input
|
||||||
className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"/>
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
{...props}
|
||||||
|
className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300 lowercase"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
UsernameInput.displayName = "UsernameInput";
|
||||||
|
|
||||||
|
export { UsernameInput };
|
||||||
|
|
|
@ -7,18 +7,51 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"
|
||||||
|
|
||||||
const translator = short();
|
const translator = short();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { google } = require("googleapis");
|
const { google } = require("googleapis");
|
||||||
|
import prisma from "./prisma";
|
||||||
|
|
||||||
const googleAuth = () => {
|
const googleAuth = (credential) => {
|
||||||
const { client_secret, client_id, redirect_uris } = JSON.parse(
|
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
|
||||||
process.env.GOOGLE_API_CREDENTIALS
|
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||||
).web;
|
myGoogleAuth.setCredentials(credential.key);
|
||||||
return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
|
||||||
|
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
||||||
|
|
||||||
|
const refreshAccessToken = () =>
|
||||||
|
myGoogleAuth
|
||||||
|
.refreshToken(credential.key.refresh_token)
|
||||||
|
.then((res) => {
|
||||||
|
const token = res.res.data;
|
||||||
|
credential.key.access_token = token.access_token;
|
||||||
|
credential.key.expiry_date = token.expiry_date;
|
||||||
|
return prisma.credential
|
||||||
|
.update({
|
||||||
|
where: {
|
||||||
|
id: credential.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
key: credential.key,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
myGoogleAuth.setCredentials(credential.key);
|
||||||
|
return myGoogleAuth;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Error refreshing google token", err);
|
||||||
|
return myGoogleAuth;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleErrorsJson(response) {
|
function handleErrorsJson(response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
response.json().then(console.log);
|
response.json().then((e) => console.error("O365 Error", e));
|
||||||
throw Error(response.statusText);
|
throw Error(response.statusText);
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
|
@ -26,17 +59,17 @@ function handleErrorsJson(response) {
|
||||||
|
|
||||||
function handleErrorsRaw(response) {
|
function handleErrorsRaw(response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
response.text().then(console.log);
|
response.text().then((e) => console.error("O365 Error", e));
|
||||||
throw Error(response.statusText);
|
throw Error(response.statusText);
|
||||||
}
|
}
|
||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
const o365Auth = (credential) => {
|
const o365Auth = (credential) => {
|
||||||
const isExpired = (expiryDate) => expiryDate < +new Date();
|
const isExpired = (expiryDate) => expiryDate < Math.round(+new Date() / 1000);
|
||||||
|
|
||||||
const refreshAccessToken = (refreshToken) =>
|
const refreshAccessToken = (refreshToken) => {
|
||||||
fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
|
@ -50,11 +83,19 @@ const o365Auth = (credential) => {
|
||||||
.then(handleErrorsJson)
|
.then(handleErrorsJson)
|
||||||
.then((responseBody) => {
|
.then((responseBody) => {
|
||||||
credential.key.access_token = responseBody.access_token;
|
credential.key.access_token = responseBody.access_token;
|
||||||
credential.key.expiry_date = Math.round(
|
credential.key.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||||
+new Date() / 1000 + responseBody.expires_in
|
return prisma.credential
|
||||||
);
|
.update({
|
||||||
return credential.key.access_token;
|
where: {
|
||||||
|
id: credential.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
key: credential.key,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => credential.key.access_token);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: () =>
|
getToken: () =>
|
||||||
|
@ -96,15 +137,11 @@ interface IntegrationCalendar {
|
||||||
interface CalendarApiAdapter {
|
interface CalendarApiAdapter {
|
||||||
createEvent(event: CalendarEvent): Promise<any>;
|
createEvent(event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
updateEvent(uid: String, event: CalendarEvent);
|
updateEvent(uid: string, event: CalendarEvent);
|
||||||
|
|
||||||
deleteEvent(uid: String);
|
deleteEvent(uid: string);
|
||||||
|
|
||||||
getAvailability(
|
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>;
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
selectedCalendars: IntegrationCalendar[]
|
|
||||||
): Promise<any>;
|
|
||||||
|
|
||||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||||
}
|
}
|
||||||
|
@ -113,7 +150,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
const auth = o365Auth(credential);
|
const auth = o365Auth(credential);
|
||||||
|
|
||||||
const translateEvent = (event: CalendarEvent) => {
|
const translateEvent = (event: CalendarEvent) => {
|
||||||
let optional = {};
|
const optional = {};
|
||||||
if (event.location) {
|
if (event.location) {
|
||||||
optional.location = { displayName: event.location };
|
optional.location = { displayName: event.location };
|
||||||
}
|
}
|
||||||
|
@ -171,12 +208,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
|
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
|
||||||
const filter =
|
const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'";
|
||||||
"?$filter=start/dateTime ge '" +
|
|
||||||
dateFrom +
|
|
||||||
"' and end/dateTime le '" +
|
|
||||||
dateTo +
|
|
||||||
"'";
|
|
||||||
return auth
|
return auth
|
||||||
.getToken()
|
.getToken()
|
||||||
.then((accessToken) => {
|
.then((accessToken) => {
|
||||||
|
@ -195,10 +227,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
).then((ids: string[]) => {
|
).then((ids: string[]) => {
|
||||||
const urls = ids.map(
|
const urls = ids.map(
|
||||||
(calendarId) =>
|
(calendarId) =>
|
||||||
"https://graph.microsoft.com/v1.0/me/calendars/" +
|
"https://graph.microsoft.com/v1.0/me/calendars/" + calendarId + "/events" + filter
|
||||||
calendarId +
|
|
||||||
"/events" +
|
|
||||||
filter
|
|
||||||
);
|
);
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
urls.map((url) =>
|
urls.map((url) =>
|
||||||
|
@ -217,9 +246,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).then((results) =>
|
).then((results) => results.reduce((acc, events) => acc.concat(events), []));
|
||||||
results.reduce((acc, events) => acc.concat(events), [])
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
@ -242,7 +269,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
disableConfirmationEmail: true,
|
disableConfirmationEmail: true,
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
deleteEvent: (uid: String) =>
|
deleteEvent: (uid: string) =>
|
||||||
auth.getToken().then((accessToken) =>
|
auth.getToken().then((accessToken) =>
|
||||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
@ -251,7 +278,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
},
|
},
|
||||||
}).then(handleErrorsRaw)
|
}).then(handleErrorsRaw)
|
||||||
),
|
),
|
||||||
updateEvent: (uid: String, event: CalendarEvent) =>
|
updateEvent: (uid: string, event: CalendarEvent) =>
|
||||||
auth.getToken().then((accessToken) =>
|
auth.getToken().then((accessToken) =>
|
||||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
@ -267,187 +294,188 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const GoogleCalendar = (credential): CalendarApiAdapter => {
|
const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
const myGoogleAuth = googleAuth();
|
const auth = googleAuth(credential);
|
||||||
myGoogleAuth.setCredentials(credential.key);
|
|
||||||
const integrationType = "google_calendar";
|
const integrationType = "google_calendar";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
|
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) =>
|
||||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
auth.getToken().then((myGoogleAuth) => {
|
||||||
calendar.calendarList
|
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||||
.list()
|
const selectedCalendarIds = selectedCalendars
|
||||||
.then((cals) => {
|
.filter((e) => e.integration === integrationType)
|
||||||
const filteredItems = cals.data.items.filter(
|
.map((e) => e.externalId);
|
||||||
(i) =>
|
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) {
|
||||||
selectedCalendars.findIndex((e) => e.externalId === i.id) > -1
|
// Only calendars of other integrations selected
|
||||||
);
|
resolve([]);
|
||||||
if (filteredItems.length == 0 && selectedCalendars.length > 0) {
|
return;
|
||||||
// Only calendars of other integrations selected
|
}
|
||||||
resolve([]);
|
|
||||||
}
|
(selectedCalendarIds.length == 0
|
||||||
calendar.freebusy.query(
|
? calendar.calendarList.list().then((cals) => cals.data.items.map((cal) => cal.id))
|
||||||
{
|
: Promise.resolve(selectedCalendarIds)
|
||||||
requestBody: {
|
)
|
||||||
timeMin: dateFrom,
|
.then((calsIds) => {
|
||||||
timeMax: dateTo,
|
calendar.freebusy.query(
|
||||||
items:
|
{
|
||||||
filteredItems.length > 0 ? filteredItems : cals.data.items,
|
requestBody: {
|
||||||
|
timeMin: dateFrom,
|
||||||
|
timeMax: dateTo,
|
||||||
|
items: calsIds.map((id) => ({ id: id })),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
(err, apires) => {
|
||||||
(err, apires) => {
|
if (err) {
|
||||||
if (err) {
|
reject(err);
|
||||||
reject(err);
|
}
|
||||||
|
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
|
||||||
}
|
}
|
||||||
|
);
|
||||||
resolve(
|
})
|
||||||
Object.values(apires.data.calendars).flatMap(
|
.catch((err) => {
|
||||||
(item) => item["busy"]
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
)
|
reject(err);
|
||||||
);
|
});
|
||||||
}
|
})
|
||||||
);
|
),
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
createEvent: (event: CalendarEvent) =>
|
createEvent: (event: CalendarEvent) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) =>
|
||||||
const payload = {
|
auth.getToken().then((myGoogleAuth) => {
|
||||||
summary: event.title,
|
const payload = {
|
||||||
description: event.description,
|
summary: event.title,
|
||||||
start: {
|
description: event.description,
|
||||||
dateTime: event.startTime,
|
start: {
|
||||||
timeZone: event.organizer.timeZone,
|
dateTime: event.startTime,
|
||||||
},
|
timeZone: event.organizer.timeZone,
|
||||||
end: {
|
},
|
||||||
dateTime: event.endTime,
|
end: {
|
||||||
timeZone: event.organizer.timeZone,
|
dateTime: event.endTime,
|
||||||
},
|
timeZone: event.organizer.timeZone,
|
||||||
attendees: event.attendees,
|
},
|
||||||
reminders: {
|
attendees: event.attendees,
|
||||||
useDefault: false,
|
reminders: {
|
||||||
overrides: [{ method: "email", minutes: 60 }],
|
useDefault: false,
|
||||||
},
|
overrides: [{ method: "email", minutes: 60 }],
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (event.location) {
|
if (event.location) {
|
||||||
payload["location"] = event.location;
|
payload["location"] = event.location;
|
||||||
}
|
|
||||||
|
|
||||||
if (event.conferenceData) {
|
|
||||||
payload["conferenceData"] = event.conferenceData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
|
||||||
calendar.events.insert(
|
|
||||||
{
|
|
||||||
auth: myGoogleAuth,
|
|
||||||
calendarId: "primary",
|
|
||||||
resource: payload,
|
|
||||||
conferenceDataVersion: 1,
|
|
||||||
},
|
|
||||||
function (err, event) {
|
|
||||||
if (err) {
|
|
||||||
console.log(
|
|
||||||
"There was an error contacting the Calendar service: " + err
|
|
||||||
);
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
return resolve(event.data);
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}),
|
|
||||||
updateEvent: (uid: String, event: CalendarEvent) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const payload = {
|
|
||||||
summary: event.title,
|
|
||||||
description: event.description,
|
|
||||||
start: {
|
|
||||||
dateTime: event.startTime,
|
|
||||||
timeZone: event.organizer.timeZone,
|
|
||||||
},
|
|
||||||
end: {
|
|
||||||
dateTime: event.endTime,
|
|
||||||
timeZone: event.organizer.timeZone,
|
|
||||||
},
|
|
||||||
attendees: event.attendees,
|
|
||||||
reminders: {
|
|
||||||
useDefault: false,
|
|
||||||
overrides: [{ method: "email", minutes: 60 }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.location) {
|
if (event.conferenceData) {
|
||||||
payload["location"] = event.location;
|
payload["conferenceData"] = event.conferenceData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||||
calendar.events.update(
|
calendar.events.insert(
|
||||||
{
|
{
|
||||||
auth: myGoogleAuth,
|
auth: myGoogleAuth,
|
||||||
calendarId: "primary",
|
calendarId: "primary",
|
||||||
eventId: uid,
|
resource: payload,
|
||||||
sendNotifications: true,
|
},
|
||||||
sendUpdates: "all",
|
function (err, event) {
|
||||||
resource: payload,
|
if (err) {
|
||||||
},
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
function (err, event) {
|
return reject(err);
|
||||||
if (err) {
|
}
|
||||||
console.log(
|
return resolve(event.data);
|
||||||
"There was an error contacting the Calendar service: " + err
|
|
||||||
);
|
|
||||||
return reject(err);
|
|
||||||
}
|
}
|
||||||
return resolve(event.data);
|
);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
updateEvent: (uid: string, event: CalendarEvent) =>
|
||||||
|
new Promise((resolve, reject) =>
|
||||||
|
auth.getToken().then((myGoogleAuth) => {
|
||||||
|
const payload = {
|
||||||
|
summary: event.title,
|
||||||
|
description: event.description,
|
||||||
|
start: {
|
||||||
|
dateTime: event.startTime,
|
||||||
|
timeZone: event.organizer.timeZone,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: event.endTime,
|
||||||
|
timeZone: event.organizer.timeZone,
|
||||||
|
},
|
||||||
|
attendees: event.attendees,
|
||||||
|
reminders: {
|
||||||
|
useDefault: false,
|
||||||
|
overrides: [{ method: "email", minutes: 60 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.location) {
|
||||||
|
payload["location"] = event.location;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}),
|
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||||
deleteEvent: (uid: String) =>
|
calendar.events.update(
|
||||||
new Promise((resolve, reject) => {
|
{
|
||||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
auth: myGoogleAuth,
|
||||||
calendar.events.delete(
|
calendarId: "primary",
|
||||||
{
|
eventId: uid,
|
||||||
auth: myGoogleAuth,
|
sendNotifications: true,
|
||||||
calendarId: "primary",
|
sendUpdates: "all",
|
||||||
eventId: uid,
|
resource: payload,
|
||||||
sendNotifications: true,
|
},
|
||||||
sendUpdates: "all",
|
function (err, event) {
|
||||||
},
|
if (err) {
|
||||||
function (err, event) {
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
if (err) {
|
return reject(err);
|
||||||
console.log(
|
}
|
||||||
"There was an error contacting the Calendar service: " + err
|
return resolve(event.data);
|
||||||
);
|
|
||||||
return reject(err);
|
|
||||||
}
|
}
|
||||||
return resolve(event.data);
|
);
|
||||||
}
|
})
|
||||||
);
|
),
|
||||||
}),
|
deleteEvent: (uid: string) =>
|
||||||
|
new Promise((resolve, reject) =>
|
||||||
|
auth.getToken().then((myGoogleAuth) => {
|
||||||
|
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||||
|
calendar.events.delete(
|
||||||
|
{
|
||||||
|
auth: myGoogleAuth,
|
||||||
|
calendarId: "primary",
|
||||||
|
eventId: uid,
|
||||||
|
sendNotifications: true,
|
||||||
|
sendUpdates: "all",
|
||||||
|
},
|
||||||
|
function (err, event) {
|
||||||
|
if (err) {
|
||||||
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
return resolve(event.data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
),
|
||||||
listCalendars: () =>
|
listCalendars: () =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) =>
|
||||||
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
auth.getToken().then((myGoogleAuth) => {
|
||||||
calendar.calendarList
|
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
|
||||||
.list()
|
calendar.calendarList
|
||||||
.then((cals) => {
|
.list()
|
||||||
resolve(
|
.then((cals) => {
|
||||||
cals.data.items.map((cal) => {
|
resolve(
|
||||||
const calendar: IntegrationCalendar = {
|
cals.data.items.map((cal) => {
|
||||||
externalId: cal.id,
|
const calendar: IntegrationCalendar = {
|
||||||
integration: integrationType,
|
externalId: cal.id,
|
||||||
name: cal.summary,
|
integration: integrationType,
|
||||||
primary: cal.primary,
|
name: cal.summary,
|
||||||
};
|
primary: cal.primary,
|
||||||
return calendar;
|
};
|
||||||
})
|
return calendar;
|
||||||
);
|
})
|
||||||
})
|
);
|
||||||
.catch((err) => {
|
})
|
||||||
reject(err);
|
.catch((err) => {
|
||||||
});
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
}),
|
reject(err);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -466,43 +494,37 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const getBusyCalendarTimes = (
|
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
|
||||||
withCredentials,
|
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
selectedCalendars
|
|
||||||
) =>
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
calendars(withCredentials).map((c) =>
|
calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
||||||
c.getAvailability(dateFrom, dateTo, selectedCalendars)
|
|
||||||
)
|
|
||||||
).then((results) => {
|
).then((results) => {
|
||||||
return results.reduce((acc, availability) => acc.concat(availability), []);
|
return results.reduce((acc, availability) => acc.concat(availability), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
const listCalendars = (withCredentials) =>
|
const listCalendars = (withCredentials) =>
|
||||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then(
|
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
||||||
(results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
|
results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const createEvent = async (
|
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => {
|
||||||
credential,
|
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||||
calEvent: CalendarEvent
|
|
||||||
): Promise<any> => {
|
|
||||||
const uid: string = translator.fromUUID(
|
|
||||||
uuidv5(JSON.stringify(calEvent), uuidv5.URL)
|
|
||||||
);
|
|
||||||
|
|
||||||
const creationResult = credential
|
const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null;
|
||||||
? await calendars([credential])[0].createEvent(calEvent)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerMail(calEvent, uid);
|
const organizerMail = new EventOrganizerMail(calEvent, uid);
|
||||||
const attendeeMail = new EventAttendeeMail(calEvent, uid);
|
const attendeeMail = new EventAttendeeMail(calEvent, uid);
|
||||||
await organizerMail.sendEmail();
|
try {
|
||||||
|
await organizerMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
||||||
await attendeeMail.sendEmail();
|
try {
|
||||||
|
await attendeeMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -511,14 +533,8 @@ const createEvent = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEvent = async (
|
const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise<any> => {
|
||||||
credential,
|
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||||
uidToUpdate: String,
|
|
||||||
calEvent: CalendarEvent
|
|
||||||
): Promise<any> => {
|
|
||||||
const newUid: string = translator.fromUUID(
|
|
||||||
uuidv5(JSON.stringify(calEvent), uuidv5.URL)
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateResult = credential
|
const updateResult = credential
|
||||||
? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent)
|
? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent)
|
||||||
|
@ -526,10 +542,18 @@ const updateEvent = async (
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||||
await organizerMail.sendEmail();
|
try {
|
||||||
|
await organizerMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
||||||
await attendeeMail.sendEmail();
|
try {
|
||||||
|
await attendeeMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -538,7 +562,7 @@ const updateEvent = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteEvent = (credential, uid: String): Promise<any> => {
|
const deleteEvent = (credential, uid: string): Promise<any> => {
|
||||||
if (credential) {
|
if (credential) {
|
||||||
return calendars([credential])[0].deleteEvent(uid);
|
return calendars([credential])[0].deleteEvent(uid);
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,7 +58,7 @@ export default abstract class EventMail {
|
||||||
* Sends the email to the event attendant and returns a Promise.
|
* Sends the email to the event attendant and returns a Promise.
|
||||||
*/
|
*/
|
||||||
public sendEmail(): Promise<any> {
|
public sendEmail(): Promise<any> {
|
||||||
return new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail(
|
new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail(
|
||||||
this.getNodeMailerPayload(),
|
this.getNodeMailerPayload(),
|
||||||
(error, info) => {
|
(error, info) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -67,7 +67,9 @@ export default abstract class EventMail {
|
||||||
} else {
|
} else {
|
||||||
resolve(info);
|
resolve(info);
|
||||||
}
|
}
|
||||||
}));
|
})
|
||||||
|
).catch((e) => console.error("sendEmail", e));
|
||||||
|
return new Promise((resolve) => resolve("send mail async"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -127,9 +129,10 @@ export default abstract class EventMail {
|
||||||
protected getAdditionalFooter(): string {
|
protected getAdditionalFooter(): string {
|
||||||
return `
|
return `
|
||||||
<br/>
|
<br/>
|
||||||
Need to change this event?<br />
|
<br/>
|
||||||
|
<strong>Need to change this event?</strong><br />
|
||||||
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
|
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
|
||||||
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
|
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import Handlebars from "handlebars";
|
||||||
|
|
||||||
|
export const buildMessageTemplate = ({
|
||||||
|
messageTemplate,
|
||||||
|
subjectTemplate,
|
||||||
|
vars,
|
||||||
|
}): { subject: string; message: string } => {
|
||||||
|
const buildMessage = Handlebars.compile(messageTemplate);
|
||||||
|
const message = buildMessage(vars);
|
||||||
|
|
||||||
|
const buildSubject = Handlebars.compile(subjectTemplate);
|
||||||
|
const subject = buildSubject(vars);
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildMessageTemplate;
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { serverConfig } from "../serverConfig";
|
||||||
|
import nodemailer, { SentMessageInfo } from "nodemailer";
|
||||||
|
|
||||||
|
const sendEmail = ({ to, subject, text, html = null }): Promise<string | SentMessageInfo> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const { transport, from } = serverConfig;
|
||||||
|
|
||||||
|
if (!to || !subject || (!text && !html)) {
|
||||||
|
return reject("Missing required elements to send email.");
|
||||||
|
}
|
||||||
|
|
||||||
|
nodemailer.createTransport(transport).sendMail(
|
||||||
|
{
|
||||||
|
from: `Calendso ${from}`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
},
|
||||||
|
(error, info) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("SEND_INVITATION_NOTIFICATION_ERROR", to, error);
|
||||||
|
return reject(error.message);
|
||||||
|
}
|
||||||
|
return resolve(info);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default sendEmail;
|
|
@ -0,0 +1,20 @@
|
||||||
|
import buildMessageTemplate from "../../emails/buildMessageTemplate";
|
||||||
|
|
||||||
|
export const forgotPasswordSubjectTemplate = "Forgot your password? - Calendso";
|
||||||
|
|
||||||
|
export const forgotPasswordMessageTemplate = `Hey there,
|
||||||
|
|
||||||
|
Use the link below to reset your password.
|
||||||
|
{{link}}
|
||||||
|
|
||||||
|
p.s. It expires in 6 hours.
|
||||||
|
|
||||||
|
- Calendso`;
|
||||||
|
|
||||||
|
export const buildForgotPasswordMessage = (vars) => {
|
||||||
|
return buildMessageTemplate({
|
||||||
|
subjectTemplate: forgotPasswordSubjectTemplate,
|
||||||
|
messageTemplate: forgotPasswordMessageTemplate,
|
||||||
|
vars,
|
||||||
|
});
|
||||||
|
};
|
|
@ -1,7 +1,6 @@
|
||||||
|
|
||||||
export enum LocationType {
|
export enum LocationType {
|
||||||
InPerson = 'inPerson',
|
InPerson = "inPerson",
|
||||||
Phone = 'phone',
|
Phone = "phone",
|
||||||
GoogleMeet = 'integrations:google:meet'
|
GoogleMeet = "integrations:google:meet",
|
||||||
|
Zoom = "integrations:zoom",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -193,10 +193,18 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any>
|
||||||
|
|
||||||
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
|
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
|
||||||
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
|
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
|
||||||
await organizerMail.sendEmail();
|
try {
|
||||||
|
await organizerMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("organizerMail.sendEmail failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
||||||
await attendeeMail.sendEmail();
|
try {
|
||||||
|
await attendeeMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("attendeeMail.sendEmail failed", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -216,10 +224,18 @@ const updateMeeting = async (credential, uidToUpdate: String, calEvent: Calendar
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||||
await organizerMail.sendEmail();
|
try {
|
||||||
|
await organizerMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("organizerMail.sendEmail failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
||||||
await attendeeMail.sendEmail();
|
try {
|
||||||
|
await attendeeMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("attendeeMail.sendEmail failed", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -22,7 +22,9 @@
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"dayjs": "^1.10.4",
|
"dayjs": "^1.10.4",
|
||||||
"googleapis": "^67.1.1",
|
"googleapis": "^67.1.1",
|
||||||
|
"handlebars": "^4.7.7",
|
||||||
"ics": "^2.27.0",
|
"ics": "^2.27.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.merge": "^4.6.2",
|
"lodash.merge": "^4.6.2",
|
||||||
"next": "^10.2.0",
|
"next": "^10.2.0",
|
||||||
"next-auth": "^3.13.2",
|
"next-auth": "^3.13.2",
|
||||||
|
@ -39,6 +41,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^26.0.23",
|
"@types/jest": "^26.0.23",
|
||||||
"@types/node": "^14.14.33",
|
"@types/node": "^14.14.33",
|
||||||
|
"@types/nodemailer": "^6.4.2",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react": "^17.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
||||||
"@typescript-eslint/parser": "^4.27.0",
|
"@typescript-eslint/parser": "^4.27.0",
|
||||||
|
|
168
pages/[user].tsx
168
pages/[user].tsx
|
@ -1,92 +1,94 @@
|
||||||
import Head from 'next/head';
|
import { GetServerSideProps } from "next";
|
||||||
import Link from 'next/link';
|
import Head from "next/head";
|
||||||
import prisma from '../lib/prisma';
|
import Link from "next/link";
|
||||||
import Avatar from '../components/Avatar';
|
import prisma from "../lib/prisma";
|
||||||
|
import Avatar from "../components/Avatar";
|
||||||
|
|
||||||
export default function User(props) {
|
export default function User(props): User {
|
||||||
const eventTypes = props.eventTypes.map(type =>
|
const eventTypes = props.eventTypes.map((type) => (
|
||||||
<li key={type.id}>
|
<li key={type.id}>
|
||||||
<Link href={'/' + props.user.username + '/' + type.slug}>
|
<Link href={`/${props.user.username}/${type.slug}`}>
|
||||||
<a className="block px-6 py-4">
|
<a className="block px-6 py-4">
|
||||||
<div className="inline-block w-3 h-3 rounded-full mr-2" style={{backgroundColor:getRandomColorCode()}}></div>
|
<div
|
||||||
<h2 className="inline-block font-medium">{type.title}</h2>
|
className="inline-block w-3 h-3 rounded-full mr-2"
|
||||||
<p className="inline-block text-gray-400 ml-2">{type.description}</p>
|
style={{ backgroundColor: getRandomColorCode() }}></div>
|
||||||
</a>
|
<h2 className="inline-block font-medium">{type.title}</h2>
|
||||||
</Link>
|
<p className="inline-block text-gray-400 ml-2">{type.description}</p>
|
||||||
</li>
|
</a>
|
||||||
);
|
</Link>
|
||||||
return (
|
</li>
|
||||||
<div>
|
));
|
||||||
<Head>
|
return (
|
||||||
<title>{props.user.name || props.user.username} | Calendso</title>
|
<div>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<Head>
|
||||||
</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">
|
<main className="max-w-2xl mx-auto my-24">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
|
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
|
||||||
<h1 className="text-3xl font-semibold text-gray-800 mb-1">{props.user.name || props.user.username}</h1>
|
<h1 className="text-3xl font-semibold text-gray-800 mb-1">
|
||||||
<p className="text-gray-600">{props.user.bio}</p>
|
{props.user.name || props.user.username}
|
||||||
</div>
|
</h1>
|
||||||
<div className="bg-white shadow overflow-hidden rounded-md">
|
<p className="text-gray-600">{props.user.bio}</p>
|
||||||
<ul className="divide-y divide-gray-200">
|
|
||||||
{eventTypes}
|
|
||||||
</ul>
|
|
||||||
{eventTypes.length == 0 &&
|
|
||||||
<div className="p-8 text-center text-gray-400">
|
|
||||||
<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>
|
</div>
|
||||||
)
|
<div className="bg-white shadow overflow-hidden rounded-md">
|
||||||
|
<ul className="divide-y divide-gray-200">{eventTypes}</ul>
|
||||||
|
{eventTypes.length == 0 && (
|
||||||
|
<div className="p-8 text-center text-gray-400">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username: context.query.user,
|
username: context.query.user.toLowerCase(),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email:true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
bio: true,
|
bio: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
eventTypes: true
|
eventTypes: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
notFound: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTypes = await prisma.eventType.findMany({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
hidden: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
props: {
|
notFound: true,
|
||||||
user,
|
};
|
||||||
eventTypes
|
}
|
||||||
},
|
|
||||||
}
|
const eventTypes = await prisma.eventType.findMany({
|
||||||
}
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
eventTypes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Auxiliary methods
|
// Auxiliary methods
|
||||||
|
export function getRandomColorCode(): string {
|
||||||
export function getRandomColorCode() {
|
let color = "#";
|
||||||
let color = '#';
|
for (let idx = 0; idx < 6; idx++) {
|
||||||
for (let idx = 0; idx < 6; idx++) {
|
color += Math.floor(Math.random() * 10);
|
||||||
color += Math.floor(Math.random() * 10);
|
}
|
||||||
}
|
return color;
|
||||||
return color;
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { GetServerSideProps } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import prisma from "../../lib/prisma";
|
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
|
||||||
import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
|
import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid";
|
||||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
import prisma from "../../lib/prisma";
|
||||||
import utc from "dayjs/plugin/utc";
|
import { useRouter } from "next/router";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
dayjs.extend(isSameOrBefore);
|
|
||||||
dayjs.extend(utc);
|
|
||||||
dayjs.extend(timezone);
|
|
||||||
|
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||||
import AvailableTimes from "../../components/booking/AvailableTimes";
|
import AvailableTimes from "../../components/booking/AvailableTimes";
|
||||||
|
@ -17,10 +13,10 @@ import Avatar from "../../components/Avatar";
|
||||||
import { timeZone } from "../../lib/clock";
|
import { timeZone } from "../../lib/clock";
|
||||||
import DatePicker from "../../components/booking/DatePicker";
|
import DatePicker from "../../components/booking/DatePicker";
|
||||||
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
|
import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import getSlots from "@lib/slots";
|
import getSlots from "@lib/slots";
|
||||||
|
|
||||||
export default function Type(props) {
|
export default function Type(props): Type {
|
||||||
|
// Get router variables
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { rescheduleUid } = router.query;
|
const { rescheduleUid } = router.query;
|
||||||
|
|
||||||
|
@ -47,10 +43,10 @@ export default function Type(props) {
|
||||||
|
|
||||||
const changeDate = (date: Dayjs) => {
|
const changeDate = (date: Dayjs) => {
|
||||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
|
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
|
||||||
setSelectedDate(date);
|
setSelectedDate(date.tz(timeZone()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectTimeZone = (selectedTimeZone: string) => {
|
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
setSelectedDate(selectedDate.tz(selectedTimeZone));
|
setSelectedDate(selectedDate.tz(selectedTimeZone));
|
||||||
}
|
}
|
||||||
|
@ -67,7 +63,21 @@ export default function Type(props) {
|
||||||
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
|
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
|
||||||
Calendso
|
Calendso
|
||||||
</title>
|
</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
|
||||||
|
<meta name="description" content={props.eventType.description} />
|
||||||
|
|
||||||
|
<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={props.eventType.description}/>
|
||||||
|
<meta property="og:image" content={"https://og-image-one-pi.vercel.app/" + encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description).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={props.eventType.description} />
|
||||||
|
<meta property="twitter:image" content={"https://og-image-one-pi.vercel.app/" + encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} />
|
||||||
|
|
||||||
</Head>
|
</Head>
|
||||||
<main
|
<main
|
||||||
className={
|
className={
|
||||||
|
@ -85,7 +95,7 @@ export default function Type(props) {
|
||||||
{props.eventType.length} minutes
|
{props.eventType.length} minutes
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsTimeOptionsOpen(true)}
|
onClick={() => setIsTimeOptionsOpen(!isTimeOptionsOpen)}
|
||||||
className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
||||||
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{timeZone()}
|
{timeZone()}
|
||||||
|
@ -133,10 +143,10 @@ interface WorkingHours {
|
||||||
|
|
||||||
type Availability = WorkingHours;
|
type Availability = WorkingHours;
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username: context.query.user,
|
username: context.query.user.toLowerCase(),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -192,7 +202,7 @@ export async function getServerSideProps(context) {
|
||||||
getWorkingHours(user) ||
|
getWorkingHours(user) ||
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
days: [1, 2, 3, 4, 5, 6, 7],
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
startTime: user.startTime,
|
startTime: user.startTime,
|
||||||
length: user.endTime,
|
length: user.endTime,
|
||||||
},
|
},
|
||||||
|
@ -205,4 +215,4 @@ export async function getServerSideProps(context) {
|
||||||
workingHours,
|
workingHours,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,288 +1,415 @@
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import {useRouter} from 'next/router';
|
import { useRouter } from "next/router";
|
||||||
import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid';
|
import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid";
|
||||||
import prisma from '../../lib/prisma';
|
import prisma from "../../lib/prisma";
|
||||||
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dayjs from 'dayjs';
|
import dayjs from "dayjs";
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import 'react-phone-number-input/style.css';
|
import "react-phone-number-input/style.css";
|
||||||
import PhoneInput from 'react-phone-number-input';
|
import PhoneInput from "react-phone-number-input";
|
||||||
import {LocationType} from '../../lib/location';
|
import { LocationType } from "../../lib/location";
|
||||||
import Avatar from '../../components/Avatar';
|
import Avatar from "../../components/Avatar";
|
||||||
import Button from '../../components/ui/Button';
|
import Button from "../../components/ui/Button";
|
||||||
import {EventTypeCustomInputType} from "../../lib/eventTypeInput";
|
import { EventTypeCustomInputType } from "../../lib/eventTypeInput";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default function Book(props) {
|
export default function Book(props: any): JSX.Element {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { date, user, rescheduleUid } = router.query;
|
const { date, user, rescheduleUid } = router.query;
|
||||||
|
|
||||||
const [ is24h, setIs24h ] = useState(false);
|
const [is24h, setIs24h] = useState(false);
|
||||||
const [ preferredTimeZone, setPreferredTimeZone ] = useState('');
|
const [preferredTimeZone, setPreferredTimeZone] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
const locations = props.eventType.locations || [];
|
const locations = props.eventType.locations || [];
|
||||||
|
|
||||||
const [ selectedLocation, setSelectedLocation ] = useState<LocationType>(locations.length === 1 ? locations[0].type : '');
|
const [selectedLocation, setSelectedLocation] = useState<LocationType>(
|
||||||
const telemetry = useTelemetry();
|
locations.length === 1 ? locations[0].type : ""
|
||||||
useEffect(() => {
|
);
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
useEffect(() => {
|
||||||
|
setPreferredTimeZone(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess());
|
||||||
|
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
|
||||||
|
|
||||||
setPreferredTimeZone(localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess());
|
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
|
||||||
setIs24h(!!localStorage.getItem('timeOption.is24hClock'));
|
});
|
||||||
|
|
||||||
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
|
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
|
||||||
});
|
|
||||||
|
|
||||||
const locationInfo = (type: LocationType) => locations.find(
|
// TODO: Move to translations
|
||||||
(location) => location.type === type
|
const locationLabels = {
|
||||||
);
|
[LocationType.InPerson]: "In-person meeting",
|
||||||
|
[LocationType.Phone]: "Phone call",
|
||||||
|
[LocationType.GoogleMeet]: "Google Meet",
|
||||||
|
[LocationType.Zoom]: "Zoom Video",
|
||||||
|
};
|
||||||
|
|
||||||
// TODO: Move to translations
|
const bookingHandler = (event) => {
|
||||||
const locationLabels = {
|
const book = async () => {
|
||||||
[LocationType.InPerson]: 'In-person meeting',
|
setLoading(true);
|
||||||
[LocationType.Phone]: 'Phone call',
|
setError(false);
|
||||||
[LocationType.GoogleMeet]: 'Google Meet',
|
let notes = "";
|
||||||
|
if (props.eventType.customInputs) {
|
||||||
|
notes = props.eventType.customInputs
|
||||||
|
.map((input) => {
|
||||||
|
const data = event.target["custom_" + input.id];
|
||||||
|
if (data) {
|
||||||
|
if (input.type === EventTypeCustomInputType.Bool) {
|
||||||
|
return input.label + "\n" + (data.value ? "Yes" : "No");
|
||||||
|
} else {
|
||||||
|
return input.label + "\n" + data.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
if (!!notes && !!event.target.notes.value) {
|
||||||
|
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
|
||||||
|
} else {
|
||||||
|
notes += event.target.notes.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
start: dayjs(date).format(),
|
||||||
|
end: dayjs(date).add(props.eventType.length, "minute").format(),
|
||||||
|
name: event.target.name.value,
|
||||||
|
email: event.target.email.value,
|
||||||
|
notes: notes,
|
||||||
|
timeZone: preferredTimeZone,
|
||||||
|
eventTypeId: props.eventType.id,
|
||||||
|
rescheduleUid: rescheduleUid,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedLocation) {
|
||||||
|
switch (selectedLocation) {
|
||||||
|
case LocationType.Phone:
|
||||||
|
payload["location"] = event.target.phone.value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LocationType.InPerson:
|
||||||
|
payload["location"] = locationInfo(selectedLocation).address;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||||
|
default:
|
||||||
|
payload["location"] = selectedLocation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetry.withJitsu((jitsu) =>
|
||||||
|
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
||||||
|
);
|
||||||
|
|
||||||
|
/*const res = await */ fetch("/api/book/" + user, {
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
// TODO When the endpoint is fixed, change this to await the result again
|
||||||
|
//if (res.ok) {
|
||||||
|
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${
|
||||||
|
props.user.username
|
||||||
|
}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
|
||||||
|
if (payload["location"]) {
|
||||||
|
if (payload["location"].includes("integration")) {
|
||||||
|
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
|
||||||
|
} else {
|
||||||
|
successUrl += "&location=" + encodeURIComponent(payload["location"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(successUrl);
|
||||||
|
/*} else {
|
||||||
|
setLoading(false);
|
||||||
|
setError(true);
|
||||||
|
}*/
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingHandler = (event) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
book();
|
||||||
|
};
|
||||||
|
|
||||||
let notes = "";
|
return (
|
||||||
if (props.eventType.customInputs) {
|
<div>
|
||||||
notes = props.eventType.customInputs.map(input => {
|
<Head>
|
||||||
const data = event.target["custom_" + input.id];
|
<title>
|
||||||
if (!!data) {
|
{rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with{" "}
|
||||||
if (input.type === EventTypeCustomInputType.Bool) {
|
{props.user.name || props.user.username} | Calendso
|
||||||
return input.label + "\n" + (data.value ? "Yes" : "No")
|
</title>
|
||||||
} else {
|
<link rel="icon" href="/favicon.ico" />
|
||||||
return input.label + "\n" + data.value
|
</Head>
|
||||||
}
|
|
||||||
}
|
|
||||||
}).join("\n\n")
|
|
||||||
}
|
|
||||||
if (!!notes && !!event.target.notes.value) {
|
|
||||||
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
|
|
||||||
} else {
|
|
||||||
notes += event.target.notes.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = {
|
<main className="max-w-3xl mx-auto my-24">
|
||||||
start: dayjs(date).format(),
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
end: dayjs(date).add(props.eventType.length, 'minute').format(),
|
<div className="sm:flex px-4 py-5 sm:p-6">
|
||||||
name: event.target.name.value,
|
<div className="sm:w-1/2 sm:border-r">
|
||||||
email: event.target.email.value,
|
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
||||||
notes: notes,
|
<h2 className="font-medium text-gray-500">{props.user.name}</h2>
|
||||||
timeZone: preferredTimeZone,
|
<h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1>
|
||||||
eventTypeId: props.eventType.id,
|
<p className="text-gray-500 mb-2">
|
||||||
rescheduleUid: rescheduleUid
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
};
|
{props.eventType.length} minutes
|
||||||
|
</p>
|
||||||
if (selectedLocation) {
|
{selectedLocation === LocationType.InPerson && (
|
||||||
switch (selectedLocation) {
|
<p className="text-gray-500 mb-2">
|
||||||
case LocationType.Phone:
|
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
payload['location'] = event.target.phone.value
|
{locationInfo(selectedLocation).address}
|
||||||
break
|
</p>
|
||||||
|
)}
|
||||||
case LocationType.InPerson:
|
<p className="text-blue-600 mb-4">
|
||||||
payload['location'] = locationInfo(selectedLocation).address
|
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
break
|
{preferredTimeZone &&
|
||||||
|
dayjs(date)
|
||||||
case LocationType.GoogleMeet:
|
.tz(preferredTimeZone)
|
||||||
payload['location'] = LocationType.GoogleMeet
|
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
|
||||||
break
|
</p>
|
||||||
}
|
<p className="text-gray-600">{props.eventType.description}</p>
|
||||||
}
|
</div>
|
||||||
|
<div className="sm:w-1/2 pl-8 pr-4">
|
||||||
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()));
|
<form onSubmit={bookingHandler}>
|
||||||
const res = fetch(
|
<div className="mb-4">
|
||||||
'/api/book/' + user,
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
{
|
Your name
|
||||||
body: JSON.stringify(payload),
|
</label>
|
||||||
headers: {
|
<div className="mt-1">
|
||||||
'Content-Type': 'application/json'
|
<input
|
||||||
},
|
type="text"
|
||||||
method: 'POST'
|
name="name"
|
||||||
}
|
id="name"
|
||||||
);
|
required
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
|
placeholder="John Doe"
|
||||||
if (payload['location']) {
|
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
|
||||||
if (payload['location'].includes('integration')) {
|
/>
|
||||||
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
|
</div>
|
||||||
}
|
|
||||||
else {
|
|
||||||
successUrl += "&location=" + encodeURIComponent(payload['location']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(successUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Head>
|
|
||||||
<title>{rescheduleUid ? 'Reschedule' : 'Confirm'} your {props.eventType.title} with {props.user.name || props.user.username} | Calendso</title>
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto my-24">
|
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
|
||||||
<div className="sm:flex px-4 py-5 sm:p-6">
|
|
||||||
<div className="sm:w-1/2 sm:border-r">
|
|
||||||
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
|
||||||
<h2 className="font-medium text-gray-500">{props.user.name}</h2>
|
|
||||||
<h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1>
|
|
||||||
<p className="text-gray-500 mb-2">
|
|
||||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
|
||||||
{props.eventType.length} minutes
|
|
||||||
</p>
|
|
||||||
{selectedLocation === LocationType.InPerson && <p className="text-gray-500 mb-2">
|
|
||||||
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
|
||||||
{locationInfo(selectedLocation).address}
|
|
||||||
</p>}
|
|
||||||
<p className="text-blue-600 mb-4">
|
|
||||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
|
||||||
{preferredTimeZone && dayjs(date).tz(preferredTimeZone).format( (is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-600">{props.eventType.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="sm:w-1/2 pl-8 pr-4">
|
|
||||||
<form onSubmit={bookingHandler}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Your name</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<input type="text" name="name" id="name" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="John Doe" defaultValue={props.booking ? props.booking.attendees[0].name : ''} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email address</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<input type="email" name="email" id="email" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="you@example.com" defaultValue={props.booking ? props.booking.attendees[0].email : ''} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{locations.length > 1 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="block text-sm font-medium text-gray-700">Location</span>
|
|
||||||
{locations.map( (location) => (
|
|
||||||
<label key={location.type} className="block">
|
|
||||||
<input type="radio" required onChange={(e) => setSelectedLocation(e.target.value)} className="location" name="location" value={location.type} checked={selectedLocation === location.type} />
|
|
||||||
<span className="text-sm ml-2">{locationLabels[location.type]}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedLocation === LocationType.Phone && (<div className="mb-4">
|
|
||||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">Phone Number</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<PhoneInput name="phone" placeholder="Enter phone number" id="phone" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" onChange={() => {}} />
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
{props.eventType.customInputs && props.eventType.customInputs.sort((a,b) => a.id - b.id).map(input => (
|
|
||||||
<div className="mb-4">
|
|
||||||
{input.type !== EventTypeCustomInputType.Bool &&
|
|
||||||
<label htmlFor={input.label} className="block text-sm font-medium text-gray-700 mb-1">{input.label}</label>}
|
|
||||||
{input.type === EventTypeCustomInputType.TextLong &&
|
|
||||||
<textarea name={"custom_" + input.id} id={"custom_" + input.id}
|
|
||||||
required={input.required}
|
|
||||||
rows={3}
|
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
|
||||||
placeholder=""/>}
|
|
||||||
{input.type === EventTypeCustomInputType.Text &&
|
|
||||||
<input type="text" name={"custom_" + input.id} id={"custom_" + input.id}
|
|
||||||
required={input.required}
|
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
|
||||||
placeholder=""/>}
|
|
||||||
{input.type === EventTypeCustomInputType.Number &&
|
|
||||||
<input type="number" name={"custom_" + input.id} id={"custom_" + input.id}
|
|
||||||
required={input.required}
|
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
|
||||||
placeholder=""/>}
|
|
||||||
{input.type === EventTypeCustomInputType.Bool &&
|
|
||||||
<div className="flex items-center h-5">
|
|
||||||
<input type="checkbox" name={"custom_" + input.id} id={"custom_" + input.id}
|
|
||||||
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
|
|
||||||
placeholder=""/>
|
|
||||||
<label htmlFor={input.label} className="block text-sm font-medium text-gray-700">{input.label}</label>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="mb-4">
|
|
||||||
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">Additional notes</label>
|
|
||||||
<textarea name="notes" id="notes" rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 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" className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button>
|
|
||||||
<Link href={"/" + props.user.username + "/" + props.eventType.slug + (rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")}>
|
|
||||||
<a className="ml-2 btn btn-white">Cancel</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div className="mb-4">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{locations.length > 1 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="block text-sm font-medium text-gray-700">Location</span>
|
||||||
|
{locations.map((location) => (
|
||||||
|
<label key={location.type} className="block">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
required
|
||||||
|
onChange={(e) => setSelectedLocation(e.target.value)}
|
||||||
|
className="location"
|
||||||
|
name="location"
|
||||||
|
value={location.type}
|
||||||
|
checked={selectedLocation === location.type}
|
||||||
|
/>
|
||||||
|
<span className="text-sm ml-2">{locationLabels[location.type]}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedLocation === LocationType.Phone && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||||
|
Phone Number
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<PhoneInput
|
||||||
|
name="phone"
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
id="phone"
|
||||||
|
required
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{props.eventType.customInputs &&
|
||||||
|
props.eventType.customInputs
|
||||||
|
.sort((a, b) => a.id - b.id)
|
||||||
|
.map((input) => (
|
||||||
|
<div className="mb-4" key={"input-" + input.label.toLowerCase}>
|
||||||
|
{input.type !== EventTypeCustomInputType.Bool && (
|
||||||
|
<label
|
||||||
|
htmlFor={input.label}
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{input.label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{input.type === EventTypeCustomInputType.TextLong && (
|
||||||
|
<textarea
|
||||||
|
name={"custom_" + input.id}
|
||||||
|
id={"custom_" + input.id}
|
||||||
|
required={input.required}
|
||||||
|
rows={3}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{input.type === EventTypeCustomInputType.Text && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={"custom_" + input.id}
|
||||||
|
id={"custom_" + input.id}
|
||||||
|
required={input.required}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{input.type === EventTypeCustomInputType.Number && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name={"custom_" + input.id}
|
||||||
|
id={"custom_" + input.id}
|
||||||
|
required={input.required}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{input.type === EventTypeCustomInputType.Bool && (
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={"custom_" + input.id}
|
||||||
|
id={"custom_" + input.id}
|
||||||
|
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
<label htmlFor={input.label} className="block text-sm font-medium text-gray-700">
|
||||||
|
{input.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Additional notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
id="notes"
|
||||||
|
rows={3}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 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">
|
||||||
|
{rescheduleUid ? "Reschedule" : "Confirm"}
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
"/" +
|
||||||
|
props.user.username +
|
||||||
|
"/" +
|
||||||
|
props.eventType.slug +
|
||||||
|
(rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")
|
||||||
|
}>
|
||||||
|
<a className="ml-2 btn btn-white">Cancel</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mt-2">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-yellow-700">
|
||||||
|
Could not {rescheduleUid ? "reschedule" : "book"} the meeting. Please try again or{" "}
|
||||||
|
<a
|
||||||
|
href={"mailto:" + props.user.email}
|
||||||
|
className="font-medium underline text-yellow-700 hover:text-yellow-600">
|
||||||
|
Contact {props.user.name} via e-mail
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context) {
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username: context.query.user,
|
username: context.query.user,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
username: true,
|
username: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
bio: true,
|
||||||
|
avatar: true,
|
||||||
|
eventTypes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventType = await prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parseInt(context.query.type),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
length: true,
|
||||||
|
locations: true,
|
||||||
|
customInputs: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let booking = null;
|
||||||
|
|
||||||
|
if (context.query.rescheduleUid) {
|
||||||
|
booking = await prisma.booking.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: context.query.rescheduleUid,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
description: true,
|
||||||
|
attendees: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
email:true,
|
},
|
||||||
bio: true,
|
|
||||||
avatar: true,
|
|
||||||
eventTypes: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findUnique({
|
|
||||||
where: {
|
|
||||||
id: parseInt(context.query.type),
|
|
||||||
},
|
},
|
||||||
select: {
|
},
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
slug: true,
|
|
||||||
description: true,
|
|
||||||
length: true,
|
|
||||||
locations: true,
|
|
||||||
customInputs: true,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let booking = null;
|
return {
|
||||||
|
props: {
|
||||||
if(context.query.rescheduleUid) {
|
user,
|
||||||
booking = await prisma.booking.findFirst({
|
eventType,
|
||||||
where: {
|
booking,
|
||||||
uid: context.query.rescheduleUid
|
},
|
||||||
},
|
};
|
||||||
select: {
|
|
||||||
description: true,
|
|
||||||
attendees: {
|
|
||||||
select: {
|
|
||||||
email: true,
|
|
||||||
name: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
user,
|
|
||||||
eventType,
|
|
||||||
booking
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import prisma from "../../../lib/prisma";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { User, ResetPasswordRequest } from "@prisma/client";
|
||||||
|
import sendEmail from "../../../lib/emails/sendMail";
|
||||||
|
import { buildForgotPasswordMessage } from "../../../lib/forgot-password/messaging/forgot-password";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ message: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawEmail = req.body?.email;
|
||||||
|
|
||||||
|
const maybeUser: User = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email: rawEmail,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!maybeUser) {
|
||||||
|
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = dayjs().toDate();
|
||||||
|
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
|
||||||
|
where: {
|
||||||
|
email: rawEmail,
|
||||||
|
expires: {
|
||||||
|
gt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let passwordRequest: ResetPasswordRequest;
|
||||||
|
|
||||||
|
if (maybePreviousRequest && maybePreviousRequest?.length >= 1) {
|
||||||
|
passwordRequest = maybePreviousRequest[0];
|
||||||
|
} else {
|
||||||
|
const expiry = dayjs().add(6, "hours").toDate();
|
||||||
|
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
|
||||||
|
data: {
|
||||||
|
email: rawEmail,
|
||||||
|
expires: expiry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
passwordRequest = createdResetPasswordRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
|
||||||
|
const { subject, message } = buildForgotPasswordMessage({
|
||||||
|
user: {
|
||||||
|
name: maybeUser.name,
|
||||||
|
},
|
||||||
|
link: passwordResetLink,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: rawEmail,
|
||||||
|
subject: subject,
|
||||||
|
text: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ message: "Reset Requested", data: passwordRequest });
|
||||||
|
} catch (reason) {
|
||||||
|
console.error(reason);
|
||||||
|
return res.status(500).json({ message: "Unable to create password reset request" });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import prisma from "../../../lib/prisma";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { User, ResetPasswordRequest } from "@prisma/client";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
import { hashPassword } from "../../../lib/auth";
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(400).json({ message: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawPassword = req.body?.password;
|
||||||
|
const rawRequestId = req.body?.requestId;
|
||||||
|
|
||||||
|
if (!rawPassword || !rawRequestId) {
|
||||||
|
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeRequest: ResetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: rawRequestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!maybeRequest) {
|
||||||
|
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeUser: User = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email: maybeRequest.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!maybeUser) {
|
||||||
|
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(rawPassword);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: maybeUser.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ message: "Password reset." });
|
||||||
|
} catch (reason) {
|
||||||
|
console.error(reason);
|
||||||
|
return res.status(500).json({ message: "Unable to create password reset request" });
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,6 +22,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
customInputs: !req.body.customInputs
|
customInputs: !req.body.customInputs
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
|
deleteMany: {
|
||||||
|
eventTypeId: req.body.id,
|
||||||
|
NOT: {
|
||||||
|
id: {in: req.body.customInputs.filter(input => !!input.id).map(e => e.id)}
|
||||||
|
}
|
||||||
|
},
|
||||||
createMany: {
|
createMany: {
|
||||||
data: req.body.customInputs.filter(input => !input.id).map(input => ({
|
data: req.body.customInputs.filter(input => !input.id).map(input => ({
|
||||||
type: input.type,
|
type: input.type,
|
||||||
|
|
|
@ -1,38 +1,70 @@
|
||||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import prisma from '../../../lib/prisma';
|
import prisma from "../../../lib/prisma";
|
||||||
import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient';
|
import { CalendarEvent, createEvent, updateEvent, getBusyCalendarTimes } from "../../../lib/calendarClient";
|
||||||
import async from 'async';
|
import async from "async";
|
||||||
import {v5 as uuidv5} from 'uuid';
|
import { v5 as uuidv5 } from "uuid";
|
||||||
import short from 'short-uuid';
|
import short from "short-uuid";
|
||||||
import {createMeeting, updateMeeting} from "../../../lib/videoClient";
|
import { createMeeting, updateMeeting, getBusyVideoTimes } from "../../../lib/videoClient";
|
||||||
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
|
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
|
||||||
import {getEventName} from "../../../lib/event";
|
import { getEventName } from "../../../lib/event";
|
||||||
import { LocationType } from '../../../lib/location';
|
import { LocationType } from "../../../lib/location";
|
||||||
import merge from "lodash.merge"
|
import merge from "lodash.merge";
|
||||||
const translator = short();
|
const translator = short();
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
interface p {
|
const isAvailable = (busyTimes, time, length) => {
|
||||||
location: string
|
// Check for conflicts
|
||||||
|
let t = true;
|
||||||
|
busyTimes.forEach((busyTime) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if slot end time is between start and end time
|
||||||
|
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
|
||||||
|
t = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if startTime is between slot
|
||||||
|
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
|
||||||
|
t = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetLocationRequestFromIntegrationRequest {
|
||||||
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLocationRequestFromIntegration = ({location}: p) => {
|
const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromIntegrationRequest) => {
|
||||||
if (location === LocationType.GoogleMeet.valueOf()) {
|
if (location === LocationType.GoogleMeet.valueOf()) {
|
||||||
const requestId = uuidv5(location, uuidv5.URL)
|
const requestId = uuidv5(location, uuidv5.URL);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conferenceData: {
|
conferenceData: {
|
||||||
createRequest: {
|
createRequest: {
|
||||||
requestId: requestId
|
requestId: requestId,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {user} = req.query;
|
const { user } = req.query;
|
||||||
|
|
||||||
const currentUser = await prisma.user.findFirst({
|
const currentUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
@ -44,27 +76,61 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedCalendars = await prisma.selectedCalendar.findMany({
|
||||||
|
where: {
|
||||||
|
userId: currentUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
// Split credentials up into calendar credentials and video credentials
|
// Split credentials up into calendar credentials and video credentials
|
||||||
const calendarCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_calendar'));
|
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
||||||
const videoCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_video'));
|
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||||
|
|
||||||
|
const hasCalendarIntegrations =
|
||||||
|
currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
|
||||||
|
const hasVideoIntegrations =
|
||||||
|
currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0;
|
||||||
|
|
||||||
|
const calendarAvailability = await getBusyCalendarTimes(
|
||||||
|
currentUser.credentials,
|
||||||
|
dayjs(req.body.start).startOf("day").utc().format(),
|
||||||
|
dayjs(req.body.end).endOf("day").utc().format(),
|
||||||
|
selectedCalendars
|
||||||
|
);
|
||||||
|
const videoAvailability = await getBusyVideoTimes(
|
||||||
|
currentUser.credentials,
|
||||||
|
dayjs(req.body.start).startOf("day").utc().format(),
|
||||||
|
dayjs(req.body.end).endOf("day").utc().format()
|
||||||
|
);
|
||||||
|
let commonAvailability = [];
|
||||||
|
|
||||||
|
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
||||||
|
commonAvailability = calendarAvailability.filter((availability) =>
|
||||||
|
videoAvailability.includes(availability)
|
||||||
|
);
|
||||||
|
} else if (hasVideoIntegrations) {
|
||||||
|
commonAvailability = videoAvailability;
|
||||||
|
} else if (hasCalendarIntegrations) {
|
||||||
|
commonAvailability = calendarAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
const rescheduleUid = req.body.rescheduleUid;
|
const rescheduleUid = req.body.rescheduleUid;
|
||||||
|
|
||||||
const selectedEventType = await prisma.eventType.findFirst({
|
const selectedEventType = await prisma.eventType.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
id: req.body.eventTypeId
|
id: req.body.eventTypeId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
eventName: true,
|
eventName: true,
|
||||||
title: true
|
title: true,
|
||||||
}
|
length: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let rawLocation = req.body.location
|
const rawLocation = req.body.location;
|
||||||
|
|
||||||
let evt: CalendarEvent = {
|
let evt: CalendarEvent = {
|
||||||
type: selectedEventType.title,
|
type: selectedEventType.title,
|
||||||
|
@ -72,40 +138,44 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
description: req.body.notes,
|
description: req.body.notes,
|
||||||
startTime: req.body.start,
|
startTime: req.body.start,
|
||||||
endTime: req.body.end,
|
endTime: req.body.end,
|
||||||
organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone},
|
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
||||||
attendees: [
|
attendees: [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }],
|
||||||
{email: req.body.email, name: req.body.name, timeZone: req.body.timeZone}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// If phone or inPerson use raw location
|
// If phone or inPerson use raw location
|
||||||
// set evt.location to req.body.location
|
// set evt.location to req.body.location
|
||||||
if (!rawLocation.includes('integration')) {
|
if (!rawLocation?.includes("integration")) {
|
||||||
evt.location = rawLocation
|
evt.location = rawLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// If location is set to an integration location
|
// If location is set to an integration location
|
||||||
// Build proper transforms for evt object
|
// Build proper transforms for evt object
|
||||||
// Extend evt object with those transformations
|
// Extend evt object with those transformations
|
||||||
if (rawLocation.includes('integration')) {
|
if (rawLocation?.includes("integration")) {
|
||||||
let maybeLocationRequestObject = getLocationRequestFromIntegration({
|
const maybeLocationRequestObject = getLocationRequestFromIntegration({
|
||||||
location: rawLocation
|
location: rawLocation,
|
||||||
})
|
});
|
||||||
|
|
||||||
evt = merge(evt, maybeLocationRequestObject)
|
evt = merge(evt, maybeLocationRequestObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findFirst({
|
const eventType = await prisma.eventType.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
title: evt.type
|
title: evt.type,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true
|
id: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO isAvailable was throwing an error
|
||||||
|
const isAvailableToBeBooked = true;//isAvailable(commonAvailability, req.body.start, selectedEventType.length);
|
||||||
|
|
||||||
|
if (!isAvailableToBeBooked) {
|
||||||
|
return res.status(400).json({ message: `${currentUser.name} is unavailable at this time.` });
|
||||||
|
}
|
||||||
|
|
||||||
let results = [];
|
let results = [];
|
||||||
let referencesToCreate = [];
|
let referencesToCreate = [];
|
||||||
|
|
||||||
|
@ -113,7 +183,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
// Reschedule event
|
// Reschedule event
|
||||||
const booking = await prisma.booking.findFirst({
|
const booking = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
uid: rescheduleUid
|
uid: rescheduleUid,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -121,112 +191,144 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
type: true,
|
type: true,
|
||||||
uid: true
|
uid: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use all integrations
|
// Use all integrations
|
||||||
results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => {
|
results = results.concat(
|
||||||
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
|
await async.mapLimit(calendarCredentials, 5, async (credential) => {
|
||||||
const response = await updateEvent(credential, bookingRefUid, evt);
|
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) => {
|
||||||
|
console.error("updateEvent failed", e);
|
||||||
|
return { type: credential.type, success: false };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
results = results.concat(
|
||||||
type: credential.type,
|
await async.mapLimit(videoCredentials, 5, async (credential) => {
|
||||||
response
|
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) => {
|
||||||
|
console.error("updateMeeting failed", e);
|
||||||
|
return { type: credential.type, success: false };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
|
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||||
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
|
res.status(500).json({ message: "Rescheduling failed" });
|
||||||
const response = await updateMeeting(credential, bookingRefUid, evt);
|
return;
|
||||||
return {
|
}
|
||||||
type: credential.type,
|
|
||||||
response
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clone elements
|
// Clone elements
|
||||||
referencesToCreate = [...booking.references];
|
referencesToCreate = [...booking.references];
|
||||||
|
|
||||||
// Now we can delete the old booking and its references.
|
// Now we can delete the old booking and its references.
|
||||||
let bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
bookingId: booking.id
|
bookingId: booking.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
let attendeeDeletes = prisma.attendee.deleteMany({
|
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
bookingId: booking.id
|
bookingId: booking.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
let bookingDeletes = prisma.booking.delete({
|
const bookingDeletes = prisma.booking.delete({
|
||||||
where: {
|
where: {
|
||||||
uid: rescheduleUid
|
uid: rescheduleUid,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
|
||||||
bookingReferenceDeletes,
|
|
||||||
attendeeDeletes,
|
|
||||||
bookingDeletes
|
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
// Schedule event
|
// Schedule event
|
||||||
results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => {
|
results = results.concat(
|
||||||
const response = await createEvent(credential, evt);
|
await async.mapLimit(calendarCredentials, 5, async (credential) => {
|
||||||
return {
|
return createEvent(credential, evt)
|
||||||
type: credential.type,
|
.then((response) => ({ type: credential.type, success: true, response }))
|
||||||
response
|
.catch((e) => {
|
||||||
};
|
console.error("createEvent failed", e);
|
||||||
}));
|
return { type: credential.type, success: false };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
|
results = results.concat(
|
||||||
const response = await createMeeting(credential, evt);
|
await async.mapLimit(videoCredentials, 5, async (credential) => {
|
||||||
return {
|
return createMeeting(credential, evt)
|
||||||
type: credential.type,
|
.then((response) => ({ type: credential.type, success: true, response }))
|
||||||
response
|
.catch((e) => {
|
||||||
};
|
console.error("createMeeting failed", e);
|
||||||
}));
|
return { type: credential.type, success: false };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
referencesToCreate = results.map((result => {
|
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||||
|
res.status(500).json({ message: "Booking failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
referencesToCreate = results.map((result) => {
|
||||||
return {
|
return {
|
||||||
type: result.type,
|
type: result.type,
|
||||||
uid: result.response.createdEvent.id.toString()
|
uid: result.response.createdEvent.id.toString(),
|
||||||
};
|
};
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hashUID =
|
||||||
|
results.length > 0
|
||||||
|
? results[0].response.uid
|
||||||
|
: translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
|
||||||
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
|
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
|
||||||
// UID generation should happen in the integration itself, not here.
|
// UID generation should happen in the integration itself, not here.
|
||||||
const hashUID = results.length > 0 ? results[0].response.uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
|
if (results.length === 0) {
|
||||||
if(results.length === 0) {
|
|
||||||
// Legacy as well, as soon as we have a separate email integration class. Just used
|
// Legacy as well, as soon as we have a separate email integration class. Just used
|
||||||
// to send an email even if there is no integration at all.
|
// to send an email even if there is no integration at all.
|
||||||
const mail = new EventAttendeeMail(evt, hashUID);
|
try {
|
||||||
await mail.sendEmail();
|
const mail = new EventAttendeeMail(evt, hashUID);
|
||||||
|
await mail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Sending legacy event mail failed", e);
|
||||||
|
res.status(500).json({ message: "Booking failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.booking.create({
|
try {
|
||||||
data: {
|
await prisma.booking.create({
|
||||||
uid: hashUID,
|
data: {
|
||||||
userId: currentUser.id,
|
uid: hashUID,
|
||||||
references: {
|
userId: currentUser.id,
|
||||||
create: referencesToCreate
|
references: {
|
||||||
|
create: referencesToCreate,
|
||||||
|
},
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
|
||||||
|
title: evt.title,
|
||||||
|
description: evt.description,
|
||||||
|
startTime: evt.startTime,
|
||||||
|
endTime: evt.endTime,
|
||||||
|
|
||||||
|
attendees: {
|
||||||
|
create: evt.attendees,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
eventTypeId: eventType.id,
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error when saving booking to db", e);
|
||||||
|
res.status(500).json({ message: "Booking already exists" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
title: evt.title,
|
res.status(204).json({});
|
||||||
description: evt.description,
|
|
||||||
startTime: evt.startTime,
|
|
||||||
endTime: evt.endTime,
|
|
||||||
|
|
||||||
attendees: {
|
|
||||||
create: evt.attendees
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(200).json(results);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { getCsrfToken } from "next-auth/client";
|
||||||
|
import prisma from "../../../lib/prisma";
|
||||||
|
|
||||||
|
import Head from "next/head";
|
||||||
|
import React 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";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
resetPasswordRequest: ResetPasswordRequest;
|
||||||
|
csrfToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState(null);
|
||||||
|
const [success, setSuccess] = React.useState(false);
|
||||||
|
|
||||||
|
const [password, setPassword] = React.useState("");
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitChangePassword = async ({ password, requestId }) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ requestId: requestId, password: password }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(json);
|
||||||
|
} else {
|
||||||
|
setSuccess(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
} catch (reason) {
|
||||||
|
setError({ message: "An unexpected error occurred. Try again." });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedChangePassword = debounce(submitChangePassword, 250);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const Success = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Success</h2>
|
||||||
|
</div>
|
||||||
|
<p>Your password has been reset. You can now login with your newly created password.</p>
|
||||||
|
<Link href="/auth/login">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Expired = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Whoops</h2>
|
||||||
|
<h2 className="text-center text-3xl font-extrabold text-gray-900">That Request is Expired.</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
That request is expired. You can back and enter the email associated with your account and we will
|
||||||
|
you another link to reset your password.
|
||||||
|
</p>
|
||||||
|
<Link href="/auth/forgot-password">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRequestExpired = useMemo(() => {
|
||||||
|
const now = dayjs();
|
||||||
|
return dayjs(resetPasswordRequest.expires).isBefore(now);
|
||||||
|
}, [resetPasswordRequest]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<Head>
|
||||||
|
<title>Reset Password</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10 space-y-6">
|
||||||
|
{isRequestExpired && <Expired />}
|
||||||
|
{!isRequestExpired && !success && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Reset Password</h2>
|
||||||
|
<p>Enter the new password you'd like for your account.</p>
|
||||||
|
{error && <p className="text-red-600">{error.message}</p>}
|
||||||
|
</div>
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit} action="#">
|
||||||
|
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
onChange={handleChange}
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="password"
|
||||||
|
required
|
||||||
|
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
||||||
|
loading ? "cursor-not-allowed" : ""
|
||||||
|
}`}>
|
||||||
|
{loading && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-3 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>
|
||||||
|
)}
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isRequestExpired && success && (
|
||||||
|
<>
|
||||||
|
<Success />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
const id = context.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
expires: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
resetPasswordRequest: {
|
||||||
|
...resetPasswordRequest,
|
||||||
|
expires: resetPasswordRequest.expires.toString(),
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
csrfToken: await getCsrfToken({ req: context.req }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (reason) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
import Head from "next/head";
|
||||||
|
import React from "react";
|
||||||
|
import { getCsrfToken } from "next-auth/client";
|
||||||
|
import debounce from "lodash.debounce";
|
||||||
|
|
||||||
|
export default function Page({ csrfToken }) {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState(null);
|
||||||
|
const [success, setSuccess] = React.useState(false);
|
||||||
|
const [email, setEmail] = React.useState("");
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForgotPasswordRequest = async ({ email }) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email: email }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(json);
|
||||||
|
} else {
|
||||||
|
setSuccess(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
} catch (reason) {
|
||||||
|
setError({ message: "An unexpected error occurred. Try again." });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
await debouncedHandleSubmitPasswordRequest({ email });
|
||||||
|
};
|
||||||
|
|
||||||
|
const Success = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Done</h2>
|
||||||
|
<p>Check your email. We sent you a link to reset your password.</p>
|
||||||
|
{error && <p className="text-red-600">{error.message}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<Head>
|
||||||
|
<title>Forgot Password</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10 space-y-6">
|
||||||
|
{success && <Success />}
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Forgot Password</h2>
|
||||||
|
<p>
|
||||||
|
Enter the email address associated with your account and we will send you a link to reset
|
||||||
|
your password.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-red-600">{error.message}</p>}
|
||||||
|
</div>
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit} action="#">
|
||||||
|
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
onChange={handleChange}
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="john.doe@example.com"
|
||||||
|
required
|
||||||
|
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
||||||
|
loading ? "cursor-not-allowed" : ""
|
||||||
|
}`}>
|
||||||
|
{loading && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-3 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>
|
||||||
|
)}
|
||||||
|
Request Password Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page.getInitialProps = async ({ req }) => {
|
||||||
|
return {
|
||||||
|
csrfToken: await getCsrfToken({ req }),
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,55 +1,79 @@
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import { getCsrfToken } from 'next-auth/client';
|
import Link from "next/link";
|
||||||
|
import { getCsrfToken } from "next-auth/client";
|
||||||
|
|
||||||
export default function Login({ csrfToken }) {
|
export default function Login({ csrfToken }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
<Head>
|
<Head>
|
||||||
<title>Login</title>
|
<title>Login</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Sign in to your account</h2>
|
||||||
Sign in to your account
|
</div>
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10">
|
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10">
|
||||||
<form className="space-y-6" method="post" action="/api/auth/callback/credentials">
|
<form className="space-y-6" method="post" action="/api/auth/callback/credentials">
|
||||||
<input name='csrfToken' type='hidden' defaultValue={csrfToken} hidden/>
|
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<input id="email" name="email" type="email" autoComplete="email" placeholder="john.doe@example.com" required className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
|
<input
|
||||||
</div>
|
id="email"
|
||||||
</div>
|
name="email"
|
||||||
|
type="email"
|
||||||
<div>
|
autoComplete="email"
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
placeholder="john.doe@example.com"
|
||||||
Password
|
required
|
||||||
</label>
|
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
<div className="mt-1">
|
/>
|
||||||
<input id="password" name="password" type="password" autoComplete="current-password" placeholder="•••••••••••••" required className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="submit" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="•••••••••••••"
|
||||||
|
required
|
||||||
|
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
<Link href="/auth/forgot-password">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Forgot Password?
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Login.getInitialProps = async ({ req, res }) => {
|
Login.getInitialProps = async ({ req }) => {
|
||||||
return {
|
return {
|
||||||
csrfToken: await getCsrfToken({ req })
|
csrfToken: await getCsrfToken({ req }),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
@ -19,7 +19,8 @@ dayjs.extend(utc);
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default function EventType(props) {
|
export default function EventType(props: any): JSX.Element {
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const inputOptions: OptionBase[] = [
|
const inputOptions: OptionBase[] = [
|
||||||
|
@ -35,6 +36,7 @@ export default function EventType(props) {
|
||||||
const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]);
|
const [selectedInputOption, setSelectedInputOption] = useState<OptionBase>(inputOptions[0]);
|
||||||
const [locations, setLocations] = useState(props.eventType.locations || []);
|
const [locations, setLocations] = useState(props.eventType.locations || []);
|
||||||
const [schedule, setSchedule] = useState(undefined);
|
const [schedule, setSchedule] = useState(undefined);
|
||||||
|
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
||||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||||
props.eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
props.eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||||
);
|
);
|
||||||
|
@ -131,41 +133,7 @@ export default function EventType(props) {
|
||||||
const closeAddCustomModal = () => {
|
const closeAddCustomModal = () => {
|
||||||
setSelectedInputOption(inputOptions[0]);
|
setSelectedInputOption(inputOptions[0]);
|
||||||
setShowAddCustomModal(false);
|
setShowAddCustomModal(false);
|
||||||
};
|
setSelectedCustomInput(undefined);
|
||||||
|
|
||||||
const LocationOptions = () => {
|
|
||||||
if (!selectedLocation) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
switch (selectedLocation.value) {
|
|
||||||
case LocationType.InPerson: {
|
|
||||||
const address = locations.find((location) => location.type === LocationType.InPerson)?.address;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
|
||||||
Set an address or place
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="address"
|
|
||||||
id="address"
|
|
||||||
required
|
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
|
||||||
defaultValue={address}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case LocationType.Phone:
|
|
||||||
return (
|
|
||||||
<p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p>
|
|
||||||
);
|
|
||||||
case LocationType.GoogleMeet:
|
|
||||||
return <p className="text-sm">Calendso will provide a Google Meet location.</p>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateLocations = (e) => {
|
const updateLocations = (e) => {
|
||||||
|
@ -192,6 +160,47 @@ export default function EventType(props) {
|
||||||
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
|
||||||
|
setSelectedCustomInput(customInput);
|
||||||
|
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
|
||||||
|
setShowAddCustomModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LocationOptions = () => {
|
||||||
|
if (!selectedLocation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switch (selectedLocation.value) {
|
||||||
|
case LocationType.InPerson:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||||
|
Set an address or place
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="address"
|
||||||
|
id="address"
|
||||||
|
required
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
defaultValue={locations.find((location) => location.type === LocationType.InPerson)?.address}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case LocationType.Phone:
|
||||||
|
return (
|
||||||
|
<p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p>
|
||||||
|
);
|
||||||
|
case LocationType.GoogleMeet:
|
||||||
|
return <p className="text-sm">Calendso will provide a Google Meet location.</p>;
|
||||||
|
case LocationType.Zoom:
|
||||||
|
return <p className="text-sm">Calendso will provide a Zoom meeting URL.</p>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const updateCustom = (e) => {
|
const updateCustom = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
@ -201,9 +210,28 @@ export default function EventType(props) {
|
||||||
type: e.target.type.value,
|
type: e.target.type.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
setCustomInputs(customInputs.concat(customInput));
|
if (e.target.id?.value) {
|
||||||
|
const index = customInputs.findIndex((inp) => inp.id === +e.target.id?.value);
|
||||||
|
if (index >= 0) {
|
||||||
|
const input = customInputs[index];
|
||||||
|
input.label = customInput.label;
|
||||||
|
input.required = customInput.required;
|
||||||
|
input.type = customInput.type;
|
||||||
|
setCustomInputs(customInputs);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCustomInputs(customInputs.concat(customInput));
|
||||||
|
}
|
||||||
|
closeAddCustomModal();
|
||||||
|
};
|
||||||
|
|
||||||
setShowAddCustomModal(false);
|
const removeCustom = (customInput, e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const index = customInputs.findIndex((inp) => inp.id === customInput.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
customInputs.splice(index, 1);
|
||||||
|
setCustomInputs([...customInputs]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -309,6 +337,50 @@ export default function EventType(props) {
|
||||||
<span className="ml-2 text-sm">Google Meet</span>
|
<span className="ml-2 text-sm">Google Meet</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{location.type === LocationType.Zoom && (
|
||||||
|
<div className="flex-grow flex">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 1329.08 1329.08"
|
||||||
|
height="1.25em"
|
||||||
|
width="1.25em"
|
||||||
|
shapeRendering="geometricPrecision"
|
||||||
|
textRendering="geometricPrecision"
|
||||||
|
imageRendering="optimizeQuality"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd">
|
||||||
|
<g id="Layer_x0020_1">
|
||||||
|
<g id="_2116467169744">
|
||||||
|
<path
|
||||||
|
d="M664.54 0c367.02 0 664.54 297.52 664.54 664.54s-297.52 664.54-664.54 664.54S0 1031.56 0 664.54 297.52 0 664.54 0z"
|
||||||
|
fill="#e5e5e4"
|
||||||
|
fillRule="nonzero"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: "#fff",
|
||||||
|
fillRule: "nonzero",
|
||||||
|
}}
|
||||||
|
d="M664.54 12.94c359.87 0 651.6 291.73 651.6 651.6s-291.73 651.6-651.6 651.6-651.6-291.73-651.6-651.6 291.74-651.6 651.6-651.6z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M664.54 65.21c331 0 599.33 268.33 599.33 599.33 0 331-268.33 599.33-599.33 599.33-331 0-599.33-268.33-599.33-599.33 0-331 268.33-599.33 599.33-599.33z"
|
||||||
|
fill="#4a8cff"
|
||||||
|
fillRule="nonzero"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: "#fff",
|
||||||
|
fillRule: "nonzero",
|
||||||
|
}}
|
||||||
|
d="M273.53 476.77v281.65c.25 63.69 52.27 114.95 115.71 114.69h410.55c11.67 0 21.06-9.39 21.06-20.81V570.65c-.25-63.69-52.27-114.95-115.7-114.69H294.6c-11.67 0-21.06 9.39-21.06 20.81zm573.45 109.87l169.5-123.82c14.72-12.18 26.13-9.14 26.13 12.94v377.56c0 25.12-13.96 22.08-26.13 12.94l-169.5-123.57V586.64z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span className="ml-2 text-sm">Zoom Video</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -393,7 +465,7 @@ export default function EventType(props) {
|
||||||
</label>
|
</label>
|
||||||
<ul className="w-96 mt-1">
|
<ul className="w-96 mt-1">
|
||||||
{customInputs.map((customInput) => (
|
{customInputs.map((customInput) => (
|
||||||
<li key={customInput.type} className="bg-blue-50 mb-2 p-2 border">
|
<li key={customInput.label} className="bg-blue-50 mb-2 p-2 border">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -409,10 +481,13 @@ export default function EventType(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button type="button" className="mr-2 text-sm text-blue-600">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditCustomModel(customInput)}
|
||||||
|
className="mr-2 text-sm text-blue-600">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button>
|
<button onClick={(e) => removeCustom(customInput, e)}>
|
||||||
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " />
|
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -447,7 +522,7 @@ export default function EventType(props) {
|
||||||
Hide this event type
|
Hide this event type
|
||||||
</label>
|
</label>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
Hide the event type from your page, so it can only be booked through it's URL.
|
Hide the event type from your page, so it can only be booked through its URL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -599,6 +674,7 @@ export default function EventType(props) {
|
||||||
id="label"
|
id="label"
|
||||||
required
|
required
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
defaultValue={selectedCustomInput?.label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -608,13 +684,13 @@ export default function EventType(props) {
|
||||||
name="required"
|
name="required"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
|
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
|
||||||
defaultChecked={true}
|
defaultChecked={selectedCustomInput?.required ?? true}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||||
Is required
|
Is required
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" name="id" id="id" value={selectedCustomInput?.id} />
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Save
|
Save
|
||||||
|
@ -640,7 +716,7 @@ const validJson = (jsonString: string) => {
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// no longer empty
|
console.log("Invalid JSON:", e);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
@ -650,7 +726,6 @@ export async function getServerSideProps(context) {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
|
@ -715,6 +790,7 @@ export async function getServerSideProps(context) {
|
||||||
const locationOptions: OptionBase[] = [
|
const locationOptions: OptionBase[] = [
|
||||||
{ value: LocationType.InPerson, label: "In-person meeting" },
|
{ value: LocationType.InPerson, label: "In-person meeting" },
|
||||||
{ value: LocationType.Phone, label: "Phone call" },
|
{ value: LocationType.Phone, label: "Phone call" },
|
||||||
|
{ value: LocationType.Zoom, label: "Zoom Video" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasGoogleCalendarIntegration = integrations.find(
|
const hasGoogleCalendarIntegration = integrations.find(
|
||||||
|
@ -746,7 +822,7 @@ export async function getServerSideProps(context) {
|
||||||
const schedules = getAvailability(eventType) ||
|
const schedules = getAvailability(eventType) ||
|
||||||
getAvailability(user) || [
|
getAvailability(user) || [
|
||||||
{
|
{
|
||||||
days: [1, 2, 3, 4, 5, 6, 7],
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
startTime: user.startTime,
|
startTime: user.startTime,
|
||||||
length: user.endTime >= 1440 ? 1439 : user.endTime,
|
length: user.endTime >= 1440 ? 1439 : user.endTime,
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
import Head from "next/head";
|
||||||
|
import prisma from "../../lib/prisma";
|
||||||
|
import { getSession, useSession } from "next-auth/client";
|
||||||
|
import Shell from "../../components/Shell";
|
||||||
|
|
||||||
|
export default function Bookings({ bookings }) {
|
||||||
|
const [session, loading] = useSession();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-gray-400">Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>Bookings | Calendso</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<Shell heading="Bookings">
|
||||||
|
<div className="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-lg">
|
||||||
|
<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">
|
||||||
|
Title
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="relative px-6 py-3">
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{bookings.map((booking) => (
|
||||||
|
<tr key={booking.uid}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{booking.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{booking.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{booking.attendees[0].name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{booking.attendees[0].email}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<a
|
||||||
|
href={window.location.href + "/../reschedule/" + booking.uid}
|
||||||
|
className="text-blue-600 hover:text-blue-900">
|
||||||
|
Reschedule
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={window.location.href + "/../cancel/" + booking.uid}
|
||||||
|
className="ml-4 text-blue-600 hover:text-blue-900">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(context) {
|
||||||
|
const session = await getSession(context);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: session.user.email,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
uid: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
attendees: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { props: { bookings } };
|
||||||
|
}
|
|
@ -1,140 +1,173 @@
|
||||||
import Head from 'next/head';
|
import { GetServerSideProps } from "next";
|
||||||
import Link from 'next/link';
|
import Head from "next/head";
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from "react";
|
||||||
import { useRouter } from 'next/router';
|
import prisma from "../../lib/prisma";
|
||||||
import prisma from '../../lib/prisma';
|
import Modal from "../../components/Modal";
|
||||||
import Modal from '../../components/Modal';
|
import Shell from "../../components/Shell";
|
||||||
import Shell from '../../components/Shell';
|
import SettingsShell from "../../components/Settings";
|
||||||
import SettingsShell from '../../components/Settings';
|
import Avatar from "../../components/Avatar";
|
||||||
import Avatar from '../../components/Avatar';
|
import { getSession } from "next-auth/client";
|
||||||
import { signIn, useSession, getSession } from 'next-auth/client';
|
import TimezoneSelect from "react-timezone-select";
|
||||||
import TimezoneSelect from 'react-timezone-select';
|
import { UsernameInput } from "../../components/ui/UsernameInput";
|
||||||
import {UsernameInput} from "../../components/ui/UsernameInput";
|
|
||||||
import ErrorAlert from "../../components/ui/alerts/Error";
|
import ErrorAlert from "../../components/ui/alerts/Error";
|
||||||
|
|
||||||
export default function Settings(props) {
|
export default function Settings(props) {
|
||||||
const [ session, loading ] = useSession();
|
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||||
const router = useRouter();
|
const usernameRef = useRef<HTMLInputElement>();
|
||||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
const nameRef = useRef<HTMLInputElement>();
|
||||||
const usernameRef = useRef<HTMLInputElement>();
|
const descriptionRef = useRef<HTMLTextAreaElement>();
|
||||||
const nameRef = useRef<HTMLInputElement>();
|
const avatarRef = useRef<HTMLInputElement>();
|
||||||
const descriptionRef = useRef<HTMLTextAreaElement>();
|
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
|
||||||
const avatarRef = useRef<HTMLInputElement>();
|
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState(props.user.weekStart || "Sunday");
|
||||||
|
|
||||||
const [ selectedTimeZone, setSelectedTimeZone ] = useState({ value: props.user.timeZone });
|
const [hasErrors, setHasErrors] = useState(false);
|
||||||
const [ selectedWeekStartDay, setSelectedWeekStartDay ] = useState(props.user.weekStart || 'Sunday');
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const [ hasErrors, setHasErrors ] = useState(false);
|
const closeSuccessModal = () => {
|
||||||
const [ errorMessage, setErrorMessage ] = useState('');
|
setSuccessModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
const handleError = async (resp) => {
|
||||||
return <p className="text-gray-400">Loading...</p>;
|
if (!resp.ok) {
|
||||||
|
const error = await resp.json();
|
||||||
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeSuccessModal = () => { setSuccessModalOpen(false); }
|
async function updateProfileHandler(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
const handleError = async (resp) => {
|
const enteredUsername = usernameRef.current.value.toLowerCase();
|
||||||
if (!resp.ok) {
|
const enteredName = nameRef.current.value;
|
||||||
const error = await resp.json();
|
const enteredDescription = descriptionRef.current.value;
|
||||||
throw new Error(error.message);
|
const enteredAvatar = avatarRef.current.value;
|
||||||
}
|
const enteredTimeZone = selectedTimeZone.value;
|
||||||
}
|
const enteredWeekStartDay = selectedWeekStartDay;
|
||||||
|
|
||||||
async function updateProfileHandler(event) {
|
// TODO: Add validation
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const enteredUsername = usernameRef.current.value;
|
await fetch("/api/user/profile", {
|
||||||
const enteredName = nameRef.current.value;
|
method: "PATCH",
|
||||||
const enteredDescription = descriptionRef.current.value;
|
body: JSON.stringify({
|
||||||
const enteredAvatar = avatarRef.current.value;
|
username: enteredUsername,
|
||||||
const enteredTimeZone = selectedTimeZone.value;
|
name: enteredName,
|
||||||
const enteredWeekStartDay = selectedWeekStartDay;
|
description: enteredDescription,
|
||||||
|
avatar: enteredAvatar,
|
||||||
|
timeZone: enteredTimeZone,
|
||||||
|
weekStart: enteredWeekStartDay,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(handleError)
|
||||||
|
.then(() => {
|
||||||
|
setSuccessModalOpen(true);
|
||||||
|
setHasErrors(false); // dismiss any open errors
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setHasErrors(true);
|
||||||
|
setErrorMessage(err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Add validation
|
return (
|
||||||
|
<Shell heading="Profile">
|
||||||
|
<Head>
|
||||||
|
<title>Profile | Calendso</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<SettingsShell>
|
||||||
|
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
||||||
|
{hasErrors && <ErrorAlert message={errorMessage} />}
|
||||||
|
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg leading-6 font-medium text-gray-900">Profile</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Review and change your public page details.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
const response = await fetch('/api/user/profile', {
|
<div className="mt-6 flex flex-col lg:flex-row">
|
||||||
method: 'PATCH',
|
<div className="flex-grow space-y-6">
|
||||||
body: JSON.stringify({username: enteredUsername, name: enteredName, description: enteredDescription, avatar: enteredAvatar, timeZone: enteredTimeZone, weekStart: enteredWeekStartDay}),
|
<div className="flex">
|
||||||
headers: {
|
<div className="w-1/2 mr-2">
|
||||||
'Content-Type': 'application/json'
|
<UsernameInput ref={usernameRef} defaultValue={props.user.username} />
|
||||||
}
|
</div>
|
||||||
}).then(handleError).then( () => {
|
<div className="w-1/2 ml-2">
|
||||||
setSuccessModalOpen(true);
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
setHasErrors(false); // dismiss any open errors
|
Full name
|
||||||
}).catch( (err) => {
|
</label>
|
||||||
setHasErrors(true);
|
<input
|
||||||
setErrorMessage(err.message);
|
ref={nameRef}
|
||||||
});
|
type="text"
|
||||||
}
|
name="name"
|
||||||
|
id="name"
|
||||||
|
autoComplete="given-name"
|
||||||
|
placeholder="Your name"
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
defaultValue={props.user.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
return(
|
<div>
|
||||||
<Shell heading="Profile">
|
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
|
||||||
<Head>
|
About
|
||||||
<title>Profile | Calendso</title>
|
</label>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<div className="mt-1">
|
||||||
</Head>
|
<textarea
|
||||||
<SettingsShell>
|
ref={descriptionRef}
|
||||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
id="about"
|
||||||
{hasErrors && <ErrorAlert message={errorMessage} />}
|
name="about"
|
||||||
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
placeholder="A little something about yourself."
|
||||||
<div>
|
rows={3}
|
||||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Profile</h2>
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
{props.user.bio}
|
||||||
Review and change your public page details.
|
</textarea>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||||
|
Timezone
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<TimezoneSelect
|
||||||
|
id="timeZone"
|
||||||
|
value={selectedTimeZone}
|
||||||
|
onChange={setSelectedTimeZone}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="weekStart" className="block text-sm font-medium text-gray-700">
|
||||||
|
First Day of Week
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<select
|
||||||
|
id="weekStart"
|
||||||
|
value={selectedWeekStartDay}
|
||||||
|
onChange={(e) => setSelectedWeekStartDay(e.target.value)}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">
|
||||||
|
<option value="Sunday">Sunday</option>
|
||||||
|
<option value="Monday">Monday</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col lg:flex-row">
|
<div className="mt-6 flex-grow lg:mt-0 lg:ml-6 lg:flex-grow-0 lg:flex-shrink-0">
|
||||||
<div className="flex-grow space-y-6">
|
<p className="mb-2 text-sm font-medium text-gray-700" aria-hidden="true">
|
||||||
<div className="flex">
|
Photo
|
||||||
<div className="w-1/2 mr-2">
|
</p>
|
||||||
<UsernameInput ref={usernameRef} defaultValue={props.user.username} />
|
<div className="mt-1 lg:hidden">
|
||||||
</div>
|
<div className="flex items-center">
|
||||||
<div className="w-1/2 ml-2">
|
<div
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Full name</label>
|
className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12"
|
||||||
<input ref={nameRef} type="text" name="name" id="name" autoComplete="given-name" placeholder="Your name" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" defaultValue={props.user.name} />
|
aria-hidden="true">
|
||||||
</div>
|
<Avatar user={props.user} className="rounded-full h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
{/* <div className="ml-5 rounded-md shadow-sm">
|
||||||
<div>
|
|
||||||
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
|
|
||||||
About
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<textarea ref={descriptionRef} id="about" name="about" placeholder="A little something about yourself." rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">{props.user.bio}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
|
||||||
Timezone
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<TimezoneSelect id="timeZone" value={selectedTimeZone} onChange={setSelectedTimeZone} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="weekStart" className="block text-sm font-medium text-gray-700">
|
|
||||||
First Day of Week
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<select id="weekStart" value={selectedWeekStartDay} onChange={e => setSelectedWeekStartDay(e.target.value)} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">
|
|
||||||
<option value="Sunday">Sunday</option>
|
|
||||||
<option value="Monday">Monday</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex-grow lg:mt-0 lg:ml-6 lg:flex-grow-0 lg:flex-shrink-0">
|
|
||||||
<p className="mb-2 text-sm font-medium text-gray-700" aria-hidden="true">
|
|
||||||
Photo
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 lg:hidden">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12" aria-hidden="true">
|
|
||||||
<Avatar user={props.user} className="rounded-full h-full w-full" />
|
|
||||||
</div>
|
|
||||||
{/* <div className="ml-5 rounded-md shadow-sm">
|
|
||||||
<div className="group relative border border-gray-300 rounded-md py-2 px-3 flex items-center justify-center hover:bg-gray-50 focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
|
<div className="group relative border border-gray-300 rounded-md py-2 px-3 flex items-center justify-center hover:bg-gray-50 focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
|
||||||
<label htmlFor="user_photo" className="relative text-sm leading-4 font-medium text-gray-700 pointer-events-none">
|
<label htmlFor="user_photo" className="relative text-sm leading-4 font-medium text-gray-700 pointer-events-none">
|
||||||
<span>Change</span>
|
<span>Change</span>
|
||||||
|
@ -143,64 +176,81 @@ export default function Settings(props) {
|
||||||
<input id="user_photo" name="user_photo" type="file" className="absolute w-full h-full opacity-0 cursor-pointer border-gray-300 rounded-md" />
|
<input id="user_photo" name="user_photo" type="file" className="absolute w-full h-full opacity-0 cursor-pointer border-gray-300 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div> */}
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden relative rounded-full overflow-hidden lg:block">
|
<div className="hidden relative rounded-full overflow-hidden lg:block">
|
||||||
<Avatar
|
<Avatar
|
||||||
user={props.user}
|
user={props.user}
|
||||||
className="relative rounded-full w-40 h-40"
|
className="relative rounded-full w-40 h-40"
|
||||||
fallback={<div className="relative bg-blue-600 rounded-full w-40 h-40"></div>}
|
fallback={<div className="relative bg-blue-600 rounded-full w-40 h-40"></div>}
|
||||||
/>
|
/>
|
||||||
{/* <label htmlFor="user-photo" className="absolute inset-0 w-full h-full bg-black bg-opacity-75 flex items-center justify-center text-sm font-medium text-white opacity-0 hover:opacity-100 focus-within:opacity-100">
|
{/* <label htmlFor="user-photo" className="absolute inset-0 w-full h-full bg-black bg-opacity-75 flex items-center justify-center text-sm font-medium text-white opacity-0 hover:opacity-100 focus-within:opacity-100">
|
||||||
<span>Change</span>
|
<span>Change</span>
|
||||||
<span className="sr-only"> user photo</span>
|
<span className="sr-only"> user photo</span>
|
||||||
<input type="file" id="user-photo" name="user-photo" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer border-gray-300 rounded-md" />
|
<input type="file" id="user-photo" name="user-photo" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer border-gray-300 rounded-md" />
|
||||||
</label> */}
|
</label> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<label htmlFor="avatar" className="block text-sm font-medium text-gray-700">Avatar URL</label>
|
<label htmlFor="avatar" className="block text-sm font-medium text-gray-700">
|
||||||
<input ref={avatarRef} type="text" name="avatar" id="avatar" placeholder="URL" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" defaultValue={props.user.avatar} />
|
Avatar URL
|
||||||
</div>
|
</label>
|
||||||
</div>
|
<input
|
||||||
</div>
|
ref={avatarRef}
|
||||||
<hr className="mt-8" />
|
type="text"
|
||||||
<div className="py-4 flex justify-end">
|
name="avatar"
|
||||||
<button type="submit" className="ml-2 bg-blue-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
id="avatar"
|
||||||
Save
|
placeholder="URL"
|
||||||
</button>
|
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
</div>
|
defaultValue={props.user.avatar}
|
||||||
</div>
|
/>
|
||||||
</form>
|
</div>
|
||||||
<Modal heading="Profile updated successfully" description="Your user profile has been updated successfully." open={successModalOpen} handleClose={closeSuccessModal} />
|
</div>
|
||||||
</SettingsShell>
|
</div>
|
||||||
</Shell>
|
<hr className="mt-8" />
|
||||||
);
|
<div className="py-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="ml-2 bg-blue-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<Modal
|
||||||
|
heading="Profile updated successfully"
|
||||||
|
description="Your user profile has been updated successfully."
|
||||||
|
open={successModalOpen}
|
||||||
|
handleClose={closeSuccessModal}
|
||||||
|
/>
|
||||||
|
</SettingsShell>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
const session = await getSession(context);
|
const session = await getSession(context);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { redirect: { permanent: false, destination: '/auth/login' } };
|
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
bio: true,
|
bio: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
weekStart: true,
|
weekStart: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user}, // will be passed to the page component as props
|
props: { user }, // will be passed to the page component as props
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
@ -159,3 +159,11 @@ model EventTypeCustomInput {
|
||||||
required Boolean
|
required Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ResetPasswordRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
email String
|
||||||
|
expires DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
|
39
yarn.lock
39
yarn.lock
|
@ -902,6 +902,13 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215"
|
||||||
integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA==
|
integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA==
|
||||||
|
|
||||||
|
"@types/nodemailer@^6.4.2":
|
||||||
|
version "6.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.2.tgz#d8ee254c969e6ad83fb9a0a0df3a817406a3fa3b"
|
||||||
|
integrity sha512-yhsqg5Xbr8aWdwjFS3QjkniW5/tLpWXtOYQcJdo9qE3DolBxsKzgRCQrteaMY0hos8MklJNSEsMqDpZynGzMNg==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/parse-json@^4.0.0":
|
"@types/parse-json@^4.0.0":
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
||||||
|
@ -2874,6 +2881,18 @@ gtoken@^5.0.4:
|
||||||
google-p12-pem "^3.0.3"
|
google-p12-pem "^3.0.3"
|
||||||
jws "^4.0.0"
|
jws "^4.0.0"
|
||||||
|
|
||||||
|
handlebars@^4.7.7:
|
||||||
|
version "4.7.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1"
|
||||||
|
integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==
|
||||||
|
dependencies:
|
||||||
|
minimist "^1.2.5"
|
||||||
|
neo-async "^2.6.0"
|
||||||
|
source-map "^0.6.1"
|
||||||
|
wordwrap "^1.0.0"
|
||||||
|
optionalDependencies:
|
||||||
|
uglify-js "^3.1.4"
|
||||||
|
|
||||||
has-ansi@^2.0.0:
|
has-ansi@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
|
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
|
||||||
|
@ -4075,6 +4094,11 @@ lodash.clonedeep@^4.5.0:
|
||||||
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
||||||
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
|
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
|
||||||
|
|
||||||
|
lodash.debounce@^4.0.8:
|
||||||
|
version "4.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||||
|
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
|
||||||
|
|
||||||
lodash.includes@^4.3.0:
|
lodash.includes@^4.3.0:
|
||||||
version "4.3.0"
|
version "4.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||||
|
@ -4331,6 +4355,11 @@ natural-compare@^1.4.0:
|
||||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||||
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
|
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
|
||||||
|
|
||||||
|
neo-async@^2.6.0:
|
||||||
|
version "2.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
||||||
|
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
||||||
|
|
||||||
next-auth@^3.13.2:
|
next-auth@^3.13.2:
|
||||||
version "3.19.8"
|
version "3.19.8"
|
||||||
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-3.19.8.tgz#32331f33dd73b46ec5c774735a9db78f9dbba3c7"
|
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-3.19.8.tgz#32331f33dd73b46ec5c774735a9db78f9dbba3c7"
|
||||||
|
@ -6032,6 +6061,11 @@ typescript@^4.2.3:
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
|
||||||
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
|
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
|
||||||
|
|
||||||
|
uglify-js@^3.1.4:
|
||||||
|
version "3.13.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.9.tgz#4d8d21dcd497f29cfd8e9378b9df123ad025999b"
|
||||||
|
integrity sha512-wZbyTQ1w6Y7fHdt8sJnHfSIuWeDgk6B5rCb4E/AM6QNNPbOMIZph21PW5dRB3h7Df0GszN+t7RuUH6sWK5bF0g==
|
||||||
|
|
||||||
unbox-primitive@^1.0.0, unbox-primitive@^1.0.1:
|
unbox-primitive@^1.0.0, unbox-primitive@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
|
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
|
||||||
|
@ -6254,6 +6288,11 @@ word-wrap@^1.2.3, word-wrap@~1.2.3:
|
||||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
|
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
|
||||||
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
|
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
|
||||||
|
|
||||||
|
wordwrap@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||||
|
integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=
|
||||||
|
|
||||||
wrap-ansi@^6.2.0:
|
wrap-ansi@^6.2.0:
|
||||||
version "6.2.0"
|
version "6.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
||||||
|
|
Loading…
Reference in New Issue