Added booking tabs, type fixing and refactoring (#825)
* More type fixes * More type fixes * Type fixes * Adds inputMode to email fields * Added booking tabs * Adds aditional notes to bookings
This commit is contained in:
parent
5318047794
commit
a04336ba06
29 changed files with 609 additions and 519 deletions
28
components/BookingsShell.tsx
Normal file
28
components/BookingsShell.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import NavTabs from "./NavTabs";
|
||||
|
||||
export default function BookingsShell(props) {
|
||||
const tabs = [
|
||||
{
|
||||
name: "Upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: "Past",
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: "Cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:mx-auto">
|
||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} />
|
||||
<hr />
|
||||
</div>
|
||||
<main className="max-w-4xl">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
50
components/NavTabs.tsx
Normal file
50
components/NavTabs.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import Link, { LinkProps } from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ElementType, FC } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
interface Props {
|
||||
tabs: {
|
||||
name: string;
|
||||
href: string;
|
||||
icon?: ElementType;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
}
|
||||
|
||||
const NavTabs: FC<Props> = ({ tabs, linkProps }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<nav className="-mb-px flex space-x-2 sm:space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const isCurrent = router.asPath === tab.href;
|
||||
return (
|
||||
<Link {...linkProps} key={tab.name} href={tab.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
"group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm"
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 mr-2 h-5 w-5 hidden sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavTabs;
|
|
@ -1,69 +0,0 @@
|
|||
import { CodeIcon, CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export default function SettingsShell(props) {
|
||||
const router = useRouter();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: "Profile",
|
||||
href: "/settings/profile",
|
||||
icon: UserIcon,
|
||||
current: router.pathname == "/settings/profile",
|
||||
},
|
||||
{
|
||||
name: "Security",
|
||||
href: "/settings/security",
|
||||
icon: KeyIcon,
|
||||
current: router.pathname == "/settings/security",
|
||||
},
|
||||
{ name: "Embed", href: "/settings/embed", icon: CodeIcon, current: router.pathname == "/settings/embed" },
|
||||
{
|
||||
name: "Teams",
|
||||
href: "/settings/teams",
|
||||
icon: UserGroupIcon,
|
||||
current: router.pathname == "/settings/teams",
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
current: router.pathname == "/settings/billing",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:mx-auto">
|
||||
<nav className="-mb-px flex space-x-2 sm:space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<Link key={tab.name} href={tab.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
tab.current
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
"group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm"
|
||||
)}
|
||||
aria-current={tab.current ? "page" : undefined}>
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
tab.current ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 mr-2 h-5 w-5 hidden sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<hr />
|
||||
</div>
|
||||
<main className="max-w-4xl">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
39
components/SettingsShell.tsx
Normal file
39
components/SettingsShell.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { CodeIcon, CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
export default function SettingsShell(props) {
|
||||
const tabs = [
|
||||
{
|
||||
name: "Profile",
|
||||
href: "/settings/profile",
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
name: "Security",
|
||||
href: "/settings/security",
|
||||
icon: KeyIcon,
|
||||
},
|
||||
{ name: "Embed", href: "/settings/embed", icon: CodeIcon },
|
||||
{
|
||||
name: "Teams",
|
||||
href: "/settings/teams",
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:mx-auto">
|
||||
<NavTabs tabs={tabs} />
|
||||
<hr />
|
||||
</div>
|
||||
<main className="max-w-4xl">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -77,37 +77,37 @@ export default function Shell(props: {
|
|||
name: "Event Types",
|
||||
href: "/event-types",
|
||||
icon: LinkIcon,
|
||||
current: router.pathname.startsWith("/event-types"),
|
||||
current: router.asPath.startsWith("/event-types"),
|
||||
},
|
||||
{
|
||||
name: "Bookings",
|
||||
href: "/bookings",
|
||||
href: "/bookings/upcoming",
|
||||
icon: ClockIcon,
|
||||
current: router.pathname.startsWith("/bookings"),
|
||||
current: router.asPath.startsWith("/bookings"),
|
||||
},
|
||||
{
|
||||
name: "Availability",
|
||||
href: "/availability",
|
||||
icon: CalendarIcon,
|
||||
current: router.pathname.startsWith("/availability"),
|
||||
current: router.asPath.startsWith("/availability"),
|
||||
},
|
||||
{
|
||||
name: "Integrations",
|
||||
href: "/integrations",
|
||||
icon: PuzzleIcon,
|
||||
current: router.pathname.startsWith("/integrations"),
|
||||
current: router.asPath.startsWith("/integrations"),
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: "/settings/profile",
|
||||
icon: CogIcon,
|
||||
current: router.pathname.startsWith("/settings"),
|
||||
current: router.asPath.startsWith("/settings"),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => {
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname));
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
|
||||
});
|
||||
}, [telemetry]);
|
||||
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
// TODO: replace headlessui with radix-ui
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
import { is24h, timeZone } from "../../lib/clock";
|
||||
|
||||
const TimeOptions = (props) => {
|
||||
type Props = {
|
||||
onSelectTimeZone: (selectedTimeZone: string) => void;
|
||||
onToggle24hClock: (is24hClock: boolean) => void;
|
||||
};
|
||||
|
||||
const TimeOptions: FC<Props> = (props) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [is24hClock, setIs24hClock] = useState(false);
|
||||
|
||||
|
@ -27,47 +32,45 @@ const TimeOptions = (props) => {
|
|||
props.onToggle24hClock(is24h(is24hClock));
|
||||
};
|
||||
|
||||
return (
|
||||
selectedTimeZone !== "" && (
|
||||
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group as="div" className="flex items-center justify-end">
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm dark:text-white text-gray-500">am/pm</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={handle24hClockToggle}
|
||||
return selectedTimeZone !== "" ? (
|
||||
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group as="div" className="flex items-center justify-end">
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm dark:text-white text-gray-500">am/pm</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={handle24hClockToggle}
|
||||
className={classNames(
|
||||
is24hClock ? "bg-black" : "dark:bg-gray-600 bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
|
||||
)}>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
is24hClock ? "bg-black" : "dark:bg-gray-600 bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
|
||||
)}>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<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 dark:text-white text-gray-500">24h</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
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 dark:text-white text-gray-500">24h</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz) => setSelectedTimeZone(tz.value)}
|
||||
className="mb-2 shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
className="mb-2 shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default TimeOptions;
|
||||
|
|
|
@ -22,11 +22,14 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
|||
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||
|
||||
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPageProps) => {
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
const { isReady } = useTheme(profile.theme);
|
||||
|
|
|
@ -264,6 +264,7 @@ const BookingPage = (props: BookingPageProps) => {
|
|||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
inputMode="email"
|
||||
required
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
|
|
93
components/ui/TableActions.tsx
Normal file
93
components/ui/TableActions.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import React, { FC, Fragment } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
import Button from "./Button";
|
||||
|
||||
type ActionType = {
|
||||
id: string;
|
||||
icon: SVGComponent;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
} & ({ href?: never; onClick: () => any } | { href: string; onClick?: never });
|
||||
|
||||
interface Props {
|
||||
actions: ActionType[];
|
||||
}
|
||||
|
||||
const TableActions: FC<Props> = ({ actions }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="space-x-2 hidden lg:block">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
data-testid={action.id}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
StartIcon={action.icon}
|
||||
disabled={action.disabled}
|
||||
color="secondary">
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Menu as="div" className="inline-block lg:hidden text-left ">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
|
||||
<span className="sr-only">Open options</span>
|
||||
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
|
||||
<div className="py-1">
|
||||
{actions.map((action) => {
|
||||
const Element = typeof action.onClick === "function" ? "span" : "a";
|
||||
return (
|
||||
<Menu.Item key={action.id} disabled={action.disabled}>
|
||||
{({ active }) => (
|
||||
<Element
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
className={classNames(
|
||||
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
|
||||
"group flex items-center px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<action.icon
|
||||
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{action.label}
|
||||
</Element>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableActions;
|
|
@ -1,21 +1,26 @@
|
|||
import { XIcon, CheckIcon } from "@heroicons/react/outline";
|
||||
import { CheckIcon, XIcon } from "@heroicons/react/outline";
|
||||
import React, { ForwardedRef, useEffect, useState } from "react";
|
||||
import { OptionsType } from "react-select/lib/types";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type CheckedSelectValue = {
|
||||
avatar: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
export type CheckedSelectProps = {
|
||||
defaultValue?: [];
|
||||
defaultValue?: CheckedSelectValue;
|
||||
placeholder?: string;
|
||||
name?: string;
|
||||
options: [];
|
||||
onChange: (options: OptionsType) => void;
|
||||
disabled: [];
|
||||
options: CheckedSelectValue;
|
||||
onChange: (options: CheckedSelectValue) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const CheckedSelect = React.forwardRef((props: CheckedSelectProps, ref: ForwardedRef<unknown>) => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<[]>(props.defaultValue || []);
|
||||
const [selectedOptions, setSelectedOptions] = useState<CheckedSelectValue>(props.defaultValue || []);
|
||||
|
||||
useEffect(() => {
|
||||
props.onChange(selectedOptions);
|
||||
|
@ -38,7 +43,7 @@ export const CheckedSelect = React.forwardRef((props: CheckedSelectProps, ref: F
|
|||
disabled: !!selectedOptions.find((selectedOption) => selectedOption.value === option.value),
|
||||
}));
|
||||
|
||||
const removeOption = (value) =>
|
||||
const removeOption = (value: string) =>
|
||||
setSelectedOptions(selectedOptions.filter((option) => option.value !== value));
|
||||
|
||||
const changeHandler = (selections) =>
|
||||
|
|
|
@ -6,8 +6,8 @@ import React from "react";
|
|||
import "react-calendar/dist/Calendar.css";
|
||||
|
||||
type Props = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onDatesChange?: ((arg: { startDate: Date; endDate: Date }) => void) | undefined;
|
||||
};
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ export const SelectComp = (props: PropsWithChildren<NamedProps>) => (
|
|||
<Select
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: "2px",
|
||||
borderRadius: 2,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
|
||||
|
|
|
@ -74,6 +74,11 @@ module.exports = () => plugins.reduce((acc, next) => next(acc), {
|
|||
destination: "/settings/profile",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/bookings",
|
||||
destination: "/bookings/upcoming",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
|
@ -17,8 +17,6 @@ export default function Type(props: AvailabilityPageProps) {
|
|||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const locale = await extractLocaleInfo(context.req);
|
||||
// get query params and typecast them to string
|
||||
// (would be even better to assert them instead of typecasting)
|
||||
const userParam = asStringOrNull(context.query.user);
|
||||
const typeParam = asStringOrNull(context.query.type);
|
||||
const dateParam = asStringOrNull(context.query.date);
|
||||
|
|
|
@ -102,6 +102,7 @@ export default function ForgotPassword({ csrfToken }) {
|
|||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
|
|
|
@ -100,6 +100,7 @@ export default function Login({ csrfToken }) {
|
|||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
|
|
|
@ -75,6 +75,7 @@ export default function Signup(props) {
|
|||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
inputMode="email"
|
||||
id="email"
|
||||
placeholder="jdoe@example.com"
|
||||
disabled={!!props.email}
|
||||
|
|
173
pages/bookings/[status].tsx
Normal file
173
pages/bookings/[status].tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
// TODO: replace headlessui with radix-ui
|
||||
import { BanIcon, CalendarIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import BookingsShell from "@components/BookingsShell";
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import TableActions from "@components/ui/TableActions";
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">[number];
|
||||
|
||||
function BookingListItem(booking: BookingItem) {
|
||||
const utils = trpc.useContext();
|
||||
const mutation = useMutation(
|
||||
async (confirm: boolean) => {
|
||||
const res = await fetch("/api/book/confirm", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ id: booking.id, confirmed: confirm }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new HttpError({ statusCode: res.status });
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQuery(["viewer.bookings"]);
|
||||
},
|
||||
}
|
||||
);
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
|
||||
const pendingActions = [
|
||||
{
|
||||
id: "confirm",
|
||||
label: "Confirm",
|
||||
onClick: () => mutation.mutate(true),
|
||||
icon: CheckIcon,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
onClick: () => mutation.mutate(false),
|
||||
icon: BanIcon,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
const bookedActions = [
|
||||
{
|
||||
id: "cancel",
|
||||
label: "Cancel",
|
||||
href: `/cancel/${booking.uid}`,
|
||||
icon: XIcon,
|
||||
},
|
||||
{
|
||||
id: "reschedule",
|
||||
label: "Reschedule",
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
icon: ClockIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Unconfirmed
|
||||
</span>
|
||||
)}
|
||||
<div className="text-sm text-neutral-900 font-medium truncate max-w-60 md:max-w-96">
|
||||
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
||||
{booking.title}
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
<div className="text-sm text-gray-900">
|
||||
{dayjs(booking.startTime).format("D MMMM YYYY")}:{" "}
|
||||
<small className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div className="text-sm text-neutral-600 truncate max-w-60 md:max-w-96">
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<div className="text-sm text-blue-500">
|
||||
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{dayjs(booking.startTime).format("D MMMM YYYY")}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
|
||||
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
|
||||
{!booking.confirmed && booking.rejected && <div className="text-sm text-gray-500">Rejected</div>}
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Bookings() {
|
||||
const router = useRouter();
|
||||
const query = trpc.useQuery(["viewer.bookings"]);
|
||||
const filtersByStatus = {
|
||||
upcoming: (booking: BookingItem) =>
|
||||
new Date(booking.endTime) >= new Date() && booking.status !== BookingStatus.CANCELLED,
|
||||
past: (booking: BookingItem) => new Date(booking.endTime) < new Date(),
|
||||
cancelled: (booking: BookingItem) => booking.status === BookingStatus.CANCELLED,
|
||||
} as const;
|
||||
const filterKey = (router.query?.status as string as keyof typeof filtersByStatus) || "upcoming";
|
||||
const appliedFilter = filtersByStatus[filterKey];
|
||||
const bookings = query.data?.filter(appliedFilter);
|
||||
|
||||
return (
|
||||
<Shell heading="Bookings" subtitle="See upcoming and past events booked through your event type links.">
|
||||
<BookingsShell>
|
||||
<div className="-mx-4 sm:mx-auto flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title="Something went wrong" message={query.error.message} />
|
||||
)}
|
||||
{query.status === "loading" && <Loader />}
|
||||
{bookings &&
|
||||
(bookings.length === 0 ? (
|
||||
<EmptyScreen
|
||||
Icon={CalendarIcon}
|
||||
headline="No upcoming bookings, yet"
|
||||
description="You have no upcoming bookings. As soon as someone books a time with you it will show up here."
|
||||
/>
|
||||
) : (
|
||||
<div className="my-6 border border-gray-200 overflow-hidden border-b rounded-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="bg-white divide-y divide-gray-200" data-testid="bookings">
|
||||
{bookings.map((booking) => (
|
||||
<BookingListItem key={booking.id} {...booking} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BookingsShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
|
@ -1,277 +1,16 @@
|
|||
// TODO: replace headlessui with radix-ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { BanIcon, CalendarIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { Fragment } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import { Button } from "@components/ui/Button";
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">[number];
|
||||
|
||||
function BookingListItem(booking: BookingItem) {
|
||||
const utils = trpc.useContext();
|
||||
const mutation = useMutation(
|
||||
async (confirm: boolean) => {
|
||||
const res = await fetch("/api/book/confirm", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ id: booking.id, confirmed: confirm }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new HttpError({ statusCode: res.status });
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQuery(["viewer.bookings"]);
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<tr>
|
||||
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Unconfirmed
|
||||
</span>
|
||||
)}
|
||||
<div className="text-sm text-neutral-900 font-medium truncate max-w-60 md:max-w-96">
|
||||
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
||||
{booking.title}
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
<div className="text-sm text-gray-900">
|
||||
{dayjs(booking.startTime).format("D MMMM YYYY")}:{" "}
|
||||
<small className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{booking.attendees.length !== 0 && (
|
||||
<div className="text-sm text-blue-500">
|
||||
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{dayjs(booking.startTime).format("D MMMM YYYY")}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<>
|
||||
<div className="space-x-2 hidden lg:block">
|
||||
<Button
|
||||
onClick={() => mutation.mutate(true)}
|
||||
StartIcon={CheckIcon}
|
||||
color="secondary"
|
||||
disabled={mutation.isLoading}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate(false)}
|
||||
StartIcon={BanIcon}
|
||||
color="secondary"
|
||||
disabled={mutation.isLoading}>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
<Menu as="div" className="inline-block lg:hidden text-left ">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
|
||||
<span className="sr-only">Open options</span>
|
||||
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<span
|
||||
onClick={() => mutation.mutate(true)}
|
||||
className={classNames(
|
||||
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
|
||||
"group flex items-center px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<CheckIcon
|
||||
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Confirm
|
||||
</span>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<span
|
||||
onClick={() => mutation.mutate(false)}
|
||||
className={classNames(
|
||||
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
|
||||
"group flex items-center px-4 py-2 text-sm w-full font-medium"
|
||||
)}>
|
||||
<BanIcon
|
||||
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Reject
|
||||
</span>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
{booking.confirmed && !booking.rejected && (
|
||||
<>
|
||||
<div className="space-x-2 hidden lg:block">
|
||||
<Button
|
||||
data-testid="cancel"
|
||||
href={"/cancel/" + booking.uid}
|
||||
StartIcon={XIcon}
|
||||
color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button href={"reschedule/" + booking.uid} StartIcon={ClockIcon} color="secondary">
|
||||
Reschedule
|
||||
</Button>
|
||||
</div>
|
||||
<Menu as="div" className="inline-block lg:hidden text-left ">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
|
||||
<span className="sr-only">Open options</span>
|
||||
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_APP_URL + "/../cancel/" + booking.uid}
|
||||
className={classNames(
|
||||
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
|
||||
"group flex items-center px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<XIcon
|
||||
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Cancel
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_APP_URL + "/../reschedule/" + booking.uid}
|
||||
className={classNames(
|
||||
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
|
||||
"group flex items-center px-4 py-2 text-sm w-full font-medium"
|
||||
)}>
|
||||
<ClockIcon
|
||||
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Reschedule
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
{!booking.confirmed && booking.rejected && <div className="text-sm text-gray-500">Rejected</div>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
function RedirectPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Bookings() {
|
||||
const query = trpc.useQuery(["viewer.bookings"]);
|
||||
const bookings = query.data;
|
||||
export async function getServerSideProps(context) {
|
||||
const session = await getSession(context);
|
||||
if (!session?.user?.id) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell heading="Bookings" subtitle="See upcoming and past events booked through your event type links.">
|
||||
<div className="-mx-4 sm:mx-auto flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title="Something went wrong" message={query.error.message} />
|
||||
)}
|
||||
{query.status === "loading" && <Loader />}
|
||||
{bookings &&
|
||||
(bookings.length === 0 ? (
|
||||
<EmptyScreen
|
||||
Icon={CalendarIcon}
|
||||
headline="No upcoming bookings, yet"
|
||||
description="You have no upcoming bookings. As soon as someone books a time with you it will show up here."
|
||||
/>
|
||||
) : (
|
||||
<div className="border border-gray-200 overflow-hidden border-b rounded-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="bg-white divide-y divide-gray-200" data-testid="bookings">
|
||||
{bookings
|
||||
.filter((booking) => booking.status !== BookingStatus.CANCELLED)
|
||||
.map((booking) => (
|
||||
<BookingListItem key={booking.id} {...booking} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
return { redirect: { permanent: false, destination: "/bookings/upcoming" } };
|
||||
}
|
||||
|
||||
export default RedirectPage;
|
||||
|
|
|
@ -27,7 +27,12 @@ import Select, { OptionTypeBase } from "react-select";
|
|||
|
||||
import { StripeData } from "@ee/lib/stripe/server";
|
||||
|
||||
import { asNumberOrThrow, asNumberOrUndefined, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import {
|
||||
asNumberOrThrow,
|
||||
asNumberOrUndefined,
|
||||
asStringOrThrow,
|
||||
asStringOrUndefined,
|
||||
} from "@lib/asStringOrNull";
|
||||
import { getSession } from "@lib/auth";
|
||||
import classNames from "@lib/classNames";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
@ -137,13 +142,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
const isAdvancedSettingsVisible = !!eventNameRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTimeZone(eventType.timeZone);
|
||||
setSelectedTimeZone(eventType.timeZone || "");
|
||||
}, []);
|
||||
|
||||
async function updateEventTypeHandler(event) {
|
||||
async function updateEventTypeHandler(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = Object.fromEntries(new FormData(event.target).entries());
|
||||
const formData = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||
|
||||
const enteredTitle: string = titleRef.current!.value;
|
||||
|
||||
|
@ -191,7 +196,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
updateMutation.mutate(payload);
|
||||
}
|
||||
|
||||
async function deleteEventTypeHandler(event) {
|
||||
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
||||
event.preventDefault();
|
||||
|
||||
const payload = { id: eventType.id };
|
||||
|
@ -218,33 +223,34 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
setSuccessModalOpen(false);
|
||||
};
|
||||
|
||||
const updateLocations = (e) => {
|
||||
const updateLocations = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const newLocation = e.currentTarget.location.value;
|
||||
|
||||
let details = {};
|
||||
if (e.target.location.value === LocationType.InPerson) {
|
||||
details = { address: e.target.address.value };
|
||||
if (newLocation === LocationType.InPerson) {
|
||||
details = { address: e.currentTarget.address.value };
|
||||
}
|
||||
|
||||
const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type);
|
||||
const existingIdx = locations.findIndex((loc) => newLocation === loc.type);
|
||||
if (existingIdx !== -1) {
|
||||
const copy = locations;
|
||||
copy[existingIdx] = { ...locations[existingIdx], ...details };
|
||||
setLocations(copy);
|
||||
} else {
|
||||
setLocations(locations.concat({ type: e.target.location.value, ...details }));
|
||||
setLocations(locations.concat({ type: newLocation, ...details }));
|
||||
}
|
||||
|
||||
setShowLocationModal(false);
|
||||
};
|
||||
|
||||
const removeLocation = (selectedLocation) => {
|
||||
const removeLocation = (selectedLocation: typeof eventType.locations[number]) => {
|
||||
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
||||
};
|
||||
|
||||
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
|
||||
setSelectedCustomInput(customInput);
|
||||
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
|
||||
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type)!);
|
||||
setShowAddCustomModal(true);
|
||||
};
|
||||
|
||||
|
@ -283,14 +289,16 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
return null;
|
||||
};
|
||||
|
||||
const updateCustom = (e) => {
|
||||
const updateCustom = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customInput: EventTypeCustomInput = {
|
||||
label: e.target.label.value,
|
||||
placeholder: e.target.placeholder?.value,
|
||||
required: e.target.required.checked,
|
||||
type: e.target.type.value,
|
||||
id: -1,
|
||||
eventTypeId: -1,
|
||||
label: e.currentTarget.label.value,
|
||||
placeholder: e.currentTarget.placeholder?.value,
|
||||
required: e.currentTarget.required.checked,
|
||||
type: e.currentTarget.type.value,
|
||||
};
|
||||
|
||||
if (selectedCustomInput) {
|
||||
|
@ -309,7 +317,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
setCustomInputs([...customInputs]);
|
||||
};
|
||||
|
||||
const schedulingTypeOptions: { value: string; label: string }[] = [
|
||||
const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [
|
||||
{
|
||||
value: SchedulingType.COLLECTIVE,
|
||||
label: "Collective",
|
||||
|
@ -327,6 +335,24 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
endDate: new Date(eventType.periodEndDate || Date.now()),
|
||||
});
|
||||
|
||||
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/${
|
||||
team ? `team/${team.slug}` : eventType.users[0].username
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const mapUserToValue = ({
|
||||
id,
|
||||
name,
|
||||
avatar,
|
||||
}: {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
}) => ({
|
||||
value: `${id || ""}`,
|
||||
label: `${name || ""}`,
|
||||
avatar: `${avatar || ""}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell
|
||||
|
@ -343,7 +369,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
defaultValue={eventType.title}
|
||||
/>
|
||||
}
|
||||
subtitle={eventType.description}>
|
||||
subtitle={eventType.description || ""}>
|
||||
<div className="block sm:flex">
|
||||
<div className="w-full mr-2 sm:w-10/12">
|
||||
<div className="p-4 py-6 -mx-4 bg-white border rounded-sm border-neutral-200 sm:mx-0 sm:px-8">
|
||||
|
@ -403,10 +429,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
name="location"
|
||||
id="location"
|
||||
options={locationOptions}
|
||||
isSearchable="false"
|
||||
isSearchable={false}
|
||||
classNamePrefix="react-select"
|
||||
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
onChange={(e) => openLocationModal(e.value)}
|
||||
onChange={(e) => openLocationModal(e?.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -534,7 +560,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
id="description"
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="A quick video meeting."
|
||||
defaultValue={eventType.description}></textarea>
|
||||
defaultValue={asStringOrUndefined(eventType.description)}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -551,7 +577,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
</div>
|
||||
<RadioArea.Select
|
||||
name="schedulingType"
|
||||
value={eventType.schedulingType}
|
||||
value={asStringOrUndefined(eventType.schedulingType)}
|
||||
options={schedulingTypeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
@ -564,17 +590,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<CheckedSelect
|
||||
onChange={(options: unknown) => setUsers(options.map((option) => option.value))}
|
||||
defaultValue={eventType.users.map((user: User) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
avatar: user.avatar,
|
||||
}))}
|
||||
options={teamMembers.map((user: User) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
avatar: user.avatar,
|
||||
}))}
|
||||
onChange={(options) => setUsers(options.map((option) => option.value))}
|
||||
defaultValue={eventType.users.map(mapUserToValue)}
|
||||
options={teamMembers.map(mapUserToValue)}
|
||||
id="users"
|
||||
placeholder="Add attendees"
|
||||
/>
|
||||
|
@ -921,7 +939,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
label="Hide event type"
|
||||
/>
|
||||
<a
|
||||
href={"/" + (team ? "team/" + team.slug : eventType.users[0].username) + "/" + eventType.slug}
|
||||
href={permalink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex font-medium text-md text-neutral-700">
|
||||
|
@ -930,12 +948,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
(`${process.env.NEXT_PUBLIC_APP_URL}/` ?? "https://cal.com/") +
|
||||
(team ? "team/" + team.slug : eventType.users[0].username) +
|
||||
"/" +
|
||||
eventType.slug
|
||||
);
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Link copied!", "success");
|
||||
}}
|
||||
type="button"
|
||||
|
@ -991,7 +1004,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
name="location"
|
||||
defaultValue={selectedLocation}
|
||||
options={locationOptions}
|
||||
isSearchable="false"
|
||||
isSearchable={false}
|
||||
classNamePrefix="react-select"
|
||||
className="flex-1 block w-full min-w-0 my-4 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
onChange={setSelectedLocation}
|
||||
|
@ -1051,7 +1064,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
name="type"
|
||||
defaultValue={selectedInputOption}
|
||||
options={inputOptions}
|
||||
isSearchable="false"
|
||||
isSearchable={false}
|
||||
required
|
||||
className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm"
|
||||
onChange={setSelectedInputOption}
|
||||
|
@ -1138,12 +1151,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
name: true,
|
||||
username: true,
|
||||
id: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
});
|
||||
|
||||
const eventType = await prisma.eventType.findFirst({
|
||||
const rawEventType = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
|
@ -1210,11 +1224,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
if (!rawEventType) throw Error("Event type not found");
|
||||
|
||||
type Location = {
|
||||
type: LocationType;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
const { locations, ...restEventType } = rawEventType;
|
||||
const eventType = {
|
||||
...restEventType,
|
||||
locations: locations as unknown as Location[],
|
||||
};
|
||||
|
||||
// backwards compat
|
||||
if (eventType.users.length === 0 && !eventType.team) {
|
||||
|
@ -1274,7 +1295,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
const teamMembers = eventTypeObject.team
|
||||
? eventTypeObject.team.members.map((member) => {
|
||||
const user = member.user;
|
||||
user.avatar = user.avatar || defaultAvatarSrc({ email: user.email });
|
||||
user.avatar = user.avatar || defaultAvatarSrc({ email: asStringOrUndefined(user.email) });
|
||||
return user;
|
||||
})
|
||||
: [];
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { asStringOrUndefined } from "@lib/asStringOrNull";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
export default function Type() {
|
||||
|
@ -11,7 +11,7 @@ export default function Type() {
|
|||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: asStringOrNull(context.query.uid),
|
||||
uid: asStringOrUndefined(context.query.uid),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
@ -39,11 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
},
|
||||
});
|
||||
|
||||
if (!booking.eventType) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
if (!booking?.eventType) throw Error("This booking doesn't exists");
|
||||
|
||||
const eventType = booking.eventType;
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { GetServerSidePropsContext } from "next";
|
|||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import SettingsShell from "@components/Settings";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell from "@components/Shell";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import prisma from "@lib/prisma";
|
|||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import SettingsShell from "@components/Settings";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell from "@components/Shell";
|
||||
|
||||
export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
|
|
|
@ -16,7 +16,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
|
|||
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import Modal from "@components/Modal";
|
||||
import SettingsShell from "@components/Settings";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
|
|
@ -4,7 +4,7 @@ import React from "react";
|
|||
import prisma from "@lib/prisma";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import SettingsShell from "@components/Settings";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell from "@components/Shell";
|
||||
import ChangePasswordSection from "@components/security/ChangePasswordSection";
|
||||
import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection";
|
||||
|
|
|
@ -10,7 +10,7 @@ import { Member } from "@lib/member";
|
|||
import { Team } from "@lib/team";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import SettingsShell from "@components/Settings";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell from "@components/Shell";
|
||||
import EditTeam from "@components/team/EditTeam";
|
||||
import TeamList from "@components/team/TeamList";
|
||||
|
|
|
@ -239,6 +239,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
|||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
inputMode="email"
|
||||
defaultValue={router.query.email}
|
||||
className="shadow-sm text-gray-600 dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300"
|
||||
placeholder="rick.astley@cal.com"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import { InferGetServerSidePropsType } from "next";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
|
@ -7,6 +8,7 @@ import useTheme from "@lib/hooks/useTheme";
|
|||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import prisma from "@lib/prisma";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
@ -16,7 +18,7 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
|||
import Button from "@components/ui/Button";
|
||||
import Text from "@components/ui/Text";
|
||||
|
||||
function TeamPage({ team }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { isReady } = useTheme();
|
||||
const showMembers = useToggleQuery("members");
|
||||
|
||||
|
@ -39,8 +41,8 @@ function TeamPage({ team }: InferGetServerSidePropsType<typeof getServerSideProp
|
|||
className="flex-shrink-0"
|
||||
size={10}
|
||||
items={type.users.map((user) => ({
|
||||
alt: user.name,
|
||||
image: user.avatar,
|
||||
alt: user.name || "",
|
||||
image: user.avatar || "",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
@ -51,18 +53,16 @@ function TeamPage({ team }: InferGetServerSidePropsType<typeof getServerSideProp
|
|||
</ul>
|
||||
);
|
||||
|
||||
const teamName = team.name || "Nameless Team";
|
||||
|
||||
return (
|
||||
isReady && (
|
||||
<div>
|
||||
<HeadSeo title={team.name} description={team.name} />
|
||||
<HeadSeo title={teamName} description={teamName} />
|
||||
<div className="pt-24 pb-12 px-4">
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar
|
||||
displayName={team.name}
|
||||
imageSrc={team.logo}
|
||||
className="mx-auto w-20 h-20 rounded-full mb-4"
|
||||
/>
|
||||
<Text variant="headline">{team.name}</Text>
|
||||
<Avatar alt={teamName} imageSrc={team.logo} className="mx-auto w-20 h-20 rounded-full mb-4" />
|
||||
<Text variant="headline">{teamName}</Text>
|
||||
</div>
|
||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||
{!showMembers.isOn && team.eventTypes.length && (
|
||||
|
@ -97,10 +97,19 @@ function TeamPage({ team }: InferGetServerSidePropsType<typeof getServerSideProp
|
|||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context) => {
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
|
||||
const teamSelectInput = {
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
username: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
name: true,
|
||||
id: true,
|
||||
bio: true,
|
||||
});
|
||||
|
||||
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
|
@ -108,13 +117,7 @@ export const getServerSideProps = async (context) => {
|
|||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
avatar: true,
|
||||
name: true,
|
||||
id: true,
|
||||
bio: true,
|
||||
},
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -129,36 +132,29 @@ export const getServerSideProps = async (context) => {
|
|||
length: true,
|
||||
slug: true,
|
||||
schedulingType: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
},
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
select: teamSelectInput,
|
||||
select: teamSelect,
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
if (!team) return { notFound: true };
|
||||
|
||||
team.eventTypes = team.eventTypes.map((type) => ({
|
||||
...type,
|
||||
users: type.users.map((user) => ({
|
||||
...user,
|
||||
avatar: user.avatar || defaultAvatarSrc({ email: user.email }),
|
||||
avatar: user.avatar || defaultAvatarSrc({ email: user.email || "" }),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
import { Availability, EventType } from "@prisma/client";
|
||||
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
||||
|
||||
export default function TeamType(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
export type AvailabilityTeamPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function TeamType(props: AvailabilityTeamPageProps) {
|
||||
return <AvailabilityPage {...props} />;
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
// get query params and typecast them to string
|
||||
// (would be even better to assert them instead of typecasting)
|
||||
const locale = await extractLocaleInfo(context.req);
|
||||
const slugParam = asStringOrNull(context.query.slug);
|
||||
const typeParam = asStringOrNull(context.query.type);
|
||||
const dateParam = asStringOrNull(context.query.date);
|
||||
|
||||
if (!slugParam || !typeParam) {
|
||||
throw new Error(`File is not named [idOrSlug]/[user]`);
|
||||
|
@ -49,6 +53,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
description: true,
|
||||
length: true,
|
||||
schedulingType: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -57,23 +63,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
if (!team || team.eventTypes.length != 1) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
};
|
||||
}
|
||||
|
||||
const profile = {
|
||||
name: team.name,
|
||||
slug: team.slug,
|
||||
image: team.logo || null,
|
||||
};
|
||||
const [eventType] = team.eventTypes;
|
||||
|
||||
const eventType: EventType = team.eventTypes[0];
|
||||
type Availability = typeof eventType["availability"];
|
||||
const getWorkingHours = (availability: Availability) => (availability?.length ? availability : null);
|
||||
const workingHours = getWorkingHours(eventType.availability) || [];
|
||||
|
||||
const getWorkingHours = (providesAvailability: { availability: Availability[] }) =>
|
||||
providesAvailability.availability && providesAvailability.availability.length
|
||||
? providesAvailability.availability
|
||||
: null;
|
||||
|
||||
const workingHours = getWorkingHours(eventType) || [];
|
||||
workingHours.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
|
@ -83,10 +81,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
return {
|
||||
props: {
|
||||
profile,
|
||||
team,
|
||||
localeProp: locale,
|
||||
profile: {
|
||||
name: team.name,
|
||||
slug: team.slug,
|
||||
image: team.logo || null,
|
||||
theme: null,
|
||||
},
|
||||
date: dateParam,
|
||||
eventType: eventTypeObject,
|
||||
workingHours,
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue