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",
|
name: "Event Types",
|
||||||
href: "/event-types",
|
href: "/event-types",
|
||||||
icon: LinkIcon,
|
icon: LinkIcon,
|
||||||
current: router.pathname.startsWith("/event-types"),
|
current: router.asPath.startsWith("/event-types"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Bookings",
|
name: "Bookings",
|
||||||
href: "/bookings",
|
href: "/bookings/upcoming",
|
||||||
icon: ClockIcon,
|
icon: ClockIcon,
|
||||||
current: router.pathname.startsWith("/bookings"),
|
current: router.asPath.startsWith("/bookings"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Availability",
|
name: "Availability",
|
||||||
href: "/availability",
|
href: "/availability",
|
||||||
icon: CalendarIcon,
|
icon: CalendarIcon,
|
||||||
current: router.pathname.startsWith("/availability"),
|
current: router.asPath.startsWith("/availability"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Integrations",
|
name: "Integrations",
|
||||||
href: "/integrations",
|
href: "/integrations",
|
||||||
icon: PuzzleIcon,
|
icon: PuzzleIcon,
|
||||||
current: router.pathname.startsWith("/integrations"),
|
current: router.asPath.startsWith("/integrations"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
href: "/settings/profile",
|
href: "/settings/profile",
|
||||||
icon: CogIcon,
|
icon: CogIcon,
|
||||||
current: router.pathname.startsWith("/settings"),
|
current: router.asPath.startsWith("/settings"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
telemetry.withJitsu((jitsu) => {
|
telemetry.withJitsu((jitsu) => {
|
||||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname));
|
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
|
||||||
});
|
});
|
||||||
}, [telemetry]);
|
}, [telemetry]);
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
// TODO: replace headlessui with radix-ui
|
// TODO: replace headlessui with radix-ui
|
||||||
import { Switch } from "@headlessui/react";
|
import { Switch } from "@headlessui/react";
|
||||||
import { useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import TimezoneSelect from "react-timezone-select";
|
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
import { is24h, timeZone } from "../../lib/clock";
|
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 [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||||
const [is24hClock, setIs24hClock] = useState(false);
|
const [is24hClock, setIs24hClock] = useState(false);
|
||||||
|
|
||||||
|
@ -27,8 +32,7 @@ const TimeOptions = (props) => {
|
||||||
props.onToggle24hClock(is24h(is24hClock));
|
props.onToggle24hClock(is24h(is24hClock));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return selectedTimeZone !== "" ? (
|
||||||
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="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||||
<div className="flex mb-4">
|
<div className="flex mb-4">
|
||||||
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
|
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
|
||||||
|
@ -62,12 +66,11 @@ const TimeOptions = (props) => {
|
||||||
<TimezoneSelect
|
<TimezoneSelect
|
||||||
id="timeZone"
|
id="timeZone"
|
||||||
value={selectedTimeZone}
|
value={selectedTimeZone}
|
||||||
onChange={(tz) => setSelectedTimeZone(tz.value)}
|
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"
|
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>
|
</div>
|
||||||
)
|
) : null;
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TimeOptions;
|
export default TimeOptions;
|
||||||
|
|
|
@ -22,11 +22,14 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||||
|
|
||||||
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||||
|
import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(customParseFormat);
|
dayjs.extend(customParseFormat);
|
||||||
|
|
||||||
const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPageProps) => {
|
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
|
||||||
|
|
||||||
|
const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { rescheduleUid } = router.query;
|
const { rescheduleUid } = router.query;
|
||||||
const { isReady } = useTheme(profile.theme);
|
const { isReady } = useTheme(profile.theme);
|
||||||
|
|
|
@ -264,6 +264,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
|
inputMode="email"
|
||||||
required
|
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"
|
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"
|
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 React, { ForwardedRef, useEffect, useState } from "react";
|
||||||
import { OptionsType } from "react-select/lib/types";
|
|
||||||
|
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import Select from "@components/ui/form/Select";
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
|
type CheckedSelectValue = {
|
||||||
|
avatar: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
export type CheckedSelectProps = {
|
export type CheckedSelectProps = {
|
||||||
defaultValue?: [];
|
defaultValue?: CheckedSelectValue;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
options: [];
|
options: CheckedSelectValue;
|
||||||
onChange: (options: OptionsType) => void;
|
onChange: (options: CheckedSelectValue) => void;
|
||||||
disabled: [];
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CheckedSelect = React.forwardRef((props: CheckedSelectProps, ref: ForwardedRef<unknown>) => {
|
export const CheckedSelect = React.forwardRef((props: CheckedSelectProps, ref: ForwardedRef<unknown>) => {
|
||||||
const [selectedOptions, setSelectedOptions] = useState<[]>(props.defaultValue || []);
|
const [selectedOptions, setSelectedOptions] = useState<CheckedSelectValue>(props.defaultValue || []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.onChange(selectedOptions);
|
props.onChange(selectedOptions);
|
||||||
|
@ -38,7 +43,7 @@ export const CheckedSelect = React.forwardRef((props: CheckedSelectProps, ref: F
|
||||||
disabled: !!selectedOptions.find((selectedOption) => selectedOption.value === option.value),
|
disabled: !!selectedOptions.find((selectedOption) => selectedOption.value === option.value),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const removeOption = (value) =>
|
const removeOption = (value: string) =>
|
||||||
setSelectedOptions(selectedOptions.filter((option) => option.value !== value));
|
setSelectedOptions(selectedOptions.filter((option) => option.value !== value));
|
||||||
|
|
||||||
const changeHandler = (selections) =>
|
const changeHandler = (selections) =>
|
||||||
|
|
|
@ -6,8 +6,8 @@ import React from "react";
|
||||||
import "react-calendar/dist/Calendar.css";
|
import "react-calendar/dist/Calendar.css";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
startDate: string;
|
startDate: Date;
|
||||||
endDate: string;
|
endDate: Date;
|
||||||
onDatesChange?: ((arg: { startDate: Date; endDate: Date }) => void) | undefined;
|
onDatesChange?: ((arg: { startDate: Date; endDate: Date }) => void) | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ export const SelectComp = (props: PropsWithChildren<NamedProps>) => (
|
||||||
<Select
|
<Select
|
||||||
theme={(theme) => ({
|
theme={(theme) => ({
|
||||||
...theme,
|
...theme,
|
||||||
borderRadius: "2px",
|
borderRadius: 2,
|
||||||
colors: {
|
colors: {
|
||||||
...theme.colors,
|
...theme.colors,
|
||||||
primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
|
primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
|
||||||
|
|
|
@ -74,6 +74,11 @@ module.exports = () => plugins.reduce((acc, next) => next(acc), {
|
||||||
destination: "/settings/profile",
|
destination: "/settings/profile",
|
||||||
permanent: true,
|
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) => {
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
const locale = await extractLocaleInfo(context.req);
|
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 userParam = asStringOrNull(context.query.user);
|
||||||
const typeParam = asStringOrNull(context.query.type);
|
const typeParam = asStringOrNull(context.query.type);
|
||||||
const dateParam = asStringOrNull(context.query.date);
|
const dateParam = asStringOrNull(context.query.date);
|
||||||
|
|
|
@ -102,6 +102,7 @@ export default function ForgotPassword({ csrfToken }) {
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
inputMode="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
placeholder="john.doe@example.com"
|
placeholder="john.doe@example.com"
|
||||||
required
|
required
|
||||||
|
|
|
@ -100,6 +100,7 @@ export default function Login({ csrfToken }) {
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
inputMode="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
|
|
|
@ -75,6 +75,7 @@ export default function Signup(props) {
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
|
inputMode="email"
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="jdoe@example.com"
|
placeholder="jdoe@example.com"
|
||||||
disabled={!!props.email}
|
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 { getSession } from "@lib/auth";
|
||||||
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 classNames from "@lib/classNames";
|
function RedirectPage() {
|
||||||
import { HttpError } from "@lib/core/http/error";
|
return null;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Bookings() {
|
export async function getServerSideProps(context) {
|
||||||
const query = trpc.useQuery(["viewer.bookings"]);
|
const session = await getSession(context);
|
||||||
const bookings = query.data;
|
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 { 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 { getSession } from "@lib/auth";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
|
@ -137,13 +142,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
const isAdvancedSettingsVisible = !!eventNameRef.current;
|
const isAdvancedSettingsVisible = !!eventNameRef.current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTimeZone(eventType.timeZone);
|
setSelectedTimeZone(eventType.timeZone || "");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function updateEventTypeHandler(event) {
|
async function updateEventTypeHandler(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
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;
|
const enteredTitle: string = titleRef.current!.value;
|
||||||
|
|
||||||
|
@ -191,7 +196,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
updateMutation.mutate(payload);
|
updateMutation.mutate(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEventTypeHandler(event) {
|
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const payload = { id: eventType.id };
|
const payload = { id: eventType.id };
|
||||||
|
@ -218,33 +223,34 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
setSuccessModalOpen(false);
|
setSuccessModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateLocations = (e) => {
|
const updateLocations = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const newLocation = e.currentTarget.location.value;
|
||||||
|
|
||||||
let details = {};
|
let details = {};
|
||||||
if (e.target.location.value === LocationType.InPerson) {
|
if (newLocation === LocationType.InPerson) {
|
||||||
details = { address: e.target.address.value };
|
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) {
|
if (existingIdx !== -1) {
|
||||||
const copy = locations;
|
const copy = locations;
|
||||||
copy[existingIdx] = { ...locations[existingIdx], ...details };
|
copy[existingIdx] = { ...locations[existingIdx], ...details };
|
||||||
setLocations(copy);
|
setLocations(copy);
|
||||||
} else {
|
} else {
|
||||||
setLocations(locations.concat({ type: e.target.location.value, ...details }));
|
setLocations(locations.concat({ type: newLocation, ...details }));
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowLocationModal(false);
|
setShowLocationModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLocation = (selectedLocation) => {
|
const removeLocation = (selectedLocation: typeof eventType.locations[number]) => {
|
||||||
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
|
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
|
||||||
setSelectedCustomInput(customInput);
|
setSelectedCustomInput(customInput);
|
||||||
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type));
|
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type)!);
|
||||||
setShowAddCustomModal(true);
|
setShowAddCustomModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -283,14 +289,16 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCustom = (e) => {
|
const updateCustom = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const customInput: EventTypeCustomInput = {
|
const customInput: EventTypeCustomInput = {
|
||||||
label: e.target.label.value,
|
id: -1,
|
||||||
placeholder: e.target.placeholder?.value,
|
eventTypeId: -1,
|
||||||
required: e.target.required.checked,
|
label: e.currentTarget.label.value,
|
||||||
type: e.target.type.value,
|
placeholder: e.currentTarget.placeholder?.value,
|
||||||
|
required: e.currentTarget.required.checked,
|
||||||
|
type: e.currentTarget.type.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (selectedCustomInput) {
|
if (selectedCustomInput) {
|
||||||
|
@ -309,7 +317,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
setCustomInputs([...customInputs]);
|
setCustomInputs([...customInputs]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const schedulingTypeOptions: { value: string; label: string }[] = [
|
const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [
|
||||||
{
|
{
|
||||||
value: SchedulingType.COLLECTIVE,
|
value: SchedulingType.COLLECTIVE,
|
||||||
label: "Collective",
|
label: "Collective",
|
||||||
|
@ -327,6 +335,24 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
endDate: new Date(eventType.periodEndDate || Date.now()),
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Shell
|
<Shell
|
||||||
|
@ -343,7 +369,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
defaultValue={eventType.title}
|
defaultValue={eventType.title}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
subtitle={eventType.description}>
|
subtitle={eventType.description || ""}>
|
||||||
<div className="block sm:flex">
|
<div className="block sm:flex">
|
||||||
<div className="w-full mr-2 sm:w-10/12">
|
<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">
|
<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"
|
name="location"
|
||||||
id="location"
|
id="location"
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
isSearchable="false"
|
isSearchable={false}
|
||||||
classNamePrefix="react-select"
|
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"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -534,7 +560,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
id="description"
|
id="description"
|
||||||
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
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."
|
placeholder="A quick video meeting."
|
||||||
defaultValue={eventType.description}></textarea>
|
defaultValue={asStringOrUndefined(eventType.description)}></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -551,7 +577,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
</div>
|
</div>
|
||||||
<RadioArea.Select
|
<RadioArea.Select
|
||||||
name="schedulingType"
|
name="schedulingType"
|
||||||
value={eventType.schedulingType}
|
value={asStringOrUndefined(eventType.schedulingType)}
|
||||||
options={schedulingTypeOptions}
|
options={schedulingTypeOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -564,17 +590,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
<CheckedSelect
|
<CheckedSelect
|
||||||
onChange={(options: unknown) => setUsers(options.map((option) => option.value))}
|
onChange={(options) => setUsers(options.map((option) => option.value))}
|
||||||
defaultValue={eventType.users.map((user: User) => ({
|
defaultValue={eventType.users.map(mapUserToValue)}
|
||||||
value: user.id,
|
options={teamMembers.map(mapUserToValue)}
|
||||||
label: user.name,
|
|
||||||
avatar: user.avatar,
|
|
||||||
}))}
|
|
||||||
options={teamMembers.map((user: User) => ({
|
|
||||||
value: user.id,
|
|
||||||
label: user.name,
|
|
||||||
avatar: user.avatar,
|
|
||||||
}))}
|
|
||||||
id="users"
|
id="users"
|
||||||
placeholder="Add attendees"
|
placeholder="Add attendees"
|
||||||
/>
|
/>
|
||||||
|
@ -921,7 +939,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
label="Hide event type"
|
label="Hide event type"
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href={"/" + (team ? "team/" + team.slug : eventType.users[0].username) + "/" + eventType.slug}
|
href={permalink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="flex font-medium text-md text-neutral-700">
|
className="flex font-medium text-md text-neutral-700">
|
||||||
|
@ -930,12 +948,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(permalink);
|
||||||
(`${process.env.NEXT_PUBLIC_APP_URL}/` ?? "https://cal.com/") +
|
|
||||||
(team ? "team/" + team.slug : eventType.users[0].username) +
|
|
||||||
"/" +
|
|
||||||
eventType.slug
|
|
||||||
);
|
|
||||||
showToast("Link copied!", "success");
|
showToast("Link copied!", "success");
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -991,7 +1004,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
name="location"
|
name="location"
|
||||||
defaultValue={selectedLocation}
|
defaultValue={selectedLocation}
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
isSearchable="false"
|
isSearchable={false}
|
||||||
classNamePrefix="react-select"
|
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"
|
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}
|
onChange={setSelectedLocation}
|
||||||
|
@ -1051,7 +1064,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
name="type"
|
name="type"
|
||||||
defaultValue={selectedInputOption}
|
defaultValue={selectedInputOption}
|
||||||
options={inputOptions}
|
options={inputOptions}
|
||||||
isSearchable="false"
|
isSearchable={false}
|
||||||
required
|
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"
|
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}
|
onChange={setSelectedInputOption}
|
||||||
|
@ -1138,12 +1151,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
|
|
||||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||||
name: true,
|
name: true,
|
||||||
|
username: true,
|
||||||
id: true,
|
id: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
email: true,
|
email: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findFirst({
|
const rawEventType = await prisma.eventType.findFirst({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
|
@ -1210,11 +1224,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!eventType) {
|
if (!rawEventType) throw Error("Event type not found");
|
||||||
return {
|
|
||||||
notFound: true,
|
type Location = {
|
||||||
|
type: LocationType;
|
||||||
|
address?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { locations, ...restEventType } = rawEventType;
|
||||||
|
const eventType = {
|
||||||
|
...restEventType,
|
||||||
|
locations: locations as unknown as Location[],
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// backwards compat
|
// backwards compat
|
||||||
if (eventType.users.length === 0 && !eventType.team) {
|
if (eventType.users.length === 0 && !eventType.team) {
|
||||||
|
@ -1274,7 +1295,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
const teamMembers = eventTypeObject.team
|
const teamMembers = eventTypeObject.team
|
||||||
? eventTypeObject.team.members.map((member) => {
|
? eventTypeObject.team.members.map((member) => {
|
||||||
const user = member.user;
|
const user = member.user;
|
||||||
user.avatar = user.avatar || defaultAvatarSrc({ email: user.email });
|
user.avatar = user.avatar || defaultAvatarSrc({ email: asStringOrUndefined(user.email) });
|
||||||
return user;
|
return user;
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrUndefined } from "@lib/asStringOrNull";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
export default function Type() {
|
export default function Type() {
|
||||||
|
@ -11,7 +11,7 @@ export default function Type() {
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const booking = await prisma.booking.findUnique({
|
const booking = await prisma.booking.findUnique({
|
||||||
where: {
|
where: {
|
||||||
uid: asStringOrNull(context.query.uid),
|
uid: asStringOrUndefined(context.query.uid),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -39,11 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!booking.eventType) {
|
if (!booking?.eventType) throw Error("This booking doesn't exists");
|
||||||
return {
|
|
||||||
notFound: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventType = booking.eventType;
|
const eventType = booking.eventType;
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { GetServerSidePropsContext } from "next";
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
import SettingsShell from "@components/Settings";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import SettingsShell from "@components/Settings";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
|
|
||||||
export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
|
export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import ImageUploader from "@components/ImageUploader";
|
import ImageUploader from "@components/ImageUploader";
|
||||||
import Modal from "@components/Modal";
|
import Modal from "@components/Modal";
|
||||||
import SettingsShell from "@components/Settings";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
|
|
|
@ -4,7 +4,7 @@ import React from "react";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import SettingsShell from "@components/Settings";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import ChangePasswordSection from "@components/security/ChangePasswordSection";
|
import ChangePasswordSection from "@components/security/ChangePasswordSection";
|
||||||
import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection";
|
import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection";
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Member } from "@lib/member";
|
||||||
import { Team } from "@lib/team";
|
import { Team } from "@lib/team";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import SettingsShell from "@components/Settings";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import EditTeam from "@components/team/EditTeam";
|
import EditTeam from "@components/team/EditTeam";
|
||||||
import TeamList from "@components/team/TeamList";
|
import TeamList from "@components/team/TeamList";
|
||||||
|
|
|
@ -239,6 +239,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
|
inputMode="email"
|
||||||
defaultValue={router.query.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"
|
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"
|
placeholder="rick.astley@cal.com"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
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 Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
@ -7,6 +8,7 @@ import useTheme from "@lib/hooks/useTheme";
|
||||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { defaultAvatarSrc } from "@lib/profile";
|
import { defaultAvatarSrc } from "@lib/profile";
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
|
@ -16,7 +18,7 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
import Text from "@components/ui/Text";
|
import Text from "@components/ui/Text";
|
||||||
|
|
||||||
function TeamPage({ team }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
||||||
const { isReady } = useTheme();
|
const { isReady } = useTheme();
|
||||||
const showMembers = useToggleQuery("members");
|
const showMembers = useToggleQuery("members");
|
||||||
|
|
||||||
|
@ -39,8 +41,8 @@ function TeamPage({ team }: InferGetServerSidePropsType<typeof getServerSideProp
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
size={10}
|
size={10}
|
||||||
items={type.users.map((user) => ({
|
items={type.users.map((user) => ({
|
||||||
alt: user.name,
|
alt: user.name || "",
|
||||||
image: user.avatar,
|
image: user.avatar || "",
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -51,18 +53,16 @@ function TeamPage({ team }: InferGetServerSidePropsType<typeof getServerSideProp
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const teamName = team.name || "Nameless Team";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isReady && (
|
isReady && (
|
||||||
<div>
|
<div>
|
||||||
<HeadSeo title={team.name} description={team.name} />
|
<HeadSeo title={teamName} description={teamName} />
|
||||||
<div className="pt-24 pb-12 px-4">
|
<div className="pt-24 pb-12 px-4">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Avatar
|
<Avatar alt={teamName} imageSrc={team.logo} className="mx-auto w-20 h-20 rounded-full mb-4" />
|
||||||
displayName={team.name}
|
<Text variant="headline">{teamName}</Text>
|
||||||
imageSrc={team.logo}
|
|
||||||
className="mx-auto w-20 h-20 rounded-full mb-4"
|
|
||||||
/>
|
|
||||||
<Text variant="headline">{team.name}</Text>
|
|
||||||
</div>
|
</div>
|
||||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||||
{!showMembers.isOn && team.eventTypes.length && (
|
{!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 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,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
|
@ -108,13 +117,7 @@ export const getServerSideProps = async (context) => {
|
||||||
members: {
|
members: {
|
||||||
select: {
|
select: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: userSelect,
|
||||||
username: true,
|
|
||||||
avatar: true,
|
|
||||||
name: true,
|
|
||||||
id: true,
|
|
||||||
bio: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -129,36 +132,29 @@ export const getServerSideProps = async (context) => {
|
||||||
length: true,
|
length: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
users: {
|
users: {
|
||||||
select: {
|
select: userSelect,
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
avatar: true,
|
|
||||||
email: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: {
|
where: {
|
||||||
slug,
|
slug,
|
||||||
},
|
},
|
||||||
select: teamSelectInput,
|
select: teamSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!team) {
|
if (!team) return { notFound: true };
|
||||||
return {
|
|
||||||
notFound: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
team.eventTypes = team.eventTypes.map((type) => ({
|
team.eventTypes = team.eventTypes.map((type) => ({
|
||||||
...type,
|
...type,
|
||||||
users: type.users.map((user) => ({
|
users: type.users.map((user) => ({
|
||||||
...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 } from "next";
|
||||||
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
|
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
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} />;
|
return <AvailabilityPage {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
// get query params and typecast them to string
|
const locale = await extractLocaleInfo(context.req);
|
||||||
// (would be even better to assert them instead of typecasting)
|
|
||||||
const slugParam = asStringOrNull(context.query.slug);
|
const slugParam = asStringOrNull(context.query.slug);
|
||||||
const typeParam = asStringOrNull(context.query.type);
|
const typeParam = asStringOrNull(context.query.type);
|
||||||
|
const dateParam = asStringOrNull(context.query.date);
|
||||||
|
|
||||||
if (!slugParam || !typeParam) {
|
if (!slugParam || !typeParam) {
|
||||||
throw new Error(`File is not named [idOrSlug]/[user]`);
|
throw new Error(`File is not named [idOrSlug]/[user]`);
|
||||||
|
@ -49,6 +53,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
description: true,
|
description: true,
|
||||||
length: true,
|
length: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
periodStartDate: true,
|
||||||
|
periodEndDate: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -57,23 +63,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
if (!team || team.eventTypes.length != 1) {
|
if (!team || team.eventTypes.length != 1) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
} as const;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = {
|
const [eventType] = team.eventTypes;
|
||||||
name: team.name,
|
|
||||||
slug: team.slug,
|
|
||||||
image: team.logo || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
workingHours.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
const eventTypeObject = Object.assign({}, eventType, {
|
const eventTypeObject = Object.assign({}, eventType, {
|
||||||
|
@ -83,10 +81,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
profile,
|
localeProp: locale,
|
||||||
team,
|
profile: {
|
||||||
|
name: team.name,
|
||||||
|
slug: team.slug,
|
||||||
|
image: team.logo || null,
|
||||||
|
theme: null,
|
||||||
|
},
|
||||||
|
date: dateParam,
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
workingHours,
|
workingHours,
|
||||||
|
...(await serverSideTranslations(locale, ["common"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue