Merge branch 'main' into feature/refresh-tokens-and-error-handling
This commit is contained in:
commit
917b2c4821
28 changed files with 2710 additions and 4982 deletions
34
.eslintrc.json
Normal file
34
.eslintrc.json
Normal file
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module",
|
||||
"modules": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"],
|
||||
"rules": {
|
||||
"prettier/prettier": ["error"],
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
1
.husky/.gitignore
vendored
Normal file
1
.husky/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
_
|
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
6
.prettierignore
Normal file
6
.prettierignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.next
|
||||
public
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
10
.prettierrc.js
Normal file
10
.prettierrc.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
bracketSpacing: true,
|
||||
jsxBracketSameLine: true,
|
||||
singleQuote: false,
|
||||
jsxSingleQuote: false,
|
||||
trailingComma: "es5",
|
||||
semi: true,
|
||||
printWidth: 110,
|
||||
arrowParens: "always",
|
||||
};
|
|
@ -1,152 +1,273 @@
|
|||
import Link from 'next/link';
|
||||
import {useEffect, useState} from "react";
|
||||
import {useRouter} from "next/router";
|
||||
import {signOut, useSession} from 'next-auth/client';
|
||||
import {MenuIcon, XIcon} from '@heroicons/react/outline';
|
||||
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../lib/telemetry";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { signOut, useSession } from "next-auth/client";
|
||||
import { MenuIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib/telemetry";
|
||||
|
||||
export default function Shell(props) {
|
||||
const router = useRouter();
|
||||
const [ session, loading ] = useSession();
|
||||
const [ profileDropdownExpanded, setProfileDropdownExpanded ] = useState(false);
|
||||
const [ mobileMenuExpanded, setMobileMenuExpanded ] = useState(false);
|
||||
let telemetry = useTelemetry();
|
||||
const router = useRouter();
|
||||
const [session, loading] = useSession();
|
||||
const [profileDropdownExpanded, setProfileDropdownExpanded] = useState(false);
|
||||
const [mobileMenuExpanded, setMobileMenuExpanded] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => {
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname))
|
||||
});
|
||||
}, [telemetry])
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => {
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname));
|
||||
});
|
||||
}, [telemetry]);
|
||||
|
||||
const toggleProfileDropdown = () => {
|
||||
setProfileDropdownExpanded(!profileDropdownExpanded);
|
||||
}
|
||||
const toggleProfileDropdown = () => {
|
||||
setProfileDropdownExpanded(!profileDropdownExpanded);
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuExpanded(!mobileMenuExpanded);
|
||||
}
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuExpanded(!mobileMenuExpanded);
|
||||
};
|
||||
|
||||
const logoutHandler = () => {
|
||||
signOut({ redirect: false }).then( () => router.push('/auth/logout') );
|
||||
}
|
||||
const logoutHandler = () => {
|
||||
signOut({ redirect: false }).then(() => router.push("/auth/logout"));
|
||||
};
|
||||
|
||||
if ( ! loading && ! session ) {
|
||||
router.replace('/auth/login');
|
||||
}
|
||||
if (!loading && !session) {
|
||||
router.replace("/auth/login");
|
||||
}
|
||||
|
||||
return session && (
|
||||
<div>
|
||||
<div className="bg-gradient-to-b from-blue-600 via-blue-600 to-blue-300 pb-32">
|
||||
<nav className="bg-blue-600">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<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">
|
||||
<div className="flex-shrink-0">
|
||||
<img className="h-6" src="/calendso-white.svg" alt="Calendso" />
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-4">
|
||||
<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>
|
||||
</Link>
|
||||
{/* <Link href="/">
|
||||
<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>
|
||||
</Link> */}
|
||||
<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>
|
||||
</Link>
|
||||
<Link href="/integrations">
|
||||
<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>
|
||||
<Link href="/settings/profile">
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<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>
|
||||
return session ? (
|
||||
<div>
|
||||
<div className="bg-gradient-to-b from-blue-600 via-blue-600 to-blue-300 pb-32">
|
||||
<nav className="bg-blue-600">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<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">
|
||||
<div className="flex-shrink-0">
|
||||
<img className="h-6" src="/calendso-white.svg" alt="Calendso" />
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-4">
|
||||
<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>
|
||||
</Link>
|
||||
<Link href="/bookings">
|
||||
<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>
|
||||
</Link>
|
||||
<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>
|
||||
</Link>
|
||||
<Link href="/integrations">
|
||||
<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>
|
||||
<Link href="/settings/profile">
|
||||
<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>
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
<div className="hidden md:block">
|
||||
<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;
|
||||
}
|
||||
|
|
108
components/booking/AvailableTimes.tsx
Normal file
108
components/booking/AvailableTimes.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
import dayjs, {Dayjs} from "dayjs";
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
dayjs.extend(isBetween);
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import getSlots from "../../lib/slots";
|
||||
import Link from "next/link";
|
||||
import {timeZone} from "../../lib/clock";
|
||||
import {useRouter} from "next/router";
|
||||
import {ExclamationIcon} from "@heroicons/react/solid";
|
||||
|
||||
const AvailableTimes = (props) => {
|
||||
|
||||
const router = useRouter();
|
||||
const { user, rescheduleUid } = router.query;
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const times = getSlots({
|
||||
calendarTimeZone: props.user.timeZone,
|
||||
selectedTimeZone: timeZone(),
|
||||
eventLength: props.eventType.length,
|
||||
selectedDate: props.date,
|
||||
dayStartTime: props.user.startTime,
|
||||
dayEndTime: props.user.endTime,
|
||||
});
|
||||
|
||||
const handleAvailableSlots = (busyTimes: []) => {
|
||||
// Check for conflicts
|
||||
for (let i = times.length - 1; i >= 0; i -= 1) {
|
||||
busyTimes.forEach(busyTime => {
|
||||
let startTime = dayjs(busyTime.start);
|
||||
let endTime = dayjs(busyTime.end);
|
||||
|
||||
// Check if start times are the same
|
||||
if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if time is between start and end times
|
||||
if (dayjs(times[i]).isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if slot end time is between start and end time
|
||||
if (dayjs(times[i]).add(props.eventType.length, 'minutes').isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if startTime is between slot
|
||||
if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, 'minutes'))) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Display available times
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
// Re-render only when invitee changes date
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setError(false);
|
||||
fetch(`/api/availability/${user}?dateFrom=${props.date.startOf('day').utc().format()}&dateTo=${props.date.endOf('day').utc().format()}`)
|
||||
.then( res => res.json())
|
||||
.then(handleAvailableSlots)
|
||||
.catch(e => setError(true))
|
||||
}, [props.date]);
|
||||
|
||||
return (
|
||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
||||
<div className="text-gray-600 font-light text-xl mb-4 text-left">
|
||||
<span className="w-1/2">
|
||||
{props.date.format("dddd DD MMMM YYYY")}
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
!error && loaded && times.map((time) =>
|
||||
<div key={dayjs(time).utc().format()}>
|
||||
<Link
|
||||
href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")}>
|
||||
<a key={dayjs(time).format("hh:mma")}
|
||||
className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">{dayjs(time).tz(timeZone()).format(props.timeFormat)}</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{!error && !loaded && <div className="loader"/>}
|
||||
{error &&
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<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 load the available time slots.{' '}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default AvailableTimes;
|
73
components/booking/TimeOptions.tsx
Normal file
73
components/booking/TimeOptions.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import {Switch} from "@headlessui/react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import {useEffect, useState} from "react";
|
||||
import {timeZone, is24h} from '../../lib/clock';
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const TimeOptions = (props) => {
|
||||
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState('');
|
||||
const [is24hClock, setIs24hClock] = useState(false);
|
||||
|
||||
useEffect( () => {
|
||||
setIs24hClock(is24h());
|
||||
setSelectedTimeZone(timeZone());
|
||||
}, []);
|
||||
|
||||
useEffect( () => {
|
||||
props.onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
}, [selectedTimeZone]);
|
||||
|
||||
useEffect( () => {
|
||||
props.onToggle24hClock(is24h(is24hClock));
|
||||
}, [is24hClock]);
|
||||
|
||||
return selectedTimeZone !== "" && (
|
||||
<div className="w-full rounded shadow border bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group
|
||||
as="div"
|
||||
className="flex items-center justify-end"
|
||||
>
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm text-gray-500">am/pm</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={setIs24hClock}
|
||||
className={classNames(
|
||||
is24hClock ? "bg-blue-600" : "bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
is24hClock ? "translate-x-3" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm text-gray-500">24h</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz) => setSelectedTimeZone(tz.value)}
|
||||
className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeOptions;
|
|
@ -1,6 +1,6 @@
|
|||
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?
|
||||
<div>
|
||||
<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">
|
||||
{typeof window !== "undefined" && window.location.hostname}/
|
||||
</span>
|
||||
<input 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"/>
|
||||
<input
|
||||
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>
|
||||
));
|
||||
));
|
||||
|
||||
UsernameInput.displayName = "UsernameInput";
|
||||
|
||||
export { UsernameInput };
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import EventOrganizerMail from "./emails/EventOrganizerMail";
|
||||
import EventAttendeeMail from "./emails/EventAttendeeMail";
|
||||
import {v5 as uuidv5} from 'uuid';
|
||||
import short from 'short-uuid';
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import short from "short-uuid";
|
||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
||||
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
||||
|
||||
const translator = short();
|
||||
|
||||
const { google } = require("googleapis");
|
||||
import prisma from "./prisma";
|
||||
|
||||
const {google} = require('googleapis');
|
||||
|
||||
const googleAuth = (credential) => {
|
||||
const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
|
||||
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||
|
@ -60,7 +59,6 @@ function handleErrorsRaw(response) {
|
|||
}
|
||||
|
||||
const o365Auth = (credential) => {
|
||||
|
||||
const isExpired = (expiryDate) => expiryDate < Math.round((+(new Date()) / 1000));
|
||||
|
||||
const refreshAccessToken = (refreshToken) => {
|
||||
|
@ -91,14 +89,17 @@ const o365Auth = (credential) => {
|
|||
}
|
||||
|
||||
return {
|
||||
getToken: () => !isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
|
||||
getToken: () =>
|
||||
!isExpired(credential.key.expiry_date)
|
||||
? Promise.resolve(credential.key.access_token)
|
||||
: refreshAccessToken(credential.key.refresh_token),
|
||||
};
|
||||
};
|
||||
|
||||
interface Person {
|
||||
name?: string,
|
||||
email: string,
|
||||
timeZone: string
|
||||
name?: string;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
interface CalendarEvent {
|
||||
|
@ -110,13 +111,18 @@ interface CalendarEvent {
|
|||
location?: string;
|
||||
organizer: Person;
|
||||
attendees: Person[];
|
||||
};
|
||||
conferenceData?: ConferenceData;
|
||||
}
|
||||
|
||||
interface ConferenceData {
|
||||
createRequest: any;
|
||||
}
|
||||
|
||||
interface IntegrationCalendar {
|
||||
integration: string;
|
||||
primary: boolean;
|
||||
externalId: string;
|
||||
name: string;
|
||||
integration: string;
|
||||
primary: boolean;
|
||||
externalId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CalendarApiAdapter {
|
||||
|
@ -126,26 +132,28 @@ interface CalendarApiAdapter {
|
|||
|
||||
deleteEvent(uid: String);
|
||||
|
||||
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>;
|
||||
getAvailability(
|
||||
dateFrom,
|
||||
dateTo,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
): Promise<any>;
|
||||
|
||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||
}
|
||||
|
||||
const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||
|
||||
const auth = o365Auth(credential);
|
||||
|
||||
const translateEvent = (event: CalendarEvent) => {
|
||||
|
||||
let optional = {};
|
||||
if (event.location) {
|
||||
optional.location = {displayName: event.location};
|
||||
optional.location = { displayName: event.location };
|
||||
}
|
||||
|
||||
return {
|
||||
subject: event.title,
|
||||
body: {
|
||||
contentType: 'HTML',
|
||||
contentType: "HTML",
|
||||
content: event.description,
|
||||
},
|
||||
start: {
|
||||
|
@ -156,99 +164,138 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
|||
dateTime: event.endTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
attendees: event.attendees.map(attendee => ({
|
||||
attendees: event.attendees.map((attendee) => ({
|
||||
emailAddress: {
|
||||
address: attendee.email,
|
||||
name: attendee.name
|
||||
name: attendee.name,
|
||||
},
|
||||
type: "required"
|
||||
type: "required",
|
||||
})),
|
||||
...optional
|
||||
}
|
||||
...optional,
|
||||
};
|
||||
};
|
||||
|
||||
const integrationType = "office365_calendar";
|
||||
const integrationType = "office365_calendar";
|
||||
|
||||
function listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
return auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendars', {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}).then(handleErrorsJson)
|
||||
.then(responseBody => {
|
||||
return responseBody.value.map(cal => {
|
||||
const calendar: IntegrationCalendar = {
|
||||
externalId: cal.id, integration: integrationType, name: cal.name, primary: cal.isDefaultCalendar
|
||||
}
|
||||
return calendar;
|
||||
});
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
|
||||
const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'"
|
||||
return auth.getToken().then(
|
||||
(accessToken) => {
|
||||
const selectedCalendarIds = selectedCalendars.filter(e => e.integration === integrationType).map(e => e.externalId);
|
||||
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0){
|
||||
// Only calendars of other integrations selected
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return (selectedCalendarIds.length == 0
|
||||
? listCalendars().then(cals => cals.map(e => e.externalId))
|
||||
: Promise.resolve(selectedCalendarIds).then(x => x)).then((ids: string[]) => {
|
||||
const urls = ids.map(calendarId => 'https://graph.microsoft.com/v1.0/me/calendars/' + calendarId + '/events' + filter)
|
||||
return Promise.all(urls.map(url => fetch(url, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Prefer': 'outlook.timezone="Etc/GMT"'
|
||||
}
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then(responseBody => responseBody.value.map((evt) => ({
|
||||
start: evt.start.dateTime + 'Z',
|
||||
end: evt.end.dateTime + 'Z'
|
||||
}))
|
||||
))).then(results => results.reduce((acc, events) => acc.concat(events), []))
|
||||
})
|
||||
}
|
||||
).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
function listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
return auth.getToken().then((accessToken) =>
|
||||
fetch("https://graph.microsoft.com/v1.0/me/calendars", {
|
||||
method: "get",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
createEvent: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event))
|
||||
}).then(handleErrorsJson).then((responseBody) => ({
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then((responseBody) => {
|
||||
return responseBody.value.map((cal) => {
|
||||
const calendar: IntegrationCalendar = {
|
||||
externalId: cal.id,
|
||||
integration: integrationType,
|
||||
name: cal.name,
|
||||
primary: cal.isDefaultCalendar,
|
||||
};
|
||||
return calendar;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
|
||||
const filter =
|
||||
"?$filter=start/dateTime ge '" +
|
||||
dateFrom +
|
||||
"' and end/dateTime le '" +
|
||||
dateTo +
|
||||
"'";
|
||||
return auth
|
||||
.getToken()
|
||||
.then((accessToken) => {
|
||||
const selectedCalendarIds = selectedCalendars
|
||||
.filter((e) => e.integration === integrationType)
|
||||
.map((e) => e.externalId);
|
||||
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) {
|
||||
// Only calendars of other integrations selected
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return (
|
||||
selectedCalendarIds.length == 0
|
||||
? listCalendars().then((cals) => cals.map((e) => e.externalId))
|
||||
: Promise.resolve(selectedCalendarIds).then((x) => x)
|
||||
).then((ids: string[]) => {
|
||||
const urls = ids.map(
|
||||
(calendarId) =>
|
||||
"https://graph.microsoft.com/v1.0/me/calendars/" +
|
||||
calendarId +
|
||||
"/events" +
|
||||
filter
|
||||
);
|
||||
return Promise.all(
|
||||
urls.map((url) =>
|
||||
fetch(url, {
|
||||
method: "get",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
Prefer: 'outlook.timezone="Etc/GMT"',
|
||||
},
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then((responseBody) =>
|
||||
responseBody.value.map((evt) => ({
|
||||
start: evt.start.dateTime + "Z",
|
||||
end: evt.end.dateTime + "Z",
|
||||
}))
|
||||
)
|
||||
)
|
||||
).then((results) =>
|
||||
results.reduce((acc, events) => acc.concat(events), [])
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
},
|
||||
createEvent: (event: CalendarEvent) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then((responseBody) => ({
|
||||
...responseBody,
|
||||
disableConfirmationEmail: true,
|
||||
}))),
|
||||
deleteEvent: (uid: String) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events/' + uid, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
}
|
||||
}).then(handleErrorsRaw)),
|
||||
updateEvent: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events/' + uid, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event))
|
||||
}).then(handleErrorsRaw)),
|
||||
listCalendars
|
||||
}
|
||||
}))
|
||||
),
|
||||
deleteEvent: (uid: String) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
},
|
||||
}).then(handleErrorsRaw)
|
||||
),
|
||||
updateEvent: (uid: String, event: CalendarEvent) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
}).then(handleErrorsRaw)
|
||||
),
|
||||
listCalendars,
|
||||
};
|
||||
};
|
||||
|
||||
const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||
|
@ -312,9 +359,13 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
},
|
||||
};
|
||||
|
||||
if (event.location) {
|
||||
payload['location'] = event.location;
|
||||
}
|
||||
if (event.location) {
|
||||
payload["location"] = event.location;
|
||||
}
|
||||
|
||||
if (event.conferenceData) {
|
||||
payload["conferenceData"] = event.conferenceData;
|
||||
}
|
||||
|
||||
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
|
||||
calendar.events.insert({
|
||||
|
@ -350,9 +401,9 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
},
|
||||
};
|
||||
|
||||
if (event.location) {
|
||||
payload['location'] = event.location;
|
||||
}
|
||||
if (event.location) {
|
||||
payload["location"] = event.location;
|
||||
}
|
||||
|
||||
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
|
||||
calendar.events.update({
|
||||
|
@ -407,35 +458,50 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
};
|
||||
|
||||
// factory
|
||||
const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map((cred) => {
|
||||
switch (cred.type) {
|
||||
case 'google_calendar':
|
||||
return GoogleCalendar(cred);
|
||||
case 'office365_calendar':
|
||||
return MicrosoftOffice365Calendar(cred);
|
||||
default:
|
||||
return; // unknown credential, could be legacy? In any case, ignore
|
||||
}
|
||||
}).filter(Boolean);
|
||||
const calendars = (withCredentials): CalendarApiAdapter[] =>
|
||||
withCredentials
|
||||
.map((cred) => {
|
||||
switch (cred.type) {
|
||||
case "google_calendar":
|
||||
return GoogleCalendar(cred);
|
||||
case "office365_calendar":
|
||||
return MicrosoftOffice365Calendar(cred);
|
||||
default:
|
||||
return; // unknown credential, could be legacy? In any case, ignore
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all(
|
||||
calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
||||
).then(
|
||||
(results) => {
|
||||
return results.reduce((acc, availability) => acc.concat(availability), [])
|
||||
}
|
||||
);
|
||||
const getBusyCalendarTimes = (
|
||||
withCredentials,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
selectedCalendars
|
||||
) =>
|
||||
Promise.all(
|
||||
calendars(withCredentials).map((c) =>
|
||||
c.getAvailability(dateFrom, dateTo, selectedCalendars)
|
||||
)
|
||||
).then((results) => {
|
||||
return results.reduce((acc, availability) => acc.concat(availability), []);
|
||||
});
|
||||
|
||||
const listCalendars = (withCredentials) => Promise.all(
|
||||
calendars(withCredentials).map(c => c.listCalendars())
|
||||
).then(
|
||||
(results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||
);
|
||||
const listCalendars = (withCredentials) =>
|
||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then(
|
||||
(results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||
);
|
||||
|
||||
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => {
|
||||
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||
const createEvent = async (
|
||||
credential,
|
||||
calEvent: CalendarEvent
|
||||
): Promise<any> => {
|
||||
const uid: string = translator.fromUUID(
|
||||
uuidv5(JSON.stringify(calEvent), uuidv5.URL)
|
||||
);
|
||||
|
||||
const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null;
|
||||
const creationResult = credential
|
||||
? await calendars([credential])[0].createEvent(calEvent)
|
||||
: null;
|
||||
|
||||
const organizerMail = new EventOrganizerMail(calEvent, uid);
|
||||
const attendeeMail = new EventAttendeeMail(calEvent, uid);
|
||||
|
@ -455,14 +521,22 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> =>
|
|||
|
||||
return {
|
||||
uid,
|
||||
createdEvent: creationResult
|
||||
createdEvent: creationResult,
|
||||
};
|
||||
};
|
||||
|
||||
const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => {
|
||||
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||
const updateEvent = async (
|
||||
credential,
|
||||
uidToUpdate: String,
|
||||
calEvent: CalendarEvent
|
||||
): Promise<any> => {
|
||||
const newUid: string = translator.fromUUID(
|
||||
uuidv5(JSON.stringify(calEvent), uuidv5.URL)
|
||||
);
|
||||
|
||||
const updateResult = credential ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) : null;
|
||||
const updateResult = credential
|
||||
? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent)
|
||||
: null;
|
||||
|
||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||
|
@ -482,7 +556,7 @@ const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEv
|
|||
|
||||
return {
|
||||
uid: newUid,
|
||||
updatedEvent: updateResult
|
||||
updatedEvent: updateResult,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -494,4 +568,12 @@ const deleteEvent = (credential, uid: String): Promise<any> => {
|
|||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
export {getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar};
|
||||
export {
|
||||
getBusyCalendarTimes,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
CalendarEvent,
|
||||
listCalendars,
|
||||
IntegrationCalendar,
|
||||
};
|
||||
|
|
48
lib/clock.ts
Normal file
48
lib/clock.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
// handles logic related to user clock display using 24h display / timeZone options.
|
||||
import dayjs, {Dayjs} from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
interface TimeOptions { is24hClock: boolean, inviteeTimeZone: string };
|
||||
|
||||
const timeOptions: TimeOptions = {
|
||||
is24hClock: false,
|
||||
inviteeTimeZone: '',
|
||||
}
|
||||
|
||||
const isInitialized: boolean = false;
|
||||
|
||||
const initClock = () => {
|
||||
if (typeof localStorage === "undefined" || isInitialized) {
|
||||
return;
|
||||
}
|
||||
timeOptions.is24hClock = localStorage.getItem('timeOption.is24hClock') === "true";
|
||||
timeOptions.inviteeTimeZone = localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess();
|
||||
}
|
||||
|
||||
const is24h = (is24hClock?: boolean) => {
|
||||
initClock();
|
||||
if(typeof is24hClock !== "undefined") set24hClock(is24hClock);
|
||||
return timeOptions.is24hClock;
|
||||
}
|
||||
|
||||
const set24hClock = (is24hClock: boolean) => {
|
||||
localStorage.setItem('timeOption.is24hClock', is24hClock.toString());
|
||||
timeOptions.is24hClock = is24hClock;
|
||||
}
|
||||
|
||||
function setTimeZone(selectedTimeZone: string) {
|
||||
localStorage.setItem('timeOption.preferredTimeZone', selectedTimeZone);
|
||||
timeOptions.inviteeTimeZone = selectedTimeZone;
|
||||
}
|
||||
|
||||
const timeZone = (selectedTimeZone?: string) => {
|
||||
initClock();
|
||||
if (selectedTimeZone) setTimeZone(selectedTimeZone)
|
||||
return timeOptions.inviteeTimeZone;
|
||||
}
|
||||
|
||||
export {is24h, timeZone};
|
|
@ -1,6 +1,13 @@
|
|||
import dayjs, {Dayjs} from "dayjs";
|
||||
import EventMail from "./EventMail";
|
||||
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventAttendeeMail extends EventMail {
|
||||
/**
|
||||
* Returns the email text as HTML representation.
|
||||
|
|
|
@ -127,7 +127,8 @@ export default abstract class EventMail {
|
|||
protected getAdditionalFooter(): string {
|
||||
return `
|
||||
<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 />
|
||||
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
|
||||
`;
|
||||
|
|
|
@ -2,6 +2,15 @@ import {createEvent} from "ics";
|
|||
import dayjs, {Dayjs} from "dayjs";
|
||||
import EventMail from "./EventMail";
|
||||
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import toArray from 'dayjs/plugin/toArray';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(toArray);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventOrganizerMail extends EventMail {
|
||||
/**
|
||||
* Returns the instance's event as an iCal event in string representation.
|
||||
|
|
|
@ -2,5 +2,6 @@
|
|||
export enum LocationType {
|
||||
InPerson = 'inPerson',
|
||||
Phone = 'phone',
|
||||
GoogleMeet = 'integrations:google:meet'
|
||||
}
|
||||
|
||||
|
|
3966
package-lock.json
generated
3966
package-lock.json
generated
File diff suppressed because it is too large
Load diff
26
package.json
26
package.json
|
@ -6,7 +6,10 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "prisma generate"
|
||||
"postinstall": "prisma generate",
|
||||
"pre-commit": "lint-staged",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.0.0",
|
||||
|
@ -19,6 +22,7 @@
|
|||
"dayjs": "^1.10.4",
|
||||
"googleapis": "^67.1.1",
|
||||
"ics": "^2.27.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"next": "^10.2.0",
|
||||
"next-auth": "^3.13.2",
|
||||
"next-transpile-modules": "^7.0.0",
|
||||
|
@ -34,10 +38,26 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^14.14.33",
|
||||
"@types/react": "^17.0.3",
|
||||
"autoprefixer": "^10.2.6",
|
||||
"postcss": "^8.3.5",
|
||||
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
||||
"@typescript-eslint/parser": "^4.27.0",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"eslint": "^7.29.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-react": "^7.24.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"husky": "^6.0.0",
|
||||
"lint-staged": "^11.0.0",
|
||||
"postcss": "^8.2.8",
|
||||
"prettier": "^2.3.1",
|
||||
"prisma": "^2.23.0",
|
||||
"tailwindcss": "^2.2.2",
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"./{*,{pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
168
pages/[user].tsx
168
pages/[user].tsx
|
@ -1,92 +1,94 @@
|
|||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import prisma from '../lib/prisma';
|
||||
import Avatar from '../components/Avatar';
|
||||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import prisma from "../lib/prisma";
|
||||
import Avatar from "../components/Avatar";
|
||||
|
||||
export default function User(props) {
|
||||
const eventTypes = props.eventTypes.map(type =>
|
||||
<li key={type.id}>
|
||||
<Link href={'/' + props.user.username + '/' + type.slug}>
|
||||
<a className="block px-6 py-4">
|
||||
<div className="inline-block w-3 h-3 rounded-full mr-2" style={{backgroundColor:getRandomColorCode()}}></div>
|
||||
<h2 className="inline-block font-medium">{type.title}</h2>
|
||||
<p className="inline-block text-gray-400 ml-2">{type.description}</p>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{props.user.name || props.user.username} | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
export default function User(props): User {
|
||||
const eventTypes = props.eventTypes.map((type) => (
|
||||
<li key={type.id}>
|
||||
<Link href={`/${props.user.username}/${type.slug}`}>
|
||||
<a className="block px-6 py-4">
|
||||
<div
|
||||
className="inline-block w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: getRandomColorCode() }}></div>
|
||||
<h2 className="inline-block font-medium">{type.title}</h2>
|
||||
<p className="inline-block text-gray-400 ml-2">{type.description}</p>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
));
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{props.user.name || props.user.username} | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main className="max-w-2xl mx-auto my-24">
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
|
||||
<h1 className="text-3xl font-semibold text-gray-800 mb-1">{props.user.name || props.user.username}</h1>
|
||||
<p className="text-gray-600">{props.user.bio}</p>
|
||||
</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>
|
||||
<main className="max-w-2xl mx-auto my-24">
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
|
||||
<h1 className="text-3xl font-semibold text-gray-800 mb-1">
|
||||
{props.user.name || props.user.username}
|
||||
</h1>
|
||||
<p className="text-gray-600">{props.user.bio}</p>
|
||||
</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) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: context.query.user,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email:true,
|
||||
name: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
eventTypes: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
|
||||
const eventTypes = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
hidden: false
|
||||
}
|
||||
});
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: context.query.user.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
name: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
eventTypes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
eventTypes
|
||||
},
|
||||
}
|
||||
}
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
hidden: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
eventTypes,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Auxiliary methods
|
||||
|
||||
export function getRandomColorCode() {
|
||||
let color = '#';
|
||||
for (let idx = 0; idx < 6; idx++) {
|
||||
color += Math.floor(Math.random() * 10);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
export function getRandomColorCode(): string {
|
||||
let color = "#";
|
||||
for (let idx = 0; idx < 6; idx++) {
|
||||
color += Math.floor(Math.random() * 10);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
|
|
@ -1,410 +1,237 @@
|
|||
import {useEffect, useMemo, useState} from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import prisma from '../../lib/prisma';
|
||||
import {useRouter} from 'next/router';
|
||||
import dayjs, {Dayjs} from 'dayjs';
|
||||
import {Switch} from '@headlessui/react';
|
||||
import TimezoneSelect from 'react-timezone-select';
|
||||
import { useEffect, useState } from "react";
|
||||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { useRouter } from "next/router";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import {
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
XCircleIcon, ExclamationIcon
|
||||
} from '@heroicons/react/solid';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import getSlots from '../../lib/slots';
|
||||
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
|
||||
|
||||
} from "@heroicons/react/solid";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||
import AvailableTimes from "../../components/booking/AvailableTimes";
|
||||
import TimeOptions from "../../components/booking/TimeOptions";
|
||||
import Avatar from "../../components/Avatar";
|
||||
import { timeZone } from "../../lib/clock";
|
||||
|
||||
export default function Type(props) {
|
||||
// Initialise state
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [is24h, setIs24h] = useState(false);
|
||||
const [busy, setBusy] = useState([]);
|
||||
const telemetry = useTelemetry();
|
||||
export default function Type(props): Type {
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState('');
|
||||
// Initialise state
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState("h:mma");
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
function toggleTimeOptions() {
|
||||
setIsTimeOptionsOpen(!isTimeOptionsOpen);
|
||||
}
|
||||
useEffect((): void => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
||||
}, [telemetry]);
|
||||
|
||||
function toggleClockSticky() {
|
||||
localStorage.setItem('timeOption.is24hClock', (!is24h).toString());
|
||||
setIs24h(!is24h);
|
||||
}
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth + 1);
|
||||
};
|
||||
|
||||
function setPreferredTimeZoneSticky({ value }: string) {
|
||||
localStorage.setItem('timeOption.preferredTimeZone', value);
|
||||
setSelectedTimeZone(value);
|
||||
}
|
||||
const decrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth - 1);
|
||||
};
|
||||
|
||||
function initializeTimeOptions() {
|
||||
setSelectedTimeZone(localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess());
|
||||
setIs24h(!!localStorage.getItem('timeOption.is24hClock'));
|
||||
}
|
||||
// Set up calendar
|
||||
const daysInMonth = dayjs().month(selectedMonth).daysInMonth();
|
||||
const days = [];
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()))
|
||||
}, []);
|
||||
|
||||
// Handle date change and timezone change
|
||||
useEffect(() => {
|
||||
|
||||
if ( ! selectedTimeZone ) {
|
||||
initializeTimeOptions();
|
||||
}
|
||||
|
||||
const changeDate = async () => {
|
||||
if (!selectedDate) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
const res = await fetch(`/api/availability/${user}?dateFrom=${lowerBound.utc().format()}&dateTo=${upperBound.utc().format()}`);
|
||||
if (res.ok) {
|
||||
const busyTimes = await res.json();
|
||||
if (busyTimes.length > 0) setBusy(busyTimes);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
changeDate();
|
||||
}, [selectedDate, selectedTimeZone]);
|
||||
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
const { user, rescheduleUid } = router.query;
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth + 1);
|
||||
}
|
||||
|
||||
const decrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth - 1);
|
||||
}
|
||||
|
||||
// Need to define the bounds of the 24-hour window
|
||||
const lowerBound = useMemo(() => {
|
||||
if(!selectedDate) {
|
||||
return
|
||||
}
|
||||
|
||||
return selectedDate.startOf('day')
|
||||
}, [selectedDate])
|
||||
|
||||
const upperBound = useMemo(() => {
|
||||
if(!selectedDate) return
|
||||
|
||||
return selectedDate.endOf('day')
|
||||
}, [selectedDate])
|
||||
|
||||
// Set up calendar
|
||||
var daysInMonth = dayjs().month(selectedMonth).daysInMonth();
|
||||
var days = [];
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
|
||||
if (props.user.weekStart === 'Monday') {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0)
|
||||
weekdayOfFirst = 6;
|
||||
}
|
||||
const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) =>
|
||||
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
||||
{null}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Combine placeholder days with actual days
|
||||
const calendar = [...emptyDays, ...days.map((day) =>
|
||||
<button key={day} onClick={(e) => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()))
|
||||
setSelectedDate(dayjs().tz(selectedTimeZone).month(selectedMonth).date(day))
|
||||
}} disabled={selectedMonth < parseInt(dayjs().format('MM')) && dayjs().month(selectedMonth).format("D") > day} className={"text-center w-10 h-10 rounded-full mx-auto " + (dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth)) ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-400 font-light') + (dayjs(selectedDate).month(selectedMonth).format("D") == day ? ' bg-blue-600 text-white-important' : '')}>
|
||||
{day}
|
||||
</button>
|
||||
)];
|
||||
|
||||
const times = useMemo(() =>
|
||||
getSlots({
|
||||
calendarTimeZone: props.user.timeZone,
|
||||
selectedTimeZone: selectedTimeZone,
|
||||
eventLength: props.eventType.length,
|
||||
selectedDate: selectedDate,
|
||||
dayStartTime: props.user.startTime,
|
||||
dayEndTime: props.user.endTime,
|
||||
})
|
||||
, [selectedDate, selectedTimeZone])
|
||||
|
||||
// Check for conflicts
|
||||
for(let i = times.length - 1; i >= 0; i -= 1) {
|
||||
busy.forEach(busyTime => {
|
||||
let startTime = dayjs(busyTime.start);
|
||||
let endTime = dayjs(busyTime.end);
|
||||
|
||||
// Check if start times are the same
|
||||
if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if time is between start and end times
|
||||
if (dayjs(times[i]).isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if slot end time is between start and end time
|
||||
if (dayjs(times[i]).add(props.eventType.length, 'minutes').isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
|
||||
// Check if startTime is between slot
|
||||
if(startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, 'minutes'))) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Display available times
|
||||
const availableTimes = times.map((time) =>
|
||||
<div key={dayjs(time).utc().format()}>
|
||||
<Link href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")}>
|
||||
<a key={dayjs(time).format("hh:mma")} className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">{dayjs(time).tz(selectedTimeZone).format(is24h ? "HH:mm" : "hh:mma")}</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>
|
||||
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
|
||||
Calendso
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main
|
||||
className={
|
||||
"mx-auto my-24 transition-max-width ease-in-out duration-500 " +
|
||||
(selectedDate ? "max-w-6xl" : "max-w-3xl")
|
||||
}
|
||||
>
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="sm:flex px-4 py-5 sm:p-4">
|
||||
<div
|
||||
className={
|
||||
"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||
}
|
||||
>
|
||||
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
||||
<h2 className="font-medium 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-1 px-2 py-1 -ml-2">
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{props.eventType.length} minutes
|
||||
</p>
|
||||
<button
|
||||
onClick={toggleTimeOptions}
|
||||
className="text-gray-500 mb-1 px-2 py-1 -ml-2"
|
||||
>
|
||||
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{selectedTimeZone}
|
||||
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
||||
</button>
|
||||
{isTimeOptionsOpen && (
|
||||
<div className="w-full rounded shadow border bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group
|
||||
as="div"
|
||||
className="flex items-center justify-end"
|
||||
>
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm text-gray-500">am/pm</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24h}
|
||||
onChange={toggleClockSticky}
|
||||
className={classNames(
|
||||
is24h ? "bg-blue-600" : "bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
is24h ? "translate-x-3" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm text-gray-500">24h</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={setPreferredTimeZoneSticky}
|
||||
className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-600 mt-3 mb-8">
|
||||
{props.eventType.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"mt-8 sm:mt-0 " +
|
||||
(selectedDate
|
||||
? "sm:w-1/3 border-r sm:px-4"
|
||||
: "sm:w-1/2 sm:pl-4")
|
||||
}
|
||||
>
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
||||
<span className="w-1/2">
|
||||
{dayjs().month(selectedMonth).format("MMMM YYYY")}
|
||||
</span>
|
||||
<div className="w-1/2 text-right">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={
|
||||
"mr-4 " +
|
||||
(selectedMonth < parseInt(dayjs().format("MM")) &&
|
||||
"text-gray-400")
|
||||
}
|
||||
disabled={selectedMonth < parseInt(dayjs().format("MM"))}
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={incrementMonth}>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||
{props.user.weekStart !== 'Monday' ? (
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
Sun
|
||||
</div>
|
||||
) : null}
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
Mon
|
||||
</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
Tue
|
||||
</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
Wed
|
||||
</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
Thu
|
||||
</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
Fri
|
||||
</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
Sat
|
||||
</div>
|
||||
{props.user.weekStart === 'Monday' ? (
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">
|
||||
Sun
|
||||
</div>
|
||||
) : null}
|
||||
{calendar}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDate && (
|
||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
||||
<div className="text-gray-600 font-light text-xl mb-4 text-left">
|
||||
<span className="w-1/2">
|
||||
{dayjs(selectedDate).format("dddd DD MMMM YYYY")}
|
||||
</span>
|
||||
</div>
|
||||
{!loading && !error && availableTimes}
|
||||
{loading && <div className="loader"/>}
|
||||
{error &&
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<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 load the available time slots.{' '}
|
||||
<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>
|
||||
{/* note(peer):
|
||||
you can remove calendso branding here, but we'd also appreciate it, if you don't <3
|
||||
*/}
|
||||
<div className="text-xs text-right pt-1">
|
||||
<Link href="https://calendso.com">
|
||||
<a
|
||||
style={{ color: "#104D86" }}
|
||||
className="opacity-50 hover:opacity-100"
|
||||
>
|
||||
powered by{" "}
|
||||
<img
|
||||
style={{ top: -2 }}
|
||||
className="w-auto inline h-3 relative"
|
||||
src="/calendso-logo-word.svg"
|
||||
alt="Calendso Logo"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
|
||||
if (props.user.weekStart === "Monday") {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||
}
|
||||
const emptyDays = Array(weekdayOfFirst)
|
||||
.fill(null)
|
||||
.map((day, i) => (
|
||||
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
|
||||
{null}
|
||||
</div>
|
||||
);
|
||||
));
|
||||
|
||||
const changeDate = (day): void => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
|
||||
setSelectedDate(dayjs().month(selectedMonth).date(day));
|
||||
};
|
||||
|
||||
// Combine placeholder days with actual days
|
||||
const calendar = [
|
||||
...emptyDays,
|
||||
...days.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => changeDate(day)}
|
||||
disabled={
|
||||
selectedMonth < parseInt(dayjs().format("MM")) && dayjs().month(selectedMonth).format("D") > day
|
||||
}
|
||||
className={
|
||||
"text-center w-10 h-10 rounded-full mx-auto " +
|
||||
(dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth))
|
||||
? "bg-blue-50 text-blue-600 font-medium"
|
||||
: "text-gray-400 font-light") +
|
||||
(dayjs(selectedDate).month(selectedMonth).format("D") == day
|
||||
? " bg-blue-600 text-white-important"
|
||||
: "")
|
||||
}>
|
||||
{day}
|
||||
</button>
|
||||
)),
|
||||
];
|
||||
|
||||
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
||||
if (selectedDate) {
|
||||
setSelectedDate(selectedDate.tz(selectedTimeZone));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle24hClock = (is24hClock: boolean): void => {
|
||||
if (selectedDate) {
|
||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>
|
||||
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
|
||||
Calendso
|
||||
</title>
|
||||
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
|
||||
<meta name="description" content={props.eventType.description} />
|
||||
|
||||
<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>
|
||||
<main
|
||||
className={
|
||||
"mx-auto my-24 transition-max-width ease-in-out duration-500 " +
|
||||
(selectedDate ? "max-w-6xl" : "max-w-3xl")
|
||||
}>
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="sm:flex px-4 py-5 sm:p-4">
|
||||
<div className={"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")}>
|
||||
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
||||
<h2 className="font-medium 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-1 px-2 py-1 -ml-2">
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{props.eventType.length} minutes
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsTimeOptionsOpen(!isTimeOptionsOpen)}
|
||||
className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
||||
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{timeZone()}
|
||||
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
||||
</button>
|
||||
{isTimeOptionsOpen && (
|
||||
<TimeOptions
|
||||
onSelectTimeZone={handleSelectTimeZone}
|
||||
onToggle24hClock={handleToggle24hClock}
|
||||
/>
|
||||
)}
|
||||
<p className="text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={"mt-8 sm:mt-0 " + (selectedDate ? "sm:w-1/3 border-r sm:px-4" : "sm:w-1/2 sm:pl-4")}>
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
||||
<span className="w-1/2">{dayjs().month(selectedMonth).format("MMMM YYYY")}</span>
|
||||
<div className="w-1/2 text-right">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={"mr-4 " + (selectedMonth < parseInt(dayjs().format("MM")) && "text-gray-400")}
|
||||
disabled={selectedMonth < parseInt(dayjs().format("MM"))}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={incrementMonth}>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||
{props.user.weekStart !== "Monday" ? (
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Sun</div>
|
||||
) : null}
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Mon</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Tue</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Wed</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Thu</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Fri</div>
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Sat</div>
|
||||
{props.user.weekStart === "Monday" ? (
|
||||
<div className="uppercase text-gray-400 text-xs tracking-widest">Sun</div>
|
||||
) : null}
|
||||
{calendar}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
timeFormat={timeFormat}
|
||||
user={props.user}
|
||||
eventType={props.eventType}
|
||||
date={selectedDate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* note(peer):
|
||||
you can remove calendso branding here, but we'd also appreciate it, if you don't <3
|
||||
*/}
|
||||
<div className="text-xs text-right pt-1">
|
||||
<Link href="https://calendso.com">
|
||||
<a style={{ color: "#104D86" }} className="opacity-50 hover:opacity-100">
|
||||
powered by{" "}
|
||||
<img
|
||||
style={{ top: -2 }}
|
||||
className="w-auto inline h-3 relative"
|
||||
src="/calendso-logo-word.svg"
|
||||
alt="Calendso Logo"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: context.query.user,
|
||||
username: context.query.user.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
@ -418,13 +245,13 @@ export async function getServerSideProps(context) {
|
|||
timeZone: true,
|
||||
endTime: true,
|
||||
weekStart: true,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!user ) {
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.findFirst({
|
||||
|
@ -438,14 +265,14 @@ export async function getServerSideProps(context) {
|
|||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
length: true
|
||||
}
|
||||
length: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
return {
|
||||
notFound: true
|
||||
}
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -453,5 +280,5 @@ export async function getServerSideProps(context) {
|
|||
user,
|
||||
eventType,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -47,6 +47,7 @@ export default function Book(props) {
|
|||
const locationLabels = {
|
||||
[LocationType.InPerson]: 'In-person meeting',
|
||||
[LocationType.Phone]: 'Phone call',
|
||||
[LocationType.GoogleMeet]: 'Google Meet',
|
||||
};
|
||||
|
||||
const bookingHandler = event => {
|
||||
|
@ -83,9 +84,21 @@ export default function Book(props) {
|
|||
rescheduleUid: rescheduleUid
|
||||
};
|
||||
|
||||
if (selectedLocation) {
|
||||
payload['location'] = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address;
|
||||
if (selectedLocation) {
|
||||
switch (selectedLocation) {
|
||||
case LocationType.Phone:
|
||||
payload['location'] = event.target.phone.value
|
||||
break
|
||||
|
||||
case LocationType.InPerson:
|
||||
payload['location'] = locationInfo(selectedLocation).address
|
||||
break
|
||||
|
||||
case LocationType.GoogleMeet:
|
||||
payload['location'] = LocationType.GoogleMeet
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()));
|
||||
const res = await fetch(
|
||||
|
@ -102,7 +115,12 @@ export default function Book(props) {
|
|||
if (res.ok) {
|
||||
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
|
||||
if (payload['location']) {
|
||||
successUrl += "&location=" + encodeURIComponent(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);
|
||||
|
|
|
@ -22,6 +22,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
customInputs: !req.body.customInputs
|
||||
? undefined
|
||||
: {
|
||||
deleteMany: {
|
||||
eventTypeId: req.body.id,
|
||||
NOT: {
|
||||
id: {in: req.body.customInputs.filter(input => !!input.id).map(e => e.id)}
|
||||
}
|
||||
},
|
||||
createMany: {
|
||||
data: req.body.customInputs.filter(input => !input.id).map(input => ({
|
||||
type: input.type,
|
||||
|
|
|
@ -7,9 +7,30 @@ import short from 'short-uuid';
|
|||
import {createMeeting, updateMeeting} from "../../../lib/videoClient";
|
||||
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
|
||||
import {getEventName} from "../../../lib/event";
|
||||
|
||||
import { LocationType } from '../../../lib/location';
|
||||
import merge from "lodash.merge"
|
||||
const translator = short();
|
||||
|
||||
interface p {
|
||||
location: string
|
||||
}
|
||||
|
||||
const getLocationRequestFromIntegration = ({location}: p) => {
|
||||
if (location === LocationType.GoogleMeet.valueOf()) {
|
||||
const requestId = uuidv5(location, uuidv5.URL)
|
||||
|
||||
return {
|
||||
conferenceData: {
|
||||
createRequest: {
|
||||
requestId: requestId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {user} = req.query;
|
||||
|
||||
|
@ -43,19 +64,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}
|
||||
});
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
let rawLocation = req.body.location
|
||||
|
||||
let evt: CalendarEvent = {
|
||||
type: selectedEventType.title,
|
||||
title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName),
|
||||
description: req.body.notes,
|
||||
startTime: req.body.start,
|
||||
endTime: req.body.end,
|
||||
location: req.body.location,
|
||||
organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone},
|
||||
attendees: [
|
||||
{email: req.body.email, name: req.body.name, timeZone: req.body.timeZone}
|
||||
]
|
||||
};
|
||||
|
||||
// If phone or inPerson use raw location
|
||||
// set evt.location to req.body.location
|
||||
if (!rawLocation?.includes('integration')) {
|
||||
evt.location = rawLocation
|
||||
}
|
||||
|
||||
|
||||
// If location is set to an integration location
|
||||
// Build proper transforms for evt object
|
||||
// Extend evt object with those transformations
|
||||
if (rawLocation?.includes('integration')) {
|
||||
let maybeLocationRequestObject = getLocationRequestFromIntegration({
|
||||
location: rawLocation
|
||||
})
|
||||
|
||||
evt = merge(evt, maybeLocationRequestObject)
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
userId: currentUser.id,
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useRef, useState} from 'react';
|
||||
import Select, {OptionBase} from 'react-select';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import Select, { OptionBase } from 'react-select';
|
||||
import prisma from '../../../lib/prisma';
|
||||
import {LocationType} from '../../../lib/location';
|
||||
import Shell from '../../../components/Shell';
|
||||
|
@ -31,8 +31,10 @@ export default function EventType(props) {
|
|||
const [ showAddCustomModal, setShowAddCustomModal ] = useState(false);
|
||||
const [ selectedLocation, setSelectedLocation ] = useState<OptionBase | undefined>(undefined);
|
||||
const [ selectedInputOption, setSelectedInputOption ] = useState<OptionBase>(inputOptions[0]);
|
||||
const [ selectedCustomInput, setSelectedCustomInput ] = useState<EventTypeCustomInput | undefined>(undefined);
|
||||
const [ locations, setLocations ] = useState(props.eventType.locations || []);
|
||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []);
|
||||
const [ customInputs, setCustomInputs ] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []);
|
||||
const locationOptions = props.locationOptions
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>();
|
||||
const slugRef = useRef<HTMLInputElement>();
|
||||
|
@ -81,12 +83,6 @@ export default function EventType(props) {
|
|||
router.push('/availability');
|
||||
}
|
||||
|
||||
// TODO: Tie into translations instead of abstracting to locations.ts
|
||||
const locationOptions: OptionBase[] = [
|
||||
{ value: LocationType.InPerson, label: 'In-person meeting' },
|
||||
{ value: LocationType.Phone, label: 'Phone call', },
|
||||
];
|
||||
|
||||
const openLocationModal = (type: LocationType) => {
|
||||
setSelectedLocation(locationOptions.find( (option) => option.value === type));
|
||||
setShowLocationModal(true);
|
||||
|
@ -100,8 +96,15 @@ export default function EventType(props) {
|
|||
const closeAddCustomModal = () => {
|
||||
setSelectedInputOption(inputOptions[0]);
|
||||
setShowAddCustomModal(false);
|
||||
setSelectedCustomInput(undefined);
|
||||
};
|
||||
|
||||
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
|
||||
setSelectedCustomInput(customInput);
|
||||
setSelectedInputOption(inputOptions.find(e => e.value === customInput.type));
|
||||
setShowAddCustomModal(true);
|
||||
}
|
||||
|
||||
const LocationOptions = () => {
|
||||
if (!selectedLocation) {
|
||||
return null;
|
||||
|
@ -124,6 +127,10 @@ export default function EventType(props) {
|
|||
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;
|
||||
};
|
||||
|
@ -161,12 +168,30 @@ export default function EventType(props) {
|
|||
type: e.target.type.value
|
||||
};
|
||||
|
||||
setCustomInputs(customInputs.concat(customInput));
|
||||
|
||||
console.log(customInput)
|
||||
setShowAddCustomModal(false);
|
||||
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();
|
||||
};
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<Head>
|
||||
|
@ -234,6 +259,12 @@ export default function EventType(props) {
|
|||
<span className="ml-2 text-sm">Phone call</span>
|
||||
</div>
|
||||
)}
|
||||
{location.type === LocationType.GoogleMeet && (
|
||||
<div className="flex-grow flex">
|
||||
<svg className="h-6 w-6" stroke="currentColor" fill="currentColor" stroke-width="0" role="img" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><title></title><path d="M12 0C6.28 0 1.636 4.641 1.636 10.364c0 5.421 4.945 9.817 10.364 9.817V24c6.295-3.194 10.364-8.333 10.364-13.636C22.364 4.64 17.72 0 12 0zM7.5 6.272h6.817a1.363 1.363 0 0 1 1.365 1.365v1.704l2.728-2.727v7.501l-2.726-2.726v1.703a1.362 1.362 0 0 1-1.365 1.365H7.5c-.35 0-.698-.133-.965-.4a1.358 1.358 0 0 1-.4-.965V7.637A1.362 1.362 0 0 1 7.5 6.272Z"></path></svg>
|
||||
<span className="ml-2 text-sm">Google Meet</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex">
|
||||
<button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button>
|
||||
<button onClick={() => removeLocation(location)}>
|
||||
|
@ -276,7 +307,7 @@ export default function EventType(props) {
|
|||
<label htmlFor="additionalFields" className="block text-sm font-medium text-gray-700">Additional Inputs</label>
|
||||
<ul className="w-96 mt-1">
|
||||
{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>
|
||||
<div>
|
||||
|
@ -291,11 +322,9 @@ export default function EventType(props) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button type="button" onClick={() => {
|
||||
}} className="mr-2 text-sm text-blue-600">Edit
|
||||
<button type="button" onClick={() => openEditCustomModel(customInput)} className="mr-2 text-sm text-blue-600">Edit
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
}}>
|
||||
<button onClick={(e) => removeCustom(customInput, e)}>
|
||||
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 "/>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -432,16 +461,20 @@ export default function EventType(props) {
|
|||
<div className="mb-2">
|
||||
<label htmlFor="label" className="block text-sm font-medium text-gray-700">Label</label>
|
||||
<div className="mt-1">
|
||||
<input type="text" name="label" id="label" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" />
|
||||
<input type="text" name="label" id="label" required
|
||||
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 className="flex items-center h-5">
|
||||
<input id="required" name="required" type="checkbox" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2" defaultChecked={true}/>
|
||||
<input id="required" name="required" type="checkbox" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2" defaultChecked={selectedCustomInput?.required ?? true}/>
|
||||
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||
Is required
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="id" id="id" value={selectedCustomInput?.id}/>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
|
@ -460,6 +493,17 @@ export default function EventType(props) {
|
|||
);
|
||||
}
|
||||
|
||||
const validJson = (jsonString: string) => {
|
||||
try {
|
||||
const o = JSON.parse(jsonString);
|
||||
if (o && typeof o === "object") {
|
||||
return o;
|
||||
}
|
||||
}
|
||||
catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const session = await getSession(context);
|
||||
if (!session) {
|
||||
|
@ -474,6 +518,49 @@ export async function getServerSideProps(context) {
|
|||
}
|
||||
});
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true
|
||||
}
|
||||
});
|
||||
|
||||
const integrations = [ {
|
||||
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
||||
enabled: credentials.find( (integration) => integration.type === "google_calendar" ) != null,
|
||||
type: "google_calendar",
|
||||
title: "Google Calendar",
|
||||
imageSrc: "integrations/google-calendar.png",
|
||||
description: "For personal and business accounts",
|
||||
}, {
|
||||
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
|
||||
type: "office365_calendar",
|
||||
enabled: credentials.find( (integration) => integration.type === "office365_calendar" ) != null,
|
||||
title: "Office 365 / Outlook.com Calendar",
|
||||
imageSrc: "integrations/office-365.png",
|
||||
description: "For personal and business accounts",
|
||||
} ];
|
||||
|
||||
let locationOptions: OptionBase[] = [
|
||||
{ value: LocationType.InPerson, label: 'In-person meeting' },
|
||||
{ value: LocationType.Phone, label: 'Phone call', },
|
||||
];
|
||||
|
||||
const hasGoogleCalendarIntegration = integrations.find((i) => i.type === "google_calendar" && i.installed === true && i.enabled)
|
||||
if (hasGoogleCalendarIntegration) {
|
||||
locationOptions.push( { value: LocationType.GoogleMeet, label: 'Google Meet' })
|
||||
}
|
||||
|
||||
const hasOfficeIntegration = integrations.find((i) => i.type === "office365_calendar" && i.installed === true && i.enabled)
|
||||
if (hasOfficeIntegration) {
|
||||
// TODO: Add default meeting option of the office integration.
|
||||
// Assuming it's Microsoft Teams.
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: parseInt(context.query.type),
|
||||
|
@ -494,7 +581,8 @@ export async function getServerSideProps(context) {
|
|||
return {
|
||||
props: {
|
||||
user,
|
||||
eventType
|
||||
eventType,
|
||||
locationOptions
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
121
pages/bookings/index.tsx
Normal file
121
pages/bookings/index.tsx
Normal file
|
@ -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 } };
|
||||
}
|
|
@ -134,38 +134,6 @@ export default function Type(props) {
|
|||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: context.query.user,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
slug: {
|
||||
equals: context.query.type,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
length: true
|
||||
}
|
||||
});
|
||||
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: context.query.uid,
|
||||
|
@ -176,7 +144,15 @@ export async function getServerSideProps(context) {
|
|||
description: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
attendees: true
|
||||
attendees: true,
|
||||
eventType: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -188,8 +164,8 @@ export async function getServerSideProps(context) {
|
|||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
eventType,
|
||||
user: booking.user,
|
||||
eventType: booking.eventType,
|
||||
booking: bookingObj
|
||||
},
|
||||
}
|
||||
|
|
|
@ -124,6 +124,11 @@ export default function Home(props) {
|
|||
</div>
|
||||
</li>
|
||||
))}
|
||||
{props.eventTypes.length == 0 &&
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
<p>You haven't created any event types.</p>
|
||||
</div>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-8 bg-white shadow overflow-hidden rounded-md p-6 mb-8 md:mb-0">
|
||||
|
@ -254,6 +259,11 @@ export default function Home(props) {
|
|||
</div>
|
||||
</li>
|
||||
))}
|
||||
{props.eventTypes.length == 0 &&
|
||||
<div className="text-center text-gray-400 py-2">
|
||||
<p>You haven't created any event types.</p>
|
||||
</div>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,140 +1,173 @@
|
|||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import prisma from '../../lib/prisma';
|
||||
import Modal from '../../components/Modal';
|
||||
import Shell from '../../components/Shell';
|
||||
import SettingsShell from '../../components/Settings';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import { signIn, useSession, getSession } from 'next-auth/client';
|
||||
import TimezoneSelect from 'react-timezone-select';
|
||||
import {UsernameInput} from "../../components/ui/UsernameInput";
|
||||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import { useRef, useState } from "react";
|
||||
import prisma from "../../lib/prisma";
|
||||
import Modal from "../../components/Modal";
|
||||
import Shell from "../../components/Shell";
|
||||
import SettingsShell from "../../components/Settings";
|
||||
import Avatar from "../../components/Avatar";
|
||||
import { getSession } from "next-auth/client";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import { UsernameInput } from "../../components/ui/UsernameInput";
|
||||
import ErrorAlert from "../../components/ui/alerts/Error";
|
||||
|
||||
export default function Settings(props) {
|
||||
const [ session, loading ] = useSession();
|
||||
const router = useRouter();
|
||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||
const usernameRef = useRef<HTMLInputElement>();
|
||||
const nameRef = useRef<HTMLInputElement>();
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>();
|
||||
const avatarRef = useRef<HTMLInputElement>();
|
||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||
const usernameRef = useRef<HTMLInputElement>();
|
||||
const nameRef = useRef<HTMLInputElement>();
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>();
|
||||
const avatarRef = useRef<HTMLInputElement>();
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
|
||||
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState(props.user.weekStart || "Sunday");
|
||||
|
||||
const [ selectedTimeZone, setSelectedTimeZone ] = useState({ value: props.user.timeZone });
|
||||
const [ selectedWeekStartDay, setSelectedWeekStartDay ] = useState(props.user.weekStart || 'Sunday');
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const [ hasErrors, setHasErrors ] = useState(false);
|
||||
const [ errorMessage, setErrorMessage ] = useState('');
|
||||
const closeSuccessModal = () => {
|
||||
setSuccessModalOpen(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
const handleError = async (resp) => {
|
||||
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) => {
|
||||
if (!resp.ok) {
|
||||
const error = await resp.json();
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
const enteredUsername = usernameRef.current.value.toLowerCase();
|
||||
const enteredName = nameRef.current.value;
|
||||
const enteredDescription = descriptionRef.current.value;
|
||||
const enteredAvatar = avatarRef.current.value;
|
||||
const enteredTimeZone = selectedTimeZone.value;
|
||||
const enteredWeekStartDay = selectedWeekStartDay;
|
||||
|
||||
async function updateProfileHandler(event) {
|
||||
event.preventDefault();
|
||||
// TODO: Add validation
|
||||
|
||||
const enteredUsername = usernameRef.current.value;
|
||||
const enteredName = nameRef.current.value;
|
||||
const enteredDescription = descriptionRef.current.value;
|
||||
const enteredAvatar = avatarRef.current.value;
|
||||
const enteredTimeZone = selectedTimeZone.value;
|
||||
const enteredWeekStartDay = selectedWeekStartDay;
|
||||
await fetch("/api/user/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
username: enteredUsername,
|
||||
name: enteredName,
|
||||
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', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({username: enteredUsername, name: enteredName, 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);
|
||||
});
|
||||
}
|
||||
<div className="mt-6 flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="flex">
|
||||
<div className="w-1/2 mr-2">
|
||||
<UsernameInput ref={usernameRef} defaultValue={props.user.username} />
|
||||
</div>
|
||||
<div className="w-1/2 ml-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Full name
|
||||
</label>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
<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 flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="flex">
|
||||
<div className="w-1/2 mr-2">
|
||||
<UsernameInput ref={usernameRef} defaultValue={props.user.username} />
|
||||
</div>
|
||||
<div className="w-1/2 ml-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Full name</label>
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="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">
|
||||
<label htmlFor="user_photo" className="relative text-sm leading-4 font-medium text-gray-700 pointer-events-none">
|
||||
<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" />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden relative rounded-full overflow-hidden lg:block">
|
||||
<Avatar
|
||||
user={props.user}
|
||||
className="relative rounded-full w-40 h-40"
|
||||
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">
|
||||
<div className="hidden relative rounded-full overflow-hidden lg:block">
|
||||
<Avatar
|
||||
user={props.user}
|
||||
className="relative rounded-full w-40 h-40"
|
||||
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">
|
||||
<span>Change</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" />
|
||||
</label> */}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="avatar" className="block text-sm font-medium text-gray-700">Avatar URL</label>
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="avatar" className="block text-sm font-medium text-gray-700">
|
||||
Avatar URL
|
||||
</label>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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) {
|
||||
const session = await getSession(context);
|
||||
if (!session) {
|
||||
return { redirect: { permanent: false, destination: '/auth/login' } };
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps = async (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,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
}
|
||||
});
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props: {user}, // will be passed to the page component as props
|
||||
}
|
||||
}
|
||||
return {
|
||||
props: { user }, // will be passed to the page component as props
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue