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:
Omar López 2021-09-29 15:33:18 -06:00 committed by GitHub
parent 5318047794
commit a04336ba06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 609 additions and 519 deletions

View 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
View 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;

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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]);

View File

@ -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;

View File

@ -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);

View File

@ -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"

View 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;

View File

@ -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) =>

View File

@ -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;
};

View File

@ -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))",

View File

@ -74,6 +74,11 @@ module.exports = () => plugins.reduce((acc, next) => next(acc), {
destination: "/settings/profile",
permanent: true,
},
{
source: "/bookings",
destination: "/bookings/upcoming",
permanent: true,
},
];
},
});

View File

@ -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);

View File

@ -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

View File

@ -100,6 +100,7 @@ export default function Login({ csrfToken }) {
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="email"
required
value={email}

View File

@ -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
View 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">
&quot;{booking.description}&quot;
</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>
);
}

View File

@ -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;

View File

@ -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;
})
: [];

View File

@ -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;

View File

@ -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";

View File

@ -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>) {

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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"

View File

@ -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 || "" }),
})),
}));

View File

@ -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"])),
},
};
};