[CAL-493] Implements tRCP on event types (#923)

* Removes unused component

* Refactors useLocale

We don't need to pass the locale prop everywhere

* Event type fixes

* Extracts CreateNewEventDialog

* Implements tRCP for event types
This commit is contained in:
Omar López 2021-10-15 13:07:00 -06:00 committed by GitHub
parent c01004b470
commit 3641d5e46e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 443 additions and 492 deletions

View File

@ -1,185 +0,0 @@
// TODO: replace headlessui with radix-ui
import { Menu, Transition } from "@headlessui/react";
import { DotsHorizontalIcon, ExternalLinkIcon, LinkIcon } from "@heroicons/react/solid";
import Link from "next/link";
import React, { Fragment } from "react";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { Tooltip } from "@components/Tooltip";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import AvatarGroup from "@components/ui/AvatarGroup";
interface Props {
profile: { slug: string };
readOnly: boolean;
types: {
$disabled: boolean;
hidden: boolean;
id: number;
slug: string;
title: string;
users: {
name: string;
avatar: string;
}[];
};
}
const EventTypeList = ({ readOnly, types, profile }: Props): JSX.Element => {
const { t } = useLocale();
return (
<div className="mb-16 -mx-4 overflow-hidden bg-white border border-gray-200 rounded-sm sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="event-types">
{types.map((type) => (
<li
key={type.id}
className={classNames(
type.$disabled && "opacity-30 cursor-not-allowed pointer-events-none select-none"
)}
data-disabled={type.$disabled ? 1 : 0}>
<div
className={classNames(
"hover:bg-neutral-50 flex justify-between items-center ",
type.$disabled && "pointer-events-none"
)}>
<div className="flex items-center justify-between w-full px-4 py-4 sm:px-6 hover:bg-neutral-50">
<Link href={"/event-types/" + type.id}>
<a
className="flex-grow text-sm truncate"
title={`${type.title} ${type.description ? ` ${type.description}` : ""}`}>
<div>
<span className="font-medium truncate text-neutral-900">{type.title}</span>
{type.hidden && (
<span className="ml-2 inline items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
{t("hidden")}
</span>
)}
{readOnly && (
<span className="ml-2 inline items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-gray-100 text-gray-800">
{t("readonly")}
</span>
)}
</div>
<EventTypeDescription eventType={type} />
</a>
</Link>
<div className="flex-shrink-0 hidden mt-4 sm:flex sm:mt-0 sm:ml-5">
<div className="flex items-center space-x-2 overflow-hidden">
{type.users?.length > 1 && (
<AvatarGroup
size={8}
truncateAfter={4}
items={type.users.map((organizer) => ({
alt: organizer.name || "",
image: organizer.avatar || "",
}))}
/>
)}
<Tooltip content={t("preview")}>
<a
href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className="btn-icon">
<ExternalLinkIcon className="w-5 h-5 group-hover:text-black" />
</a>
</Tooltip>
<Tooltip content={t("copy_link")}>
<button
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`
);
}}
className="btn-icon">
<LinkIcon className="w-5 h-5 group-hover:text-black" />
</button>
</Tooltip>
</div>
</div>
</div>
<div className="flex flex-shrink-0 mr-5 sm:hidden">
<Menu as="div" className="inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="p-2 mt-1 border border-transparent text-neutral-400 hover:border-gray-200">
<span className="sr-only">{t("open_options")}</span>
<DotsHorizontalIcon className="w-5 h-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="absolute right-0 z-10 w-56 mt-2 origin-top-right bg-white divide-y rounded-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none divide-neutral-100">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<ExternalLinkIcon
className="w-4 h-4 mr-3 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
{t("preview")}
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => {
showToast("Link copied!", "success");
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`
);
}}
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"
)}>
<LinkIcon
className="w-4 h-4 mr-3 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
{t("copy_link")}
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div>
</div>
</li>
))}
</ul>
</div>
);
};
export default EventTypeList;

View File

