From a2620a0e50fb6133172de4fc53a24c773f677d46 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Tue, 7 May 2024 14:51:00 +0200 Subject: [PATCH] feat: atoms team booking (#14525) * feat: setup usePublicEvent with orgSlug and duration * feat: setup useAvailableSlots with eventTypeSlug and orgSlug * fix & feat: fix TS issues and allow passing multiple users to Booker * refactor: dont show [15min] [30min] [60min] [1h30min] picker * fix: pass orgSlug to handleNewBooking -> loadUsers -> findUsersByUsername to not return empty users[] * refactor: display attendees in booking confirmation screen * fix: getting slots get orgSlug from props not event * revert: Booker username use props instead of hardcoded values * refactor: setup BookerStore org and use it for selectedTime + isDynamic logic * refactor: hide 'what is this meeting about' input in final booking step * fix: TS error * revert: what is the meeting about field hiding * revert: make org as org? in booker store * revert: createNewBooking -> loadUsers -> get forced orgSlug from header not body * refactor: useHandleBookEvent get orgSlug from booker store not props * fix: if entity undefined dont access orgSlug * refactor: add isDynamic prop as queryKey for usePublicEvent * refactor: remove unused prop * re-add docs * fix: typescript error * fix: force platform orgSlug in handleNewBooking.ts * fix: remove duplicate setSelectedDuration declaration * refactor: destructure properies from body instead of req.body * fix: entity optional and hide event members --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Morgan Vernay --- .../controllers/bookings.controller.ts | 4 +- .../bookings/inputs/create-booking.input.ts | 4 ++ ...et-public-event-type-query-params.input.ts | 2 +- apps/api/v2/swagger/documentation.json | 4 ++ .../bookings/Booker/components/EventMeta.tsx | 14 +++--- packages/features/bookings/Booker/store.ts | 8 +++- .../components/event-meta/Duration.tsx | 4 +- .../components/event-meta/Members.tsx | 4 +- .../booking-to-mutation-input-mapper.tsx | 3 ++ .../ee/organizations/lib/orgDomains.ts | 13 +++++- .../atoms/booker/BookerPlatformWrapper.tsx | 45 ++++++++++++++++--- .../atoms/hooks/useHandleBookEvent.ts | 3 ++ .../platform/atoms/hooks/usePublicEvent.tsx | 17 ++++++- .../examples/base/src/pages/[bookingUid].tsx | 17 ++++--- .../examples/base/src/pages/booking.tsx | 30 +++++++++++-- .../examples/base/src/pages/bookings.tsx | 4 +- packages/platform/types/slots.ts | 4 ++ packages/prisma/zod-utils.ts | 1 + 18 files changed, 148 insertions(+), 33 deletions(-) diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts index 74fbc355b6..0453964828 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -150,7 +150,9 @@ export class BookingsController { @Headers(X_CAL_CLIENT_ID) clientId?: string ): Promise> { const oAuthClientId = clientId?.toString(); - const locationUrl = body.locationUrl; + + const { orgSlug, locationUrl } = body; + req.headers["x-cal-force-slug"] = orgSlug; try { const booking = await handleNewBooking( await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl) diff --git a/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts index c9dc1da1b6..7101bb80a2 100644 --- a/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts @@ -99,6 +99,10 @@ export class CreateBookingInput { @Type(() => Response) responses!: Response; + @IsString() + @IsOptional() + orgSlug?: string; + @IsString() @IsOptional() locationUrl?: string; diff --git a/apps/api/v2/src/ee/event-types/inputs/get-public-event-type-query-params.input.ts b/apps/api/v2/src/ee/event-types/inputs/get-public-event-type-query-params.input.ts index a94dc9ea26..1e149f3a73 100644 --- a/apps/api/v2/src/ee/event-types/inputs/get-public-event-type-query-params.input.ts +++ b/apps/api/v2/src/ee/event-types/inputs/get-public-event-type-query-params.input.ts @@ -12,5 +12,5 @@ export class GetPublicEventTypeQueryParams { @ApiProperty({ required: false }) @IsString() @IsOptional() - org?: string; + org?: string | null; } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index dabbc49a7d..7b4ff5618a 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -722,6 +722,7 @@ "required": false, "in": "query", "schema": { + "nullable": true, "type": "string" } } @@ -4226,6 +4227,9 @@ "responses": { "$ref": "#/components/schemas/Response" }, + "orgSlug": { + "type": "string" + }, "locationUrl": { "type": "string" } diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index acb100c07e..8806037d46 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -92,12 +92,14 @@ export const EventMeta = ({ )} {!isPending && !!event && ( - + {!isPlatform && ( + + )} {event?.title} {event.description && ( diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index fb650cea55..674fd6b5f6 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -137,11 +137,13 @@ export type BookerStore = { * both the slug and the event slug. */ isTeamEvent: boolean; - org?: string | null; seatedEventData: SeatedEventData; setSeatedEventData: (seatedEventData: SeatedEventData) => void; isInstantMeeting?: boolean; + + org?: string | null; + setOrg: (org: string | null | undefined) => void; }; /** @@ -342,6 +344,10 @@ export const useBookerStore = create((set, get) => ({ setFormValues: (formValues: Record) => { set({ formValues }); }, + org: null, + setOrg: (org: string | null | undefined) => { + set({ org }); + }, })); export const useInitializeBookerStore = ({ diff --git a/packages/features/bookings/components/event-meta/Duration.tsx b/packages/features/bookings/components/event-meta/Duration.tsx index e76a681c76..bc8f51bc61 100644 --- a/packages/features/bookings/components/event-meta/Duration.tsx +++ b/packages/features/bookings/components/event-meta/Duration.tsx @@ -1,6 +1,7 @@ import type { TFunction } from "next-i18next"; import { useEffect } from "react"; +import { useIsPlatform } from "@calcom/atoms/monorepo"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -37,6 +38,7 @@ export const getDurationFormatted = (mins: number | undefined, t: TFunction) => export const EventDuration = ({ event }: { event: PublicEvent }) => { const { t } = useLocale(); + const isPlatform = useIsPlatform(); const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [ state.selectedDuration, state.setSelectedDuration, @@ -52,7 +54,7 @@ export const EventDuration = ({ event }: { event: PublicEvent }) => { setSelectedDuration(event.length); }, [selectedDuration, setSelectedDuration, event.metadata?.multipleDuration, event.length, isDynamicEvent]); - if (!event?.metadata?.multipleDuration && !isDynamicEvent) + if ((!event?.metadata?.multipleDuration && !isDynamicEvent) || isPlatform) return <>{getDurationFormatted(event.length, t)}; const durations = event?.metadata?.multipleDuration || [15, 30, 60, 90]; diff --git a/packages/features/bookings/components/event-meta/Members.tsx b/packages/features/bookings/components/event-meta/Members.tsx index bc0afa53fe..12a0c8f51a 100644 --- a/packages/features/bookings/components/event-meta/Members.tsx +++ b/packages/features/bookings/components/event-meta/Members.tsx @@ -1,4 +1,3 @@ -import { useIsPlatform } from "@calcom/atoms/monorepo"; import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; @@ -19,10 +18,9 @@ export interface EventMembersProps { } export const EventMembers = ({ schedulingType, users, profile, entity }: EventMembersProps) => { - const isPlatform = useIsPlatform(); const isEmbed = useIsEmbed(); const showMembers = !!schedulingType && schedulingType !== SchedulingType.ROUND_ROBIN; - const shownUsers = showMembers && !isPlatform ? users : []; + const shownUsers = showMembers ? users : []; // In some cases we don't show the user's names, but only show the profile name. const showOnlyProfileName = diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index a86e9c4975..4369f71453 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -19,6 +19,7 @@ export type BookingOptions = { bookingUid?: string; seatReferenceUid?: string; hashedLink?: string | null; + orgSlug?: string; }; export const mapBookingToMutationInput = ({ @@ -34,6 +35,7 @@ export const mapBookingToMutationInput = ({ bookingUid, seatReferenceUid, hashedLink, + orgSlug, }: BookingOptions): BookingCreateBody => { return { ...values, @@ -53,6 +55,7 @@ export const mapBookingToMutationInput = ({ bookingUid, seatReferenceUid, hashedLink, + orgSlug, }; }; diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 6a2cbc1050..53b6142cc6 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -53,10 +53,17 @@ export function getOrgSlug(hostname: string, forcedSlug?: string) { } export function orgDomainConfig(req: IncomingMessage | undefined, fallback?: string | string[]) { + const forPlatform = isPlatformRequest(req); const forcedSlugHeader = req?.headers?.["x-cal-force-slug"]; - const forcedSlug = forcedSlugHeader instanceof Array ? forcedSlugHeader[0] : forcedSlugHeader; + if (forPlatform && forcedSlug) { + return { + isValidOrgDomain: true, + currentOrgDomain: forcedSlug, + }; + } + const hostname = req?.headers?.host || ""; return getOrgDomainConfigFromHostname({ hostname, @@ -65,6 +72,10 @@ export function orgDomainConfig(req: IncomingMessage | undefined, fallback?: str }); } +function isPlatformRequest(req: IncomingMessage | undefined) { + return !!req?.headers?.["x-cal-client-id"]; +} + export function getOrgDomainConfigFromHostname({ hostname, fallback, diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 73b2e5a97f..5855de8b9d 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -34,13 +34,15 @@ import { usePublicEvent } from "../hooks/usePublicEvent"; import { useSlots } from "../hooks/useSlots"; import { AtomsWrapper } from "../src/components/atoms-wrapper"; -type BookerPlatformWrapperAtomProps = Omit & { +type BookerPlatformWrapperAtomProps = Omit & { rescheduleUid?: string; bookingUid?: string; firstName?: string; lastName?: string; guests?: string[]; name?: string; + username: string | string[]; + entity?: BookerProps["entity"]; onCreateBookingSuccess?: (data: ApiSuccessResponse) => void; onCreateBookingError?: (data: ApiErrorResponse | Error) => void; onCreateRecurringBookingSuccess?: (data: ApiSuccessResponse) => void; @@ -59,8 +61,8 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => const setSelectedDate = useBookerStore((state) => state.setSelectedDate); const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration); const setBookingData = useBookerStore((state) => state.setBookingData); + const setOrg = useBookerStore((state) => state.setOrg); const bookingData = useBookerStore((state) => state.bookingData); - const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); const setSelectedMonth = useBookerStore((state) => state.setMonth); const { data: booking } = useGetBookingForReschedule({ @@ -70,18 +72,36 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => }, }); + const username = useMemo(() => { + return formatUsername(props.username); + }, [props.username]); + useEffect(() => { // reset booker whenever it's unmounted return () => { setBookerState("loading"); setSelectedDate(null); setSelectedTimeslot(null); + setSelectedDuration(null); + setOrg(null); setSelectedMonth(null); setSelectedDuration(null); }; }, []); - const event = usePublicEvent({ username: props.username, eventSlug: props.eventSlug }); + setSelectedDuration(props.duration ?? null); + setOrg(props.entity?.orgSlug ?? null); + + const isDynamic = useMemo(() => { + return getUsernameList(username ?? "").length > 1; + }, [username]); + + const event = usePublicEvent({ + username, + eventSlug: props.eventSlug, + isDynamic, + }); + const bookerLayout = useBookerLayout(event.data); useInitializeBookerStore({ ...props, @@ -89,7 +109,8 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => rescheduleUid: props.rescheduleUid ?? null, bookingUid: props.bookingUid ?? null, layout: bookerLayout.defaultLayout, - org: event.data?.entity.orgSlug, + org: props.entity?.orgSlug, + username, bookingData, }); const [dayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow); @@ -133,8 +154,9 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => prefetchNextMonth, selectedDate, }); + const schedule = useAvailableSlots({ - usernameList: getUsernameList(props.username ?? ""), + usernameList: getUsernameList(username ?? ""), eventTypeId: event?.data?.id ?? 0, startTime, endTime, @@ -142,11 +164,13 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => duration: selectedDuration ?? undefined, rescheduleUid: props.rescheduleUid, enabled: - Boolean(props.username) && + Boolean(username) && Boolean(month) && Boolean(timezone) && // Should only wait for one or the other, not both. (Boolean(eventSlug) || Boolean(event?.data?.id) || event?.data?.id === 0), + orgSlug: props.entity?.orgSlug ?? undefined, + eventTypeSlug: isDynamic ? "dynamic" : undefined, }); const bookerForm = useBookingForm({ @@ -242,7 +266,7 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => ); }; + +function formatUsername(username: string | string[]): string { + if (typeof username === "string") { + return username; + } + return username.join("+"); +} diff --git a/packages/platform/atoms/hooks/useHandleBookEvent.ts b/packages/platform/atoms/hooks/useHandleBookEvent.ts index 3158972977..c52f7d8313 100644 --- a/packages/platform/atoms/hooks/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/useHandleBookEvent.ts @@ -43,6 +43,8 @@ export const useHandleBookEvent = ({ const bookingData = useBookerStore((state) => state.bookingData); const seatedEventData = useBookerStore((state) => state.seatedEventData); const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); + const orgSlug = useBookerStore((state) => state.org); + const handleBookEvent = () => { const values = bookingForm.getValues(); if (timeslot) { @@ -77,6 +79,7 @@ export const useHandleBookEvent = ({ username: username || "", metadata: metadata, hashedLink, + orgSlug: orgSlug ? orgSlug : undefined, }; if (isInstantMeeting) { diff --git a/packages/platform/atoms/hooks/usePublicEvent.tsx b/packages/platform/atoms/hooks/usePublicEvent.tsx index 85789ac242..6665782b27 100644 --- a/packages/platform/atoms/hooks/usePublicEvent.tsx +++ b/packages/platform/atoms/hooks/usePublicEvent.tsx @@ -11,16 +11,23 @@ import http from "../lib/http"; export const QUERY_KEY = "get-public-event"; export type UsePublicEventReturnType = ReturnType; -export const usePublicEvent = (props: { username: string; eventSlug: string }) => { +type Props = { + username: string; + eventSlug: string; + isDynamic?: boolean; +}; + +export const usePublicEvent = (props: Props) => { const [username, eventSlug] = useBookerStore((state) => [state.username, state.eventSlug], shallow); const isTeamEvent = useBookerStore((state) => state.isTeamEvent); const org = useBookerStore((state) => state.org); + const selectedDuration = useBookerStore((state) => state.selectedDuration); const requestUsername = username ?? props.username; const requestEventSlug = eventSlug ?? props.eventSlug; const event = useQuery({ - queryKey: [QUERY_KEY, username ?? props.username, eventSlug ?? props.eventSlug], + queryKey: [QUERY_KEY, username ?? props.username, eventSlug ?? props.eventSlug, props.isDynamic], queryFn: () => { return http .get>( @@ -34,6 +41,12 @@ export const usePublicEvent = (props: { username: string; eventSlug: string }) = ) .then((res) => { if (res.data.status === SUCCESS_STATUS) { + if (props.isDynamic && selectedDuration && res.data.data) { + // note(Lauris): Mandatory - In case of "dynamic" event type default event duration returned by the API is 30, + // but we are re-using the dynamic event type as a team event, so we must set the event length to whatever the event length is. + res.data.data.length = selectedDuration; + } + return res.data.data; } throw new Error(res.data.error.message); diff --git a/packages/platform/examples/base/src/pages/[bookingUid].tsx b/packages/platform/examples/base/src/pages/[bookingUid].tsx index 72383fbecc..a7f8186793 100644 --- a/packages/platform/examples/base/src/pages/[bookingUid].tsx +++ b/packages/platform/examples/base/src/pages/[bookingUid].tsx @@ -93,12 +93,17 @@ export default function Bookings(props: { calUsername: string; calEmail: string

{booking.user?.email}

-
-
-

{`${booking.responses.name}`}

-

{`${booking.responses.email}`}

-
-
+ {booking.attendees.map((attendee, i) => { + return ( +
+
+
+

{`${attendee.name}`}

+

{`${attendee.email}`}

+
+
+ ); + })} {!!booking.location && booking.location.startsWith("http") && ( diff --git a/packages/platform/examples/base/src/pages/booking.tsx b/packages/platform/examples/base/src/pages/booking.tsx index b6086dd4a3..4668bdcd7f 100644 --- a/packages/platform/examples/base/src/pages/booking.tsx +++ b/packages/platform/examples/base/src/pages/booking.tsx @@ -11,10 +11,12 @@ const inter = Inter({ subsets: ["latin"] }); export default function Bookings(props: { calUsername: string; calEmail: string }) { const [bookingTitle, setBookingTitle] = useState(null); const [eventTypeSlug, setEventTypeSlug] = useState(null); + const [eventTypeDuration, setEventTypeDuration] = useState(null); const router = useRouter(); const { isLoading: isLoadingEvents, data: eventTypes } = useEventTypesPublic(props.calUsername); const rescheduleUid = (router.query.rescheduleUid as string) ?? ""; const eventTypeSlugQueryParam = (router.query.eventTypeSlug as string) ?? ""; + return (
@@ -34,7 +36,10 @@ export default function Bookings(props: { calUsername: string; calEmail: string return (
setEventTypeSlug(event.slug)} + onClick={() => { + setEventTypeSlug(event.slug); + setEventTypeDuration(event.length); + }} className="mx-10 w-[80vw] cursor-pointer rounded-md border-[0.8px] border-black px-10 py-4" key={event.id}>

{formatEventSlug}

@@ -51,9 +56,23 @@ export default function Bookings(props: { calUsername: string; calEmail: string eventSlug={eventTypeSlug} username={props.calUsername ?? ""} onCreateBookingSuccess={(data) => { - setBookingTitle(data.data.title); + setBookingTitle(data.data.title ?? ""); router.push(`/${data.data.uid}`); }} + duration={eventTypeDuration} + customClassNames={{ + bookerContainer: "!bg-[#F5F2FE] [&_button:!rounded-full] border-subtle border", + datePickerCustomClassNames: { + datePickerDatesActive: "!bg-[#D7CEF5]", + }, + eventMetaCustomClassNames: { + eventMetaTitle: "text-[#7151DC]", + }, + availableTimeSlotsCustomClassNames: { + availableTimeSlotsHeaderContainer: "!bg-[#F5F2FE]", + availableTimes: "!bg-[#D7CEF5]", + }, + }} /> )} {!bookingTitle && rescheduleUid && eventTypeSlugQueryParam && ( @@ -62,9 +81,14 @@ export default function Bookings(props: { calUsername: string; calEmail: string eventSlug={eventTypeSlugQueryParam} username={props.calUsername ?? ""} onCreateBookingSuccess={(data) => { - setBookingTitle(data.data.title); + setBookingTitle(data.data.title ?? ""); router.push(`/${data.data.uid}`); }} + duration={eventTypeDuration} + entity={{ + orgSlug: "ecorp", + considerUnpublished: false, + }} /> )} {bookingTitle &&

Booking created: {bookingTitle}

} diff --git a/packages/platform/examples/base/src/pages/bookings.tsx b/packages/platform/examples/base/src/pages/bookings.tsx index 65b4b49da7..44a016e00f 100644 --- a/packages/platform/examples/base/src/pages/bookings.tsx +++ b/packages/platform/examples/base/src/pages/bookings.tsx @@ -30,6 +30,8 @@ export default function Bookings(props: { calUsername: string; calEmail: string

{props.calUsername} Bookings

{isLoading &&

Loading...

} {!isLoading && + pastBookings?.bookings && + upcomingBookings?.bookings && (Boolean(upcomingBookings?.bookings.length) || Boolean(pastBookings?.bookings.length)) && [...pastBookings?.bookings, ...upcomingBookings?.bookings].map((booking) => { const date = dayjs(booking.startTime).toDate(); @@ -62,7 +64,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string

- {booking.user.name} and {booking.attendees[0].name} + {booking?.user?.name} and {booking.attendees[0].name}

{" "}

diff --git a/packages/platform/types/slots.ts b/packages/platform/types/slots.ts index ae720e10bb..490822cb3c 100644 --- a/packages/platform/types/slots.ts +++ b/packages/platform/types/slots.ts @@ -37,6 +37,10 @@ export class GetAvailableSlotsInput { @IsString() @IsOptional() timeZone?: string; + + @IsString() + @IsOptional() + orgSlug?: string; } export class RemoveSelectedSlotInput { diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index c3f89294bf..1942089754 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -228,6 +228,7 @@ export const bookingCreateBodySchema = z.object({ hasHashedBookingLink: z.boolean().optional(), hashedLink: z.string().nullish(), seatReferenceUid: z.string().optional(), + orgSlug: z.string().optional(), }); export const requiredCustomInputSchema = z.union([