[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:
		
							parent
							
								
									c01004b470
								
							
						
					
					
						commit
						3641d5e46e
					
				
					 9 changed files with 443 additions and 492 deletions
				
			
		|  | @ -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; | ||||
|  | @ -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; | ||||
|  | @ -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; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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\\\"}\"", | ||||
|  |  | |||
|  | @ -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"; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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"]), | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Omar López
						Omar López