@ -1,58 +0,0 @@
// TODO: replace headlessui with radix-ui
import { UsersIcon } from "@heroicons/react/solid";
import Link from "next/link";
import React from "react";
import Avatar from "@components/ui/Avatar";
import Badge from "@components/ui/Badge";
interface Props {
profile: {
slug?: string | null;
name?: string | null;
image?: string | null;
};
membershipCount: number;
}
const EventTypeListHeading = ({ profile, membershipCount }: Props): JSX.Element => (
<div className="flex mb-4">
<Link href="/settings/teams">
<a>
<Avatar
alt={profile?.name || ""}
imageSrc={profile?.image || undefined}
size={8}
className="inline mt-1 mr-2"
/>
</a>
</Link>
<div>
<Link href="/settings/teams">
<a className="font-bold">{profile?.name || ""}</a>
</Link>
{membershipCount && (
<span className="relative ml-2 text-xs text-neutral-500 -top-px">
<Link href="/settings/teams">
<a>
<Badge variant="gray">
<UsersIcon className="inline w-3 h-3 mr-1 -mt-px" />
{membershipCount}
</Badge>
</a>
</Link>
</span>
)}
{profile?.slug && (
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}`}>
<a className="block text-xs text-neutral-500">{`${process.env.NEXT_PUBLIC_APP_URL?.replace(
"https://",
""
)}/${profile.slug}`}</a>
</Link>
)}
</div>
</div>
);
export default EventTypeListHeading;

View File

@ -1,10 +1,11 @@
import { EventType } from "@prisma/client";
import * as fetch from "@lib/core/http/fetch-wrapper";
import { CreateEventType } from "@lib/types/event-type";
import { CreateEventType, CreateEventTypeResponse } from "@lib/types/event-type";
const createEventType = async (data: CreateEventType) => {
const response = await fetch.post<CreateEventType, EventType>("/api/availability/eventtype", data);
const response = await fetch.post<CreateEventType, CreateEventTypeResponse>(
"/api/availability/eventtype",
data
);
return response;
};

View File

@ -1,4 +1,4 @@
import { SchedulingType } from "@prisma/client";
import { SchedulingType, EventType } from "@prisma/client";
export type OpeningHours = {
days: number[];
@ -49,9 +49,14 @@ export type CreateEventType = {
slug: string;
description: string;
length: number;
teamId?: number;
schedulingType?: SchedulingType;
};
export type CreateEventTypeResponse = {
eventType: EventType;
};
export type EventTypeInput = AdvancedOptions & {
id: number;
title: string;

View File

@ -15,6 +15,7 @@
"test": "jest",
"test-playwright": "jest --config jest.playwright.config.js",
"test-playwright-lcov": "cross-env PLAYWRIGHT_HEADLESS=1 PLAYWRIGHT_COVERAGE=1 yarn test-playwright && nyc report --reporter=lcov",
"type-check": "tsc --pretty --noEmit",
"build": "next build",
"start": "next start",
"ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\"",

View File

@ -50,7 +50,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
import Shell from "@components/Shell";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
import Button from "@components/ui/Button";
import { Scheduler } from "@components/ui/Scheduler";
import Switch from "@components/ui/Switch";

View File

@ -1,31 +1,33 @@
// TODO: replace headlessui with radix-ui
import { Menu, Transition } from "@headlessui/react";
import { UsersIcon } from "@heroicons/react/solid";
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid";
import { Prisma, SchedulingType } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { DotsHorizontalIcon, ExternalLinkIcon, LinkIcon } from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, useRef } from "react";
import { useMutation } from "react-query";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { QueryCell } from "@lib/QueryCell";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import { ONBOARDING_NEXT_REDIRECT, shouldShowOnboarding } from "@lib/getting-started";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import createEventType from "@lib/mutations/event-types/create-event-type";
import showToast from "@lib/notification";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { CreateEventType } from "@lib/types/event-type";
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
import Shell from "@components/Shell";
import EventTypeList from "@components/eventtype/EventTypeList";
import EventTypeListHeading from "@components/eventtype/EventTypeListHeading";
import { Tooltip } from "@components/Tooltip";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import { Alert } from "@components/ui/Alert";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import Badge from "@components/ui/Badge";
import { Button } from "@components/ui/Button";
import Dropdown, {
DropdownMenuContent,
@ -37,22 +39,236 @@ import Dropdown, {
import * as RadioArea from "@components/ui/form/radio-area";
import UserCalendarIllustration from "@components/ui/svg/UserCalendarIllustration";
type PageProps = inferSSRProps<typeof getServerSideProps>;
type Profile = PageProps["profiles"][number];
type Profiles = inferQueryOutput<"viewer.eventTypes">["profiles"];
const EventTypesPage = (props: PageProps) => {
interface CreateEventTypeProps {
canAddEvents: boolean;
profiles: Profiles;
}
const CreateFirstEventTypeView = ({ canAddEvents, profiles }: CreateEventTypeProps) => {
const { t } = useLocale();
const CreateFirstEventTypeView = () => (
return (
<div className="md:py-20">
<UserCalendarIllustration />
<div className="block mx-auto text-center md:max-w-screen-sm">
<h3 className="mt-2 text-xl font-bold text-neutral-900">{t("new_event_type_heading")}</h3>
<p className="mt-1 mb-2 text-md text-neutral-600">{t("new_event_type_description")}</p>
<CreateNewEventDialog canAddEvents={props.canAddEvents} profiles={props.profiles} />
<CreateNewEventButton canAddEvents={canAddEvents} profiles={profiles} />
</div>
</div>
);
};
type EventTypeGroup = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"][number];
type EventType = EventTypeGroup["eventTypes"][number];
interface EventTypeListProps {
profile: { slug: string | null };
readOnly: boolean;
types: EventType[];
}
const EventTypeList = ({ readOnly, types, profile }: EventTypeListProps): JSX.Element => {
const { t } = useLocale();
return (
<div className="mb-16 -mx-4 overflow-hidden bg-white border border-gray-200 rounded-sm sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="event-types">
{types.map((type) => (
<li
key={type.id}
className={classNames(
type.$disabled && "opacity-30 cursor-not-allowed pointer-events-none select-none"
)}
data-disabled={type.$disabled ? 1 : 0}>
<div
className={classNames(
"hover:bg-neutral-50 flex justify-between items-center ",
type.$disabled && "pointer-events-none"
)}>
<div className="flex items-center justify-between w-full px-4 py-4 sm:px-6 hover:bg-neutral-50">
<Link href={"/event-types/" + type.id}>
<a
className="flex-grow text-sm truncate"
title={`${type.title} ${type.description ? ` ${type.description}` : ""}`}>
<div>
<span className="font-medium truncate text-neutral-900">{type.title}</span>
{type.hidden && (
<span className="ml-2 inline items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
{t("hidden")}
</span>
)}
{readOnly && (
<span className="ml-2 inline items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-gray-100 text-gray-800">
{t("readonly")}
</span>
)}
</div>
<EventTypeDescription eventType={type} />
</a>
</Link>
<div className="flex-shrink-0 hidden mt-4 sm:flex sm:mt-0 sm:ml-5">
<div className="flex items-center space-x-2 overflow-hidden">
{type.users?.length > 1 && (
<AvatarGroup
size={8}
truncateAfter={4}
items={type.users.map((organizer) => ({
alt: organizer.name || "",
image: organizer.avatar || "",
}))}
/>
)}
<Tooltip content={t("preview")}>
<a
href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className="btn-icon">
<ExternalLinkIcon className="w-5 h-5 group-hover:text-black" />
</a>
</Tooltip>
<Tooltip content={t("copy_link")}>
<button
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`
);
}}
className="btn-icon">
<LinkIcon className="w-5 h-5 group-hover:text-black" />
</button>
</Tooltip>
</div>
</div>
</div>
<div className="flex flex-shrink-0 mr-5 sm:hidden">
<Menu as="div" className="inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="p-2 mt-1 border border-transparent text-neutral-400 hover:border-gray-200">
<span className="sr-only">{t("open_options")}</span>
<DotsHorizontalIcon className="w-5 h-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="absolute right-0 z-10 w-56 mt-2 origin-top-right bg-white divide-y rounded-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none divide-neutral-100">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<ExternalLinkIcon
className="w-4 h-4 mr-3 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
{t("preview")}
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => {
showToast("Link copied!", "success");
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`
);
}}
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"
)}>
<LinkIcon
className="w-4 h-4 mr-3 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
{t("copy_link")}
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div>
</div>
</li>
))}
</ul>
</div>
);
};
interface EventTypeListHeadingProps {
profile: Profile;
membershipCount: number;
}
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => (
<div className="flex mb-4">
<Link href="/settings/teams">
<a>
<Avatar
alt={profile?.name || ""}
imageSrc={profile?.image || undefined}
size={8}
className="inline mt-1 mr-2"
/>
</a>
</Link>
<div>
<Link href="/settings/teams">
<a className="font-bold">{profile?.name || ""}</a>
</Link>
{membershipCount && (
<span className="relative ml-2 text-xs text-neutral-500 -top-px">
<Link href="/settings/teams">
<a>
<Badge variant="gray">
<UsersIcon className="inline w-3 h-3 mr-1 -mt-px" />
{membershipCount}
</Badge>
</a>
</Link>
</span>
)}
{profile?.slug && (
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}`}>
<a className="block text-xs text-neutral-500">{`${process.env.NEXT_PUBLIC_APP_URL?.replace(
"https://",
""
)}/${profile.slug}`}</a>
</Link>
)}
</div>
</div>
);
const EventTypesPage = () => {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.eventTypes"]);
return (
<div>
@ -64,50 +280,60 @@ const EventTypesPage = (props: PageProps) => {
heading={t("event_types_page_title")}
subtitle={t("event_types_page_subtitle")}
CTA={
props.eventTypes.length !== 0 && (
<CreateNewEventDialog canAddEvents={props.canAddEvents} profiles={props.profiles} />
query.data &&
query.data.eventTypeGroups.length !== 0 && (
<CreateNewEventButton canAddEvents={query.data.canAddEvents} profiles={query.data.profiles} />
)
}>
{props.user.plan === "FREE" && !props.canAddEvents && (
<Alert
severity="warning"
title={<>{t("plan_upgrade")}</>}
message={
<>
{t("to_upgrade_go_to")}{" "}
<a href={"https://cal.com/upgrade"} className="underline">
{"https://cal.com/upgrade"}
</a>
</>
}
className="my-4"
/>
)}
{props.eventTypes &&
props.eventTypes.map((input) => (
<Fragment key={input.profile?.slug}>
{/* hide list heading when there is only one (current user) */}
{(props.eventTypes.length !== 1 || input.teamId) && (
<EventTypeListHeading
profile={input.profile}
membershipCount={input.metadata?.membershipCount}
<QueryCell
query={query}
success={({ data }) => (
<>
{data.user.plan === "FREE" && !data.canAddEvents && (
<Alert
severity="warning"
title={<>{t("plan_upgrade")}</>}
message={
<>
{t("to_upgrade_go_to")}{" "}
<a href={"https://cal.com/upgrade"} className="underline">
{"https://cal.com/upgrade"}
</a>
</>
}
className="my-4"
/>
)}
<EventTypeList
types={input.eventTypes}
profile={input.profile}
readOnly={input.metadata?.readOnly}
/>
</Fragment>
))}
{data.eventTypeGroups &&
data.eventTypeGroups.map((input) => (
<Fragment key={input.profile.slug}>
{/* hide list heading when there is only one (current user) */}
{(data.eventTypeGroups.length !== 1 || input.teamId) && (
<EventTypeListHeading
profile={input.profile}
membershipCount={input.metadata.membershipCount}
/>
)}
<EventTypeList
types={input.eventTypes}
profile={input.profile}
readOnly={input.metadata.readOnly}
/>
</Fragment>
))}
{props.eventTypes.length === 0 && <CreateFirstEventTypeView />}
{data.eventTypeGroups.length === 0 && (
<CreateFirstEventTypeView profiles={data.profiles} canAddEvents={data.canAddEvents} />
)}
</>
)}
/>
</Shell>
</div>
);
};
const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[]; canAddEvents: boolean }) => {
const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps) => {
const router = useRouter();
const teamId: number | null = Number(router.query.teamId) || null;
const modalOpen = useToggleQuery("new");
@ -173,12 +399,7 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[];
},
})
}>
<Avatar
displayName={profile.name}
imageSrc={profile.image}
size={6}
className="inline mr-2"
/>
<Avatar alt={profile.name || ""} imageSrc={profile.image} size={6} className="inline mr-2" />
{profile.name ? profile.name : profile.slug}
</DropdownMenuItem>
))}
@ -203,7 +424,7 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[];
{ value: string }
>;
const payload = {
const payload: CreateEventType = {
title: target.title.value,
slug: target.slug.value,
description: target.description.value,
@ -211,8 +432,8 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[];
};
if (router.query.teamId) {
payload.teamId = parseInt(asStringOrNull(router.query.teamId), 10);
payload.schedulingType = target.schedulingType.value;
payload.teamId = parseInt(`${router.query.teamId}`, 10);
payload.schedulingType = target.schedulingType.value as SchedulingType;
}
createMutation.mutate(payload);
@ -325,188 +546,4 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[];
);
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getSession(context);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
/**
* This makes the select reusable and type safe.
* @url https://www.prisma.io/docs/concepts/components/prisma-client/advanced-type-safety/prisma-validator#using-the-prismavalidator
* */
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
description: true,
length: true,
schedulingType: true,
slug: true,
hidden: true,
price: true,
currency: true,
users: {
select: {
id: true,
avatar: true,
name: true,
},
},
});
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
id: true,
username: true,
name: true,
startTime: true,
endTime: true,
bufferTime: true,
avatar: true,
completedOnboarding: true,
createdDate: true,
plan: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
logo: true,
members: {
select: {
userId: true,
},
},
eventTypes: {
select: eventTypeSelect,
},
},
},
},
},
eventTypes: {
where: {
team: null,
},
select: eventTypeSelect,
},
},
});
if (!user) {
// this shouldn't happen
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
if (
shouldShowOnboarding({ completedOnboarding: user.completedOnboarding, createdDate: user.createdDate })
) {
return ONBOARDING_NEXT_REDIRECT;
}
// backwards compatibility, TMP:
const typesRaw = await prisma.eventType.findMany({
where: {
userId: session.user.id,
},
select: eventTypeSelect,
});
type EventTypeGroup = {
teamId?: number | null;
profile?: {
slug: typeof user["username"];
name: typeof user["name"];
image: typeof user["avatar"];
};
metadata: {
membershipCount: number;
readOnly: boolean;
};
eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[];
};
let eventTypeGroups: EventTypeGroup[] = [];
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const oldItem = hashMap[newItem.id] || {};
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({
...et,
$disabled: user.plan === "FREE" && index > 0,
}));
eventTypeGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image: user.avatar,
},
eventTypes: mergedEventTypes,
metadata: {
membershipCount: 1,
readOnly: false,
},
});
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
user.teams.map((membership) => ({
teamId: membership.team.id,
profile: {
name: membership.team.name,
image: membership.team.logo || "",
slug: "team/" + membership.team.slug,
},
metadata: {
membershipCount: membership.team.members.length,
readOnly: membership.role !== "OWNER",
},
eventTypes: membership.team.eventTypes,
}))
);
const userObj = Object.assign({}, user, {
createdDate: user.createdDate.toString(),
});
const canAddEvents = user.plan !== "FREE" || eventTypeGroups[0].eventTypes.length < 1;
return {
props: {
session,
localeProp: locale,
canAddEvents,
user: userObj,
// don't display event teams without event types,
eventTypes: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length),
// so we can show a dropdown when the user has teams
profiles: eventTypeGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
...(await serverSideTranslations(locale, ["common"])),
},
};
}
export default EventTypesPage;

