Skip to content

Commit

Permalink
feat: atoms team booking (calcom#14525)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: Morgan Vernay <[email protected]>
  • Loading branch information
3 people authored and p6l-richard committed Jul 22, 2024
1 parent 8fc8181 commit 25274a3
Show file tree
Hide file tree
Showing 18 changed files with 148 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export class CreateBookingInput {
@Type(() => Response)
responses!: Response;

@IsString()
@IsOptional()
orgSlug?: string;

@IsString()
@IsOptional()
locationUrl?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ export class GetPublicEventTypeQueryParams {
@ApiProperty({ required: false })
@IsString()
@IsOptional()
org?: string;
org?: string | null;
}
4 changes: 4 additions & 0 deletions apps/api/v2/swagger/documentation.json
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,7 @@
"required": false,
"in": "query",
"schema": {
"nullable": true,
"type": "string"
}
}
Expand Down Expand Up @@ -4226,6 +4227,9 @@
"responses": {
"$ref": "#/components/schemas/Response"
},
"orgSlug": {
"type": "string"
},
"locationUrl": {
"type": "string"
}
Expand Down
14 changes: 8 additions & 6 deletions packages/features/bookings/Booker/components/EventMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
8 changes: 7 additions & 1 deletion packages/features/bookings/Booker/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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 = ({
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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];
Expand Down
4 changes: 1 addition & 3 deletions packages/features/bookings/components/event-meta/Members.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type BookingOptions = {
bookingUid?: string;
seatReferenceUid?: string;
hashedLink?: string | null;
orgSlug?: string;
};

export const mapBookingToMutationInput = ({
Expand All @@ -34,6 +35,7 @@ export const mapBookingToMutationInput = ({
bookingUid,
seatReferenceUid,
hashedLink,
orgSlug,
}: BookingOptions): BookingCreateBody => {
return {
...values,
Expand All @@ -53,6 +55,7 @@ export const mapBookingToMutationInput = ({
bookingUid,
seatReferenceUid,
hashedLink,
orgSlug,
};
};

Expand Down
13 changes: 12 additions & 1 deletion packages/features/ee/organizations/lib/orgDomains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
45 changes: 38 additions & 7 deletions packages/platform/atoms/booker/BookerPlatformWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand All @@ -70,26 +72,45 @@ 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,
eventId: event.data?.id,
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);
Expand Down Expand Up @@ -133,20 +154,23 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) =>
prefetchNextMonth,
selectedDate,
});

const schedule = useAvailableSlots({
usernameList: getUsernameList(props.username ?? ""),
usernameList: getUsernameList(username ?? ""),
eventTypeId: event?.data?.id ?? 0,
startTime,
endTime,
timeZone: session?.data?.timeZone,
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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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("+");
}
3 changes: 3 additions & 0 deletions packages/platform/atoms/hooks/useHandleBookEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -77,6 +79,7 @@ export const useHandleBookEvent = ({
username: username || "",
metadata: metadata,
hashedLink,
orgSlug: orgSlug ? orgSlug : undefined,
};

if (isInstantMeeting) {
Expand Down
17 changes: 15 additions & 2 deletions packages/platform/atoms/hooks/usePublicEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>>(
Expand All @@ -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);
Expand Down
17 changes: 11 additions & 6 deletions packages/platform/examples/base/src/pages/[bookingUid].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") && (
Expand Down
Loading

0 comments on commit 25274a3

Please sign in to comment.