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 <morgan@cal.com>
This commit is contained in:
authored and GitHub committed 2024-05-07 12:51:00 +00:00
1 parent cfcf5b168c
commit a2620a0e50
18 files changed
+148 -33

No files matched your search

@@ -150,7 +150,9 @@ export class BookingsController {
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<unknown>> {
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)
@@ -99,6 +99,10 @@ export class CreateBookingInput {
@Type(() => Response)
responses!: Response;
@IsString()
@IsOptional()
orgSlug?: string;
@IsString()
@IsOptional()
locationUrl?: string;
@@ -12,5 +12,5 @@ export class GetPublicEventTypeQueryParams {
@ApiProperty({ required: false })
@IsString()
@IsOptional()
org?: string;
org?: string | null;
}
+4
View File
@@ -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"
}
@@ -92,12 +92,14 @@ export const EventMeta = ({
)}
{!isPending && !!event && (
<m.div {...fadeInUp} layout transition={{ ...fadeInUp.transition, delay: 0.3 }}>
<EventMembers
schedulingType={event.schedulingType}
users={event.users}
profile={event.profile}
entity={event.entity}
/>
{!isPlatform && (
<EventMembers
schedulingType={event.schedulingType}
users={event.users}
profile={event.profile}
entity={event.entity}
/>
)}
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>{event?.title}</EventTitle>
{event.description && (
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
+7 -1
View File
@@ -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<BookerStore>((set, get) => ({
setFormValues: (formValues: Record<string, any>) => {
set({ formValues });
},
org: null,
setOrg: (org: string | null | undefined) => {
set({ org });
},
}));
export const useInitializeBookerStore = ({
@@ -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];
@@ -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 =
@@ -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,
};
};
@@ -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,
@@ -34,13 +34,15 @@ import { usePublicEvent } from "../hooks/usePublicEvent";
import { useSlots } from "../hooks/useSlots";
import { AtomsWrapper } from "../src/components/atoms-wrapper";
type BookerPlatformWrapperAtomProps = Omit<BookerProps, "entity"> & {
type BookerPlatformWrapperAtomProps = Omit<BookerProps, "username" | "entity"> & {
rescheduleUid?: string;
bookingUid?: string;
firstName?: string;
lastName?: string;
guests?: string[];
name?: string;
username: string | string[];
entity?: BookerProps["entity"];
onCreateBookingSuccess?: (data: ApiSuccessResponse<BookingResponse>) => void;
onCreateBookingError?: (data: ApiErrorResponse | Error) => void;
onCreateRecurringBookingSuccess?: (data: ApiSuccessResponse<BookingResponse[]>) => 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) =>
<BookerComponent
customClassNames={props.customClassNames}
eventSlug={props.eventSlug}
username={props.username}
username={username}
entity={
event?.data?.entity ?? {
considerUnpublished: false,
@@ -324,3 +348,10 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) =>
</AtomsWrapper>
);
};
function formatUsername(username: string | string[]): string {
if (typeof username === "string") {
return username;
}
return username.join("+");
}
@@ -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) {
@@ -11,16 +11,23 @@ import http from "../lib/http";
export const QUERY_KEY = "get-public-event";
export type UsePublicEventReturnType = ReturnType<typeof usePublicEvent>;
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<ApiResponse<PublicEventType>>(
@@ -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);
@@ -93,12 +93,17 @@ export default function Bookings(props: { calUsername: string; calEmail: string
<p>{booking.user?.email}</p>
</div>
</div>
<div>
<div>
<h4>{`${booking.responses.name}`}</h4>
<p>{`${booking.responses.email}`}</p>
</div>
</div>
{booking.attendees.map((attendee, i) => {
return (
<div key={`${i}-${attendee.name}`}>
<br />
<div>
<h4>{`${attendee.name}`}</h4>
<p>{`${attendee.email}`}</p>
</div>
</div>
);
})}
</div>
</div>
{!!booking.location && booking.location.startsWith("http") && (
@@ -11,10 +11,12 @@ const inter = Inter({ subsets: ["latin"] });
export default function Bookings(props: { calUsername: string; calEmail: string }) {
const [bookingTitle, setBookingTitle] = useState<string | null>(null);
const [eventTypeSlug, setEventTypeSlug] = useState<string | null>(null);
const [eventTypeDuration, setEventTypeDuration] = useState<number | null>(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 (
<main
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
@@ -34,7 +36,10 @@ export default function Bookings(props: { calUsername: string; calEmail: string
return (
<div
onClick={() => 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}>
<h1 className="text-lg font-semibold">{formatEventSlug}</h1>
@@ -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 && <p>Booking created: {bookingTitle}</p>}
@@ -30,6 +30,8 @@ export default function Bookings(props: { calUsername: string; calEmail: string
<h1 className="my-4 text-2xl font-semibold">{props.calUsername} Bookings</h1>
{isLoading && <p>Loading...</p>}
{!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
</div>
<div className="px-6">
<p>
{booking.user.name} and {booking.attendees[0].name}
{booking?.user?.name} and {booking.attendees[0].name}
</p>{" "}
<p />
</div>
+4
View File
@@ -37,6 +37,10 @@ export class GetAvailableSlotsInput {
@IsString()
@IsOptional()
timeZone?: string;
@IsString()
@IsOptional()
orgSlug?: string;
}
export class RemoveSelectedSlotInput {
+1
View File
@@ -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([