View File

@ -71,6 +71,156 @@ const loggedInViewerRouter = createProtectedRouter()
return me;
},
})
.query("eventTypes", {
async resolve({ ctx }) {
const { prisma } = ctx;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
description: true,
length: true,
schedulingType: true,
slug: true,
hidden: true,
price: true,
currency: true,
users: {
select: {
id: true,
avatar: true,
name: true,
},
},
});
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
id: true,
username: true,
name: true,
startTime: true,
endTime: true,
bufferTime: true,
avatar: true,
plan: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
logo: true,
members: {
select: {
userId: true,
},
},
eventTypes: {
select: eventTypeSelect,
},
},
},
},
},
eventTypes: {
where: {
team: null,
},
select: eventTypeSelect,
},
},
});
if (!user) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
// backwards compatibility, TMP:
const typesRaw = await prisma.eventType.findMany({
where: {
userId: ctx.user.id,
},
select: eventTypeSelect,
});
type EventTypeGroup = {
teamId?: number | null;
profile: {
slug: typeof user["username"];
name: typeof user["name"];
image: typeof user["avatar"];
};
metadata: {
membershipCount: number;
readOnly: boolean;
};
eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[];
};
let eventTypeGroups: EventTypeGroup[] = [];
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const oldItem = hashMap[newItem.id] || {};
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({
...et,
$disabled: user.plan === "FREE" && index > 0,
}));
eventTypeGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image: user.avatar,
},
eventTypes: mergedEventTypes,
metadata: {
membershipCount: 1,
readOnly: false,
},
});
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
user.teams.map((membership) => ({
teamId: membership.team.id,
profile: {
name: membership.team.name,
image: membership.team.logo || "",
slug: "team/" + membership.team.slug,
},
metadata: {
membershipCount: membership.team.members.length,
readOnly: membership.role !== "OWNER",
},
eventTypes: membership.team.eventTypes,
}))
);
const canAddEvents = user.plan !== "FREE" || eventTypeGroups[0].eventTypes.length < 1;
return {
canAddEvents,
user,
// don't display event teams without event types,
eventTypeGroups: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length),
// so we can show a dropdown when the user has teams
profiles: eventTypeGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
},
})
.query("bookings", {
input: z.object({
status: z.enum(["upcoming", "past", "cancelled"]),