feat(bookings): improve bookings redesign (#25251)

* use booking.uid instead of booking.id for url param

* show timezone on calendar

* fix type

* restore horizontal tab and remove header and subtitle

* clean up sidebar items

* fix event propagation from attendees

* fetch all statuses except for cancelled on calendar view

* clean up styles of the badges on BookingListItem

* fix useMediaQuery

* add close button to the header

* add assignment reason to the details sheet

* use separator row

* use ToggleGroup for the top bookings tab

* move ViewToggleButton

* resize the action button

* remove wrong prop

* fix type error

* fix type error

* hide view toggle button on mobile (and fix the breakpoint)

* remove unused e2e tests

* fix e2e tests

* hide toggle button when feature flag is off

* update skeleton

* improve attendees on booking list item and slide over

* improve attendee dropdown

* fix type error

* move query to containers

* select attendee email

* infinite fetching for calendar view

* update styles

* fix compatibility

* fix: add backward compatibility for status field in getAllUserBookings

* increase calendar height

* fix type error

* support Member filter only for admin / owners

* add debug log (TEMP)

* add event border color

* show Reject / Accept buttons on BookingDetailsSheet

* move description section to the top

* update When section

* update style of Who section

* add CancelBookingDialog WIP

* fix CancelBookingDialog

* increase clickable area

* add schedule info section WIP

* fix flaky reject button

* fixing reschedule info WIP

* add fromReschedule index to Booking

* improve rescheduled information

* improve reassignment

* fix type error

* fix unit test

* respect user's weekStart value on the booking calendar view

* update debug log

* improve payment section

* clean up

* fix log message

* reposition filters on list view

* fix bookings controller api2 e2e test

* clean up file by extracting logic into custom hooks

* rename files

* merge BookingCalendar into its container

* extract logic into separate hook files

* remove redundant logic

* rearrange items on calendar view

* add WeekPicker

* extract filter button

* responsive header on list view

* horizontal scroll for ToggleGroup WIP

* fix type error

* fix cancelling recurring event

* address feedback

* fix e2e tests

* fix unit test

* fix e2e tests

* make hover style more visible for ToggleGroup

* fix margin on CancelBookingDialog

* update styles on the slide over (mostly font weight)

* update style of CancelBookingDialog

* update styles

* update margin top for the header

* refactor getBookingDetails handler

* fix gap in who section

* auto-filter the current user on the calendar view

* calculate calendar height considering top banners

* improve booking details sheet interaction without overlay

* update calendar event styles

* update reject dialog style

* put uid first in the query params

* fix class name

* memoize functions in useMediaQuery

* query attendee with id instead of email

* update margins

* replace TRPCError with ErrorWithCode

* move calculation outside loop

* remove dead code
This commit is contained in:
Eunjae Lee
2025-12-10 13:40:04 +00:00
committed by GitHub
parent 41b71d845c
commit 3986d61f40
90 changed files with 3741 additions and 2079 deletions
@@ -1,6 +1,6 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
@@ -16,8 +16,6 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-view";
import { ViewToggleButton } from "./ViewToggleButton";
const querySchema = z.object({
status: z.enum(validStatuses),
});
@@ -36,7 +34,6 @@ const Page = async ({ params }: PageProps) => {
if (!parsed.success) {
redirect("/bookings/upcoming");
}
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
let canReadOthersBookings = false;
@@ -64,10 +61,7 @@ const Page = async ({ params }: PageProps) => {
: false;
return (
<ShellMainAppDir
heading={t("bookings")}
subtitle={t("bookings_description")}
CTA={bookingsV3Enabled ? <ViewToggleButton /> : null}>
<ShellMainAppDir>
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
@@ -6,12 +6,13 @@ import { Button } from "@calcom/ui/components/button";
import { ConfirmDialog } from "./confirmDialog";
interface IWipeMyCalActionButtonProps {
className?: string;
bookingsEmpty: boolean;
bookingStatus: "upcoming" | "recurring" | "past" | "cancelled" | "unconfirmed";
}
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
const { bookingsEmpty, bookingStatus } = props;
const { className, bookingsEmpty, bookingStatus } = props;
const [openDialog, setOpenDialog] = useState(false);
const { isSuccess, isPending, data } = trpc.viewer.apps.integrations.useQuery({
variant: "other",
@@ -28,7 +29,7 @@ const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
return (
<>
{data && isSuccess && !isPending && credentialId && (
<div className="mb-4">
<div className={className}>
<ConfirmDialog isOpenDialog={openDialog} setIsOpenDialog={setOpenDialog} />
<Button color="primary" onClick={() => setOpenDialog(true)} data-testid="wipe-today-button">
Wipe Today
@@ -0,0 +1,51 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { useBookingConfirmation } from "./hooks/useBookingConfirmation";
interface AcceptBookingButtonProps {
bookingId: number;
bookingUid: string;
recurringEventId?: string | null;
isRecurring?: boolean;
isTabRecurring?: boolean;
isTabUnconfirmed?: boolean;
size?: "sm" | "base" | "lg";
color?: "primary" | "secondary" | "minimal" | "destructive";
className?: string;
}
export function AcceptBookingButton({
bookingId,
bookingUid,
recurringEventId,
isRecurring = false,
isTabRecurring = false,
isTabUnconfirmed = false,
size = "base",
color = "primary",
className,
}: AcceptBookingButtonProps) {
const { t } = useLocale();
const { bookingConfirm, isPending } = useBookingConfirmation({
isRecurring,
isTabRecurring,
isTabUnconfirmed,
});
const confirmLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm");
return (
<Button
color={color}
size={size}
className={className}
onClick={() => bookingConfirm({ bookingId, confirmed: true, recurringEventId })}
disabled={isPending}
data-booking-uid={bookingUid}
data-testid="confirm">
{confirmLabel}
</Button>
);
}
+318 -343
View File
@@ -8,7 +8,6 @@ import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/a
import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { formatTime } from "@calcom/lib/dayjs";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -23,7 +22,6 @@ import type { Ensure } from "@calcom/types/utils";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
@@ -35,30 +33,29 @@ import {
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/components/dropdown";
import { TextAreaField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { MeetingTimeInTimezones } from "@calcom/ui/components/popover";
import { TableActions } from "@calcom/ui/components/table";
import type { ActionType } from "@calcom/ui/components/table";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
import type { BookingAttendee } from "../../modules/bookings/types";
import { AcceptBookingButton } from "./AcceptBookingButton";
import { RejectBookingButton } from "./RejectBookingButton";
import { BookingActionsDropdown } from "./actions/BookingActionsDropdown";
import {
useBookingActionsStoreContext,
BookingActionsStoreProvider,
} from "./actions/BookingActionsStoreProvider";
import {
getPendingActions,
getCancelEventAction,
shouldShowPendingActions,
shouldShowRecurringCancelAction,
shouldShowIndividualReportButton,
type BookingActionContext,
getReportAction,
isActionDisabled,
} from "./actions/bookingActions";
import type { BookingItemProps } from "./types";
@@ -146,36 +143,11 @@ function BookingListItem(booking: BookingItemProps) {
t,
i18n: { language },
} = useLocale();
const utils = trpc.useUtils();
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const attendeeList = booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
id: attendee.id,
noShow: attendee.noShow || false,
phoneNumber: attendee.phoneNumber,
};
});
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === BookingStatus.REJECTED) {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const attendeeList = booking.attendees.map((attendee) => ({
...attendee,
noShow: attendee.noShow || false,
}));
const isUpcoming = new Date(booking.endTime) >= new Date();
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
@@ -217,22 +189,6 @@ function BookingListItem(booking: BookingItemProps) {
const isDisabledRescheduling = booking.eventType.disableRescheduling;
const cardCharged = booking?.payment[0]?.success;
const bookingConfirm = async (confirm: boolean) => {
let body = {
bookingId: booking.id,
confirmed: confirm,
reason: rejectionReason,
};
/**
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
*/
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
mutation.mutate(body);
};
const getSeatReferenceUid = () => {
return userSeat?.referenceUid;
};
@@ -265,20 +221,6 @@ function BookingListItem(booking: BookingItemProps) {
t,
} as BookingActionContext;
const basePendingActions = getPendingActions(actionContext);
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
...action,
onClick:
action.id === "reject"
? () => setRejectionDialogIsOpen(true)
: action.id === "confirm"
? () => bookingConfirm(true)
: undefined,
disabled: action.disabled || mutation.isPending,
})) as ActionType[];
const cancelEventAction = getCancelEventAction(actionContext);
const RequestSentMessage = () => {
return (
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
@@ -313,6 +255,7 @@ function BookingListItem(booking: BookingItemProps) {
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
const reportAction = getReportAction(actionContext);
const reportActionWithHandler = {
@@ -321,232 +264,236 @@ function BookingListItem(booking: BookingItemProps) {
};
return (
<>
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose />
<Button
disabled={mutation.isPending}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
}}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div
data-testid="booking-item"
data-today={String(booking.isToday)}
className="hover:bg-cal-muted group w-full">
<div className="flex flex-col sm:flex-row">
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
<div className="flex h-full items-center">
{eventTypeColor && (
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
)}
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
<div className="cursor-pointer py-4">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
{!isPending && (
<div>
{(provider?.label ||
(typeof locationToDisplay === "string" &&
locationToDisplay?.startsWith("https://"))) &&
locationToDisplay.startsWith("http") && (
<a
href={locationToDisplay}
onClick={(e) => e.stopPropagation()}
target="_blank"
title={locationToDisplay}
rel="noreferrer"
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
<div className="flex items-center gap-2">
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
width={16}
height={16}
className="h-4 w-4 rounded-sm"
alt={`${provider?.label} logo`}
/>
)}
{provider?.label
? t("join_event_location", { eventLocationType: provider?.label })
: t("join_meeting")}
</div>
</a>
)}
</div>
)}
</div>
</ConditionalLink>
</div>
</div>
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
<ConditionalLink onClick={onClick} bookingLink={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{showPendingPayment && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("pending_payment")}
</Badge>
)}
{isRescheduled && (
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
{t("rescheduled")}
</Badge>
</Tooltip>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
booking={booking}
recurringDates={recurringDates}
/>
</div>
)}
</div>
<div
data-testid="booking-item"
data-today={String(booking.isToday)}
data-booking-list-item="true"
className="hover:bg-cal-muted group w-full">
<div className="flex flex-col sm:flex-row">
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
<div className="flex h-full items-center">
{eventTypeColor && <div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />}
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
<div className="cursor-pointer py-4">
<div
title={title}
className={classNames(
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
<span> </span>
{showPendingPayment && (
<Badge className="hidden sm:inline-flex" variant="orange">
{t("pending_payment")}
</Badge>
)}
</div>
{booking.description && (
<div
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={attendeeList}
user={booking.user}
currentEmail={userEmail}
bookingUid={booking.uid}
isBookingInPast={isBookingInPast}
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
{!isPending && (
<div>
{(provider?.label ||
(typeof locationToDisplay === "string" && locationToDisplay?.startsWith("https://"))) &&
locationToDisplay.startsWith("http") && (
<a
href={locationToDisplay}
onClick={(e) => e.stopPropagation()}
target="_blank"
title={locationToDisplay}
rel="noreferrer"
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
<div className="flex items-center gap-2">
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
width={16}
height={16}
className="h-4 w-4 rounded-sm"
alt={`${provider?.label} logo`}
/>
)}
{provider?.label
? t("join_event_location", { eventLocationType: provider?.label })
: t("join_meeting")}
</div>
</a>
)}
</div>
)}
</div>
</ConditionalLink>
</div>
<div className="stack-y-2 sm:stack-y-0 flex w-full flex-col flex-wrap items-end justify-end space-x-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
<BookingActionsDropdown booking={booking} context="list" />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center space-x-2">
<Button
type="button"
variant="icon"
color="destructive"
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className="min-h-[34px] min-w-[34px]"
tooltip={reportActionWithHandler.label}
/>
</div>
)}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
<RequestSentMessage />
</div>
)}
</div>
</div>
<BookingItemBadges
booking={booking}
isPending={isPending}
recurringDates={recurringDates}
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
isRescheduled={isRescheduled}
/>
<div
data-testid="title-and-attendees"
className={classNames("flex-1 px-4", isRejected && "line-through")}>
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="flex h-full flex-col">
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{showPendingPayment && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("pending_payment")}
</Badge>
)}
{isRescheduled && (
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
{t("rescheduled")}
</Badge>
</Tooltip>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
booking={booking}
recurringDates={recurringDates}
/>
</div>
)}
</div>
<div className="cursor-pointer py-4">
<div
title={title}
className={classNames(
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
<span> </span>
{showPendingPayment && (
<Badge className="hidden sm:inline-flex" variant="orange">
{t("pending_payment")}
</Badge>
)}
</div>
{booking.description && (
<div
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={attendeeList}
user={booking.user}
currentEmail={userEmail}
bookingUid={booking.uid}
isBookingInPast={isBookingInPast}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
)}
</div>
</ConditionalLink>
</div>
<div className="flex flex-col flex-wrap items-end justify-end gap-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-shrink-0 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
{shouldShowPendingActions(actionContext) && (
<div className="flex space-x-2 rtl:space-x-reverse">
<RejectBookingButton
bookingId={booking.id}
bookingUid={booking.uid}
recurringEventId={booking.recurringEventId}
isRecurring={isRecurring}
isTabRecurring={isTabRecurring}
isTabUnconfirmed={isTabUnconfirmed}
/>
<AcceptBookingButton
bookingId={booking.id}
bookingUid={booking.uid}
recurringEventId={booking.recurringEventId}
isRecurring={isRecurring}
isTabRecurring={isTabRecurring}
isTabUnconfirmed={isTabUnconfirmed}
/>
</div>
)}
{shouldShowRecurringCancelAction(actionContext) && (
<Button
className="whitespace-nowrap"
key="cancel"
data-testid="cancel"
onClick={(e) => {
e.stopPropagation();
setIsCancelDialogOpen(true);
}}
StartIcon="circle-x"
disabled={isActionDisabled("cancel", actionContext)}
data-booking-uid={booking.uid}
color="destructive">
{t("cancel_all_remaining")}
</Button>
)}
{isCancelled && booking.rescheduled && (
<div className="hidden items-center md:flex">
<RequestSentMessage />
</div>
)}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="icon"
color="destructive"
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className="min-h-[34px] min-w-[34px]"
tooltip={reportActionWithHandler.label}
/>
</div>
)}
<BookingActionsDropdown booking={booking} context="list" />
</div>
</div>
</>
<BookingItemBadges
booking={booking}
isPending={isPending}
isRejected={isRejected}
recurringDates={recurringDates}
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
isRescheduled={isRescheduled}
/>
</div>
);
}
const BookingItemBadges = ({
booking,
isPending,
isRejected,
recurringDates,
userTimeFormat,
userTimeZone,
@@ -554,6 +501,7 @@ const BookingItemBadges = ({
}: {
booking: BookingItemProps;
isPending: boolean;
isRejected: boolean;
recurringDates: Date[] | undefined;
userTimeFormat: number | null | undefined;
userTimeZone: string | undefined;
@@ -575,6 +523,11 @@ const BookingItemBadges = ({
</Badge>
</Tooltip>
)}
{isRejected && !isRescheduled && booking.assignmentReason.length === 0 && (
<Badge variant="gray" className="ltr:mr-2 rtl:ml-2">
{t("rejected")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{booking.eventType.team.name}
@@ -727,21 +680,13 @@ const FirstAttendee = ({
);
};
type AttendeeProps = {
name?: string;
email: string;
phoneNumber: string | null;
id: number;
noShow: boolean;
};
type NoShowProps = {
bookingUid: string;
isBookingInPast: boolean;
};
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const { email, name, bookingUid, isBookingInPast, noShow, phoneNumber } = attendeeProps;
const Attendee = (attendeeProps: BookingAttendee & NoShowProps) => {
const { email, name, bookingUid, isBookingInPast, noShow, phoneNumber, user } = attendeeProps;
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -758,6 +703,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
},
});
const displayName = user?.name || name || user?.email || email;
return (
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
@@ -767,10 +714,10 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
{noShow ? (
<>
{name || email} <Icon name="eye-off" className="inline h-4" />
{displayName} <Icon name="eye-off" className="inline h-4" />
</>
) : (
<>{name || email}</>
<>{displayName}</>
)}
</button>
</DropdownMenuTrigger>
@@ -825,20 +772,12 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
};
type GroupedAttendeeProps = {
attendees: AttendeeProps[];
attendees: BookingAttendee[];
bookingUid: string;
};
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
const { bookingUid } = groupedAttendeeProps;
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
return {
id: attendee.id,
email: attendee.email,
name: attendee.name,
noShow: attendee.noShow || false,
};
});
const { bookingUid, attendees } = groupedAttendeeProps;
const { t } = useLocale();
const utils = trpc.useUtils();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
@@ -850,11 +789,14 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
showToast(err.message, "error");
},
});
const { control, handleSubmit } = useForm<{
attendees: AttendeeProps[];
}>({
type FormValues = {
attendees: Array<{ id: number; email: string; noShow: boolean }>;
};
const { control, handleSubmit } = useForm<FormValues>({
defaultValues: {
attendees,
attendees: attendees.map((a) => ({ id: a.id, email: a.email, noShow: a.noShow || false })),
},
mode: "onBlur",
});
@@ -864,8 +806,11 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
name: "attendees",
});
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
const filteredData = data.attendees.slice(1);
const onSubmit = (data: FormValues) => {
const filteredData = data.attendees.slice(1).map((attendee) => ({
email: attendee.email,
noShow: attendee.noShow,
}));
noShowMutation.mutate({ bookingUid, attendees: filteredData });
setOpenDropdown(false);
};
@@ -882,30 +827,46 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
{t("plus_more", { count: attendees.length - 1 })}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent className="min-w-[300px]">
<DropdownMenuLabel className="text-xs font-medium uppercase">
{t("mark_as_no_show_title")}
</DropdownMenuLabel>
<form onSubmit={handleSubmit(onSubmit)}>
{fields.slice(1).map((field, index) => (
<Controller
key={field.id}
name={`attendees.${index + 1}.noShow`}
control={control}
render={({ field: { onChange, value } }) => (
<DropdownMenuCheckboxItem
checked={value || false}
onCheckedChange={onChange}
className="pr-8 focus:outline-none"
onClick={(e) => {
e.preventDefault();
onChange(!value);
}}>
<span className={value ? "line-through" : ""}>{field.email}</span>
</DropdownMenuCheckboxItem>
)}
/>
))}
{fields.slice(1).map((field, index) => {
const attendee = attendees[index + 1];
const displayName =
attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
const hasName = attendee.name || attendee.user?.name;
return (
<Controller
key={field.id}
name={`attendees.${index + 1}.noShow`}
control={control}
render={({ field: { onChange, value } }) => (
<DropdownMenuCheckboxItem
checked={value || false}
onCheckedChange={onChange}
className="focus:outline-none"
onClick={(e) => {
e.preventDefault();
onChange(!value);
}}>
<div className={`w-full ${value ? "line-through" : ""}`}>
{hasName ? (
<>
<div>{displayName}</div>
<div className="text-subtle text-xs">{field.email}</div>
</>
) : (
<div>{field.email}</div>
)}
</div>
</DropdownMenuCheckboxItem>
)}
/>
);
})}
<DropdownMenuSeparator />
<div className="flex justify-end p-2">
<Button
@@ -924,7 +885,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
);
};
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
const GroupedGuests = ({ guests }: { guests: BookingAttendee[] }) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { t } = useLocale();
const { copyToClipboard, isCopied } = useCopy();
@@ -944,23 +905,37 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
{t("plus_more", { count: guests.length - 1 })}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent className="min-w-[300px]">
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("guests")}</DropdownMenuLabel>
{guests.slice(1).map((guest) => (
<DropdownMenuItem key={guest.id}>
<DropdownItem
className="pr-6 focus:outline-none"
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
onClick={(e) => {
e.preventDefault();
setSelectedEmail(guest.email);
}}>
<span className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>{guest.email}</span>
</DropdownItem>
</DropdownMenuItem>
))}
{guests.slice(1).map((guest) => {
const displayName = guest.user?.name || guest.name || guest.user?.email || guest.email;
const hasName = guest.name || guest.user?.name;
return (
<DropdownMenuItem key={guest.id}>
<DropdownItem
className="pr-6 focus:outline-none"
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
onClick={(e) => {
e.preventDefault();
setSelectedEmail(guest.email);
}}>
<div className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>
{hasName ? (
<>
<div>{displayName}</div>
<div className="text-subtle text-xs">{guest.email}</div>
</>
) : (
<div>{guest.email}</div>
)}
</div>
</DropdownItem>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<div className="flex justify-end space-x-2 p-2">
<div className="flex justify-end gap-2 p-2">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
@@ -995,7 +970,7 @@ const DisplayAttendees = ({
bookingUid,
isBookingInPast,
}: {
attendees: AttendeeProps[];
attendees: BookingAttendee[];
user: UserProps | null;
currentEmail?: string | null;
bookingUid: string;
@@ -1005,7 +980,7 @@ const DisplayAttendees = ({
attendees.sort((a, b) => a.id - b.id);
return (
<div className="text-emphasis text-sm">
<div className="text-emphasis text-sm" onClick={(e) => e.stopPropagation()}>
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
{attendees.length > 1 ? <span>,&nbsp;</span> : <span>&nbsp;{t("and")}&nbsp;</span>}
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
+31 -13
View File
@@ -7,9 +7,11 @@ import { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
import type { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
interface InternalNotePresetsSelectProps {
internalNotePresets: { id: number; name: string }[];
@@ -40,7 +42,7 @@ const InternalNotePresetsSelect = ({
setCancellationReason("");
} else {
setShowOtherInput(false);
onPresetSelect && onPresetSelect(option);
onPresetSelect?.(option);
}
};
@@ -50,7 +52,7 @@ const InternalNotePresetsSelect = ({
<Select
className="mb-2"
options={[
...internalNotePresets?.map((preset) => ({
...internalNotePresets.map((preset) => ({
label: preset.name,
value: preset.id,
})),
@@ -105,7 +107,10 @@ type Props = {
};
isHost: boolean;
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
renderContext: "booking-single-view" | "dialog";
eventTypeMetadata?: Record<string, unknown> | null;
showErrorAsToast?: boolean;
onCanceled?: () => void;
};
export default function CancelBooking(props: Props) {
@@ -168,12 +173,13 @@ export default function CancelBooking(props: Props) {
node.scrollIntoView({ behavior: "smooth" });
node.focus();
}
}, []);
const isRenderedAsCancelDialog = props.renderContext === "dialog";
return (
<>
{error && (
{error && !props.showErrorAsToast && (
<div className="mt-8">
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="x" className="h-6 w-6 text-red-600" />
@@ -186,7 +192,7 @@ export default function CancelBooking(props: Props) {
</div>
)}
{!error && (
<div className="mt-5 sm:mt-6">
<div className={classNames(isRenderedAsCancelDialog ? "-mt-2 mb-8" : "mt-5 sm:mt-6")}>
{props.isHost && props.internalNotePresets.length > 0 && (
<>
<InternalNotePresetsSelect
@@ -219,13 +225,13 @@ export default function CancelBooking(props: Props) {
placeholder={t("cancellation_reason_placeholder")}
value={cancellationReason}
onChange={(e) => setCancellationReason(e.target.value)}
className="mb-4 mt-2 w-full "
className={classNames("mb-4 w-full", !isRenderedAsCancelDialog && "mt-2")}
rows={3}
/>
{isCancellationUserHost ? (
<div className="-mt-2 mb-4 flex items-center gap-2">
<Icon name="info" className="text-subtle h-4 w-4" />
<p className="text-default text-subtle text-sm leading-none">
<p className="text-subtle text-sm leading-none">
{t("notify_attendee_cancellation_reason_warning")}
</p>
</div>
@@ -248,7 +254,11 @@ export default function CancelBooking(props: Props) {
</div>
)}
<div className="flex flex-col-reverse rtl:space-x-reverse ">
<div className="ml-auto flex w-full space-x-4 ">
<div
className={classNames(
"ml-auto flex w-full space-x-4",
isRenderedAsCancelDialog && "justify-end"
)}>
<Button
className="ml-auto"
color="secondary"
@@ -295,15 +305,23 @@ export default function CancelBooking(props: Props) {
booking: bookingWithCancellationReason,
});
refreshData();
if (props.onCanceled) {
props.onCanceled();
}
} else {
const data = await res.json();
setLoading(false);
setError(
const errorMessage =
data.message ||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
"please_try_again"
)}`
);
`${t("error_with_status_code_occured", { status: res.status })} ${t(
"please_try_again"
)}`;
if (props.showErrorAsToast) {
showToast(errorMessage, "error");
} else {
setError(errorMessage);
}
}
}}
loading={loading}>
@@ -0,0 +1,62 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { RejectionReasonDialog } from "../dialog/RejectionReasonDialog";
import { useBookingConfirmation } from "./hooks/useBookingConfirmation";
interface RejectBookingButtonProps {
bookingId: number;
bookingUid: string;
recurringEventId?: string | null;
isRecurring?: boolean;
isTabRecurring?: boolean;
isTabUnconfirmed?: boolean;
size?: "sm" | "base" | "lg";
color?: "primary" | "secondary" | "minimal" | "destructive";
className?: string;
}
export function RejectBookingButton({
bookingId,
bookingUid,
recurringEventId,
isRecurring = false,
isTabRecurring = false,
isTabUnconfirmed = false,
size = "base",
color = "secondary",
className,
}: RejectBookingButtonProps) {
const { t } = useLocale();
const { bookingConfirm, handleReject, isPending, rejectionDialogIsOpen, setRejectionDialogIsOpen } =
useBookingConfirmation({
isRecurring,
isTabRecurring,
isTabUnconfirmed,
});
const rejectLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject");
return (
<>
<RejectionReasonDialog
isOpenDialog={rejectionDialogIsOpen}
setIsOpenDialog={setRejectionDialogIsOpen}
onConfirm={(reason) => bookingConfirm({ bookingId, confirmed: false, recurringEventId, reason })}
isPending={isPending}
/>
<Button
color={color}
size={size}
className={className}
onClick={handleReject}
disabled={isPending}
data-booking-uid={bookingUid}
data-testid="reject">
{rejectLabel}
</Button>
</>
);
}
+10 -8
View File
@@ -5,18 +5,12 @@ import { SkeletonText } from "@calcom/ui/components/skeleton";
function SkeletonLoader() {
return (
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
{/* TODAY header */}
<li className="bg-muted flex items-center px-6 py-4">
<SkeletonText className="h-4 w-16" />
</li>
<SkeletonHeader />
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
{/* NEXT header */}
<li className="bg-muted px-4 py-2">
<SkeletonText className="h-4 w-16" />
</li>
<SkeletonHeader />
<SkeletonItem />
<SkeletonItem />
</ul>
@@ -25,6 +19,14 @@ function SkeletonLoader() {
export default SkeletonLoader;
function SkeletonHeader() {
return (
<li className="bg-muted flex items-center px-6 py-4">
<SkeletonText className="h-4 w-16" />
</li>
);
}
function SkeletonItem() {
return (
<li className="group flex w-full items-start justify-between px-4 py-4 sm:px-6">
@@ -125,6 +125,7 @@ const mockProps = {
eventType: {},
},
internalNotePresets: [],
renderContext: "booking-single-view" as const,
};
const mockBookingWithoutCancellationFee = {
@@ -1,14 +1,14 @@
import { useState } from "react";
import type { z } from "zod";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
@@ -19,18 +19,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { TextAreaField } from "@calcom/ui/components/form";
import type { ActionType } from "@calcom/ui/components/table";
import { showToast } from "@calcom/ui/components/toast";
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { CancelBookingDialog } from "@components/dialog/CancelBookingDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { RejectionReasonDialog } from "@components/dialog/RejectionReasonDialog";
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import { useBookingConfirmation } from "../hooks/useBookingConfirmation";
import type { BookingItemProps } from "../types";
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
import {
@@ -72,11 +74,24 @@ export function BookingActionsDropdown({
const { t } = useLocale();
const utils = trpc.useUtils();
// Use store for all dialog states
const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason);
const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason);
const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen);
const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen);
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
// Use the booking confirmation hook for confirm/reject logic
const {
bookingConfirm,
handleReject,
rejectionDialogIsOpen,
setRejectionDialogIsOpen,
isPending: isConfirmPending,
} = useBookingConfirmation({
isRecurring,
isTabRecurring,
isTabUnconfirmed,
});
// Use store for all other dialog states
const chargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.chargeCardDialogIsOpen);
const setChargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.setChargeCardDialogIsOpen);
const viewRecordingsDialogIsOpen = useBookingActionsStoreContext(
@@ -105,6 +120,8 @@ export function BookingActionsDropdown({
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
const isCancelDialogOpen = useBookingActionsStoreContext((state) => state.isCancelDialogOpen);
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
const cardCharged = booking?.payment[0]?.success;
@@ -128,23 +145,6 @@ export function BookingActionsDropdown({
},
});
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === "REJECTED") {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
showToast(t("location_updated"), "success");
@@ -170,7 +170,6 @@ export function BookingActionsDropdown({
const isRejected = booking.status === "REJECTED";
const isPending = booking.status === "PENDING";
const isRescheduled = booking.fromReschedule !== null;
const isRecurring = booking.recurringEventId !== null;
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
if (isCancelled) return "cancelled";
@@ -179,15 +178,15 @@ export function BookingActionsDropdown({
return "upcoming";
};
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
const userEmail = booking.loggedInUser.userEmail;
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
const isAttendee = !!userSeat;
// Check if the logged-in user is the host/owner of the booking
const isHost = booking.loggedInUser.userId === booking.user?.id;
const isCalVideoLocation =
!booking.location ||
booking.location === "integrations:daily" ||
@@ -200,19 +199,6 @@ export function BookingActionsDropdown({
return userSeat?.referenceUid;
};
const bookingConfirm = async (confirm: boolean) => {
let body = {
bookingId: booking.id,
confirmed: confirm,
reason: rejectionReason,
};
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
mutation.mutate(body);
};
const saveLocation = async ({
newLocation,
credentialId,
@@ -268,12 +254,17 @@ export function BookingActionsDropdown({
shouldShowPending && context === "details" ? getPendingActions(actionContext) : [];
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
...action,
disabled: mutation.isPending,
disabled: isConfirmPending,
onClick:
action.id === "confirm"
? () => bookingConfirm(true)
? () =>
bookingConfirm({
bookingId: booking.id,
confirmed: true,
recurringEventId: booking.recurringEventId,
})
: action.id === "reject"
? () => setRejectionDialogIsOpen(true)
? () => handleReject()
: undefined,
})) as ActionType[];
@@ -482,35 +473,39 @@ export function BookingActionsDropdown({
isOpen={isNoShowDialogOpen}
/>
)}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose />
<Button
disabled={mutation.isPending}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
}}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CancelBookingDialog
isOpenDialog={isCancelDialogOpen}
setIsOpenDialog={setIsCancelDialogOpen}
booking={{
uid: booking.uid,
id: booking.id,
title: booking.title,
startTime: new Date(booking.startTime),
payment: booking.payment,
}}
profile={{
name: booking.user?.name || null,
slug: booking.user?.username || null,
}}
recurringEvent={booking.eventType?.recurringEvent || null}
team={booking.eventType?.team?.name}
teamId={booking.eventType?.team?.id}
allRemainingBookings={isTabRecurring && isRecurring}
seatReferenceUid={getSeatReferenceUid()}
currentUserEmail={booking.loggedInUser.userEmail}
bookingCancelledEventProps={{
booking: booking,
organizer: {
name: booking.user?.name || "Nameless",
email: booking.userPrimaryEmail || booking.user?.email || "Email-less",
timeZone: booking.user?.timeZone,
},
eventType: booking.eventType,
}}
isHost={isHost}
internalNotePresets={[]}
eventTypeMetadata={booking.eventType?.metadata}
/>
{isBookingFromRoutingForm &&
parsedBooking.eventType &&
parsedBooking.eventType.id !== undefined &&
@@ -575,6 +570,19 @@ export function BookingActionsDropdown({
return (
<>
{dialogs}
<RejectionReasonDialog
isOpenDialog={rejectionDialogIsOpen}
setIsOpenDialog={setRejectionDialogIsOpen}
onConfirm={(reason) =>
bookingConfirm({
bookingId: booking.id,
confirmed: false,
recurringEventId: booking.recurringEventId,
reason,
})
}
isPending={isConfirmPending}
/>
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
<Button
@@ -582,7 +590,7 @@ export function BookingActionsDropdown({
color="secondary"
size={size}
StartIcon="ellipsis"
className={className}
className={classNames("px-2", className)}
// Prevent click from bubbling to parent row/container click handlers
onClick={(e) => e.stopPropagation()}
/>
@@ -604,7 +612,7 @@ export function BookingActionsDropdown({
e.stopPropagation();
action.onClick?.(e);
}}
data-bookingid={action.bookingId}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
@@ -627,7 +635,7 @@ export function BookingActionsDropdown({
e.stopPropagation();
action.onClick?.(e);
}}
data-bookingid={action.bookingId}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
@@ -648,7 +656,7 @@ export function BookingActionsDropdown({
action.onClick?.(e);
}}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
@@ -685,13 +693,12 @@ export function BookingActionsDropdown({
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
onClick={(e) => {
e.stopPropagation();
cancelEventAction.onClick?.(e);
setIsCancelDialogOpen(true);
}}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-booking-uid={cancelEventAction.bookingUid}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
@@ -138,7 +138,7 @@ describe("Booking Actions", () => {
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
id: "confirm",
bookingId: 1,
bookingUid: "booking-123",
label: "confirm",
icon: "check",
disabled: false,
@@ -180,10 +180,10 @@ describe("Booking Actions", () => {
expect(action).toEqual({
id: "cancel",
label: "cancel_event",
href: "/booking/booking-123?cancel=true",
icon: "circle-x",
color: "destructive",
disabled: false,
bookingUid: "booking-123",
});
});
@@ -212,7 +212,6 @@ describe("Booking Actions", () => {
});
const action = getCancelEventAction(context);
expect(action.href).toContain("allRemainingBookings=true");
expect(action.label).toBe("cancel_all_remaining");
});
});
@@ -394,7 +393,16 @@ describe("Booking Actions", () => {
...createMockContext().booking,
status: BookingStatus.ACCEPTED,
paid: true,
payment: [{ paymentOption: "HOLD", amount: 1000, currency: "usd", success: true }],
payment: [
{
paymentOption: "HOLD",
amount: 1000,
currency: "usd",
success: true,
appId: null,
refunded: false,
},
],
},
});
const actions = getAfterEventActions(context);
@@ -45,7 +45,7 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
if ((isPending && !showPendingPayment) || (showPendingPayment && booking.paid)) {
actions.push({
id: "confirm",
bookingId: booking.id,
bookingUid: booking.uid,
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
icon: "check" as const,
disabled: false, // This would be controlled by mutation state in the component
@@ -63,18 +63,15 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
}
export function getCancelEventAction(context: BookingActionContext): ActionType {
const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context;
const seatReferenceUid = getSeatReferenceUid();
const { booking, isTabRecurring, isRecurring, t } = context;
return {
id: "cancel",
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
href: `/booking/${booking.uid}?cancel=true${
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
}${booking.seatsReferences.length && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
icon: "circle-x",
color: "destructive",
disabled: isActionDisabled("cancel", context),
bookingUid: booking.uid,
};
}
+7 -8
View File
@@ -16,6 +16,7 @@ export type BookingActionsStore = {
isOpenAddGuestsDialog: boolean;
isOpenReportDialog: boolean;
rerouteDialogIsOpen: boolean;
isCancelDialogOpen: boolean;
// Dialog setters
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -29,10 +30,7 @@ export type BookingActionsStore = {
setIsOpenAddGuestsDialog: React.Dispatch<React.SetStateAction<boolean>>;
setIsOpenReportDialog: React.Dispatch<React.SetStateAction<boolean>>;
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
// Rejection reason state
rejectionReason: string;
setRejectionReason: (reason: string) => void;
setIsCancelDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export const createBookingActionsStore = () => {
@@ -49,6 +47,7 @@ export const createBookingActionsStore = () => {
isOpenAddGuestsDialog: false,
isOpenReportDialog: false,
rerouteDialogIsOpen: false,
isCancelDialogOpen: false,
// Dialog setters
setRejectionDialogIsOpen: (isOpen) =>
@@ -98,9 +97,9 @@ export const createBookingActionsStore = () => {
set((state) => ({
rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen,
})),
// Rejection reason state
rejectionReason: "",
setRejectionReason: (reason: string) => set({ rejectionReason: reason }),
setIsCancelDialogOpen: (isOpen) =>
set((state) => ({
isCancelDialogOpen: typeof isOpen === "function" ? isOpen(state.isCancelDialogOpen) : isOpen,
})),
}));
};
@@ -0,0 +1,74 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
interface UseBookingConfirmationOptions {
isRecurring?: boolean;
isTabRecurring?: boolean;
isTabUnconfirmed?: boolean;
}
interface BookingConfirmParams {
bookingId: number;
confirmed: boolean;
recurringEventId?: string | null;
reason?: string;
}
export function useBookingConfirmation(options: UseBookingConfirmationOptions = {}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const { isRecurring = false, isTabRecurring = false, isTabUnconfirmed = false } = options;
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === BookingStatus.REJECTED) {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: (error) => {
showToast(error.message || t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const bookingConfirm = ({ bookingId, confirmed, recurringEventId, reason }: BookingConfirmParams) => {
let body = {
bookingId,
confirmed,
reason: reason || "",
};
/**
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
*/
if ((isTabRecurring || isTabUnconfirmed) && isRecurring && recurringEventId) {
body = Object.assign({}, body, { recurringEventId });
}
mutation.mutate(body);
};
const handleReject = () => {
setRejectionDialogIsOpen(true);
};
return {
bookingConfirm,
handleReject,
rejectionDialogIsOpen,
setRejectionDialogIsOpen,
isPending: mutation.isPending,
};
}
+3 -1
View File
@@ -1,6 +1,8 @@
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
export type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
export type BookingListingStatus = NonNullable<
RouterInputs["viewer"]["bookings"]["get"]["filters"]["statuses"]
>[number];
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
@@ -0,0 +1,122 @@
import type { Dispatch, SetStateAction } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast";
import CancelBooking from "@components/booking/CancelBooking";
interface ICancelBookingDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
booking: {
uid: string;
id: number;
title: string;
startTime: Date;
payment?: {
amount: number;
currency: string;
success: boolean;
paymentOption?: string | null;
appId?: string | null;
refunded?: boolean;
}[];
};
profile: {
name: string | null;
slug: string | null;
};
recurringEvent: RecurringEvent | null;
team?: string | null;
teamId?: number;
allRemainingBookings?: boolean;
seatReferenceUid?: string;
currentUserEmail?: string;
bookingCancelledEventProps: {
booking: unknown;
organizer: {
name: string;
email: string;
timeZone?: string;
};
eventType: unknown;
};
isHost: boolean;
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
eventTypeMetadata?: Record<string, unknown> | null;
}
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
const { t } = useLocale();
const {
isOpenDialog,
setIsOpenDialog,
booking,
profile,
recurringEvent,
team,
teamId,
allRemainingBookings = false,
seatReferenceUid,
currentUserEmail,
bookingCancelledEventProps,
isHost,
internalNotePresets = [],
eventTypeMetadata,
} = props;
const utils = trpc.useUtils();
// Get the first payment if it exists and map to expected format
const payment =
booking.payment && booking.payment.length > 0
? {
amount: booking.payment[0].amount,
currency: booking.payment[0].currency,
appId: booking.payment[0].appId || null,
}
: null;
const handleCanceled = () => {
utils.viewer.bookings.invalidate();
showToast(t("booking_cancelled"), "success");
setIsOpenDialog(false);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow data-testid="cancel-dialog">
<DialogHeader title={allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")} />
<CancelBooking
renderContext="dialog"
booking={{
uid: booking.uid,
id: booking.id,
title: booking.title,
startTime: booking.startTime,
payment: payment,
}}
profile={profile}
recurringEvent={recurringEvent}
team={team}
teamId={teamId}
setIsCancellationMode={setIsOpenDialog}
theme={null}
allRemainingBookings={allRemainingBookings}
seatReferenceUid={seatReferenceUid}
currentUserEmail={currentUserEmail}
bookingCancelledEventProps={bookingCancelledEventProps}
isHost={isHost}
internalNotePresets={internalNotePresets}
eventTypeMetadata={eventTypeMetadata}
showErrorAsToast={true}
onCanceled={handleCanceled}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,62 @@
import type { Dispatch, SetStateAction } from "react";
import { useState, useEffect } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { TextAreaField } from "@calcom/ui/components/form";
interface RejectionReasonDialogProps {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
onConfirm: (reason: string) => void;
isPending?: boolean;
}
export function RejectionReasonDialog({
isOpenDialog,
setIsOpenDialog,
onConfirm,
isPending = false,
}: RejectionReasonDialogProps) {
const { t } = useLocale();
const [rejectionReason, setRejectionReason] = useState("");
useEffect(() => {
if (!isOpenDialog) {
setRejectionReason("");
}
}, [isOpenDialog]);
const handleConfirm = () => {
onConfirm(rejectionReason);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> ({t("optional")})</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose color="secondary" />
<Button disabled={isPending} data-testid="rejection-confirm" onClick={handleConfirm}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+4 -3
View File
@@ -8,6 +8,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
import DynamicPostHogPageView from "@calcom/features/ee/event-tracking/lib/posthog/web/pageViewDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
@@ -16,12 +17,11 @@ import { useFlags } from "@calcom/features/flags/hooks";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
import { useNuqsParams } from "@lib/hooks/useNuqsParams";
import type { WithLocaleProps } from "@lib/withLocale";
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
import DynamicPostHogPageView from "@calcom/features/ee/event-tracking/lib/posthog/web/pageViewDynamic";
import { getThemeProviderProps } from "./getThemeProviderProps";
// Workaround for https://github.com/vercel/next.js/issues/8592
@@ -112,6 +112,7 @@ const AppProviders = (props: PageWrapperProps) => {
// No need to have intercom on public pages - Good for Page Performance
const isBookingPage = useIsBookingPage();
const isThemeSupported = useIsThemeSupported();
const nuqsParams = useNuqsParams();
const RemainingProviders = (
<>
@@ -121,7 +122,7 @@ const AppProviders = (props: PageWrapperProps) => {
nonce={props.nonce}
isThemeSupported={isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<NuqsAdapter>
<NuqsAdapter {...nuqsParams}>
<FeatureFlagsProvider>
{props.isBookingPage || isBookingPage ? (
<OrgBrandProvider>{props.children}</OrgBrandProvider>
+15 -16
View File
@@ -1,12 +1,11 @@
/**
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router (_app.tsx)
*
*
* Currently only serves the /router endpoint (routing forms redirect page).
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
*
*
* For App Router, use app-providers-app-dir.tsx instead.
*/
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { dir } from "i18next";
import type { Session } from "next-auth";
@@ -15,7 +14,6 @@ import { appWithTranslation } from "next-i18next";
import type { SSRConfig } from "next-i18next/dist/types/types";
import { ThemeProvider } from "next-themes";
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
import dynamic from "next/dynamic";
import { NuqsAdapter } from "nuqs/adapters/next/pages";
import type { ParsedUrlQuery } from "querystring";
import type { PropsWithChildren, ReactNode } from "react";
@@ -28,6 +26,7 @@ import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import { useNuqsParams } from "@lib/hooks/useNuqsParams";
import type { WithLocaleProps } from "@lib/withLocale";
import { useViewerI18n } from "@components/I18nLanguageHandler";
@@ -61,7 +60,6 @@ export type AppProps = Omit<
err?: Error;
};
type AppPropsWithChildren = AppProps & {
children: ReactNode;
};
@@ -136,8 +134,8 @@ const enum ThemeSupport {
type CalcomThemeProps = PropsWithChildren<
Pick<AppProps, "router"> &
Pick<AppProps["pageProps"], "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
Pick<AppProps["pageProps"], "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
@@ -211,8 +209,8 @@ function getThemeProviderProps({
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
? ThemeSupport.None
: ThemeSupport.App;
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
const themeBasis = props.themeBasis;
@@ -236,13 +234,13 @@ function getThemeProviderProps({
const storageKey = isEmbedMode
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
: themeSupport === ThemeSupport.App
? "app-theme"
: isBookingPageThemeSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
? "app-theme"
: isBookingPageThemeSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
return {
storageKey,
@@ -278,6 +276,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
(typeof props.Component.isBookingPage === "function"
? props.Component.isBookingPage({ router: props.router })
: props.Component.isBookingPage) || isBookingPage;
const nuqsParams = useNuqsParams();
const RemainingProviders = (
<CustomI18nextProvider {...props}>
@@ -287,7 +286,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage || isBookingPage}
router={props.router}>
<NuqsAdapter>
<NuqsAdapter {...nuqsParams}>
<FeatureFlagsProvider>
{_isBookingPage ? (
<OrgBrandProvider>{props.children}</OrgBrandProvider>
@@ -11,7 +11,7 @@ const assignmentReasonBadgeTitleMap = (assignmentReason: AssignmentReasonEnum) =
case AssignmentReasonEnum.REROUTED:
return "rerouted";
case AssignmentReasonEnum.SALESFORCE_ASSIGNMENT:
return "salesforce_assignment";
return "salesforce_assigned";
default:
return "routed";
}
-20
View File
@@ -1,20 +0,0 @@
// lets refactor and move this into packages/lib/hooks/
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
}, [matches, query]);
return matches;
};
export default useMediaQuery;
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
export function useNuqsParams() {
const pathname = usePathname();
const isBookingsPage = pathname?.startsWith("/bookings/");
return useMemo(() => {
if (isBookingsPage) {
return { processUrlSearchParams: putUidFirstInQueryParams };
}
return {};
}, [isBookingsPage]);
}
function putUidFirstInQueryParams(searchParams: URLSearchParams) {
const params = new URLSearchParams(searchParams);
const entries = Array.from(params.entries());
// Clear existing params
entries.forEach(([key]) => params.delete(key));
// Sort entries with "uid" first, then alphabetically
const sortedEntries = entries.sort(([keyA], [keyB]) => {
if (keyA === "uid") return -1;
if (keyB === "uid") return 1;
return keyA.localeCompare(keyB);
});
// Re-add sorted entries
sortedEntries.forEach(([key, value]) => params.append(key, value));
return params;
}
@@ -0,0 +1,223 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import React, { useMemo, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { DataTableFilters } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { Icon } from "@calcom/ui/components/icon";
import { useBookingCalendarData } from "~/bookings/hooks/useBookingCalendarData";
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import { useCalendarAllowedFilters } from "~/bookings/hooks/useCalendarAllowedFilters";
import { useCurrentWeekStart } from "~/bookings/hooks/useCurrentWeekStart";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { getWeekStart } from "../lib/weekUtils";
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
import { BookingCalendarView } from "./BookingCalendarView";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { ViewToggleButton } from "./ViewToggleButton";
import { WeekPicker } from "./WeekPicker";
// For calendar view, fetch all statuses except cancelled
const STATUSES: BookingListingStatus[] = ["upcoming", "unconfirmed", "recurring", "past"];
interface BookingCalendarContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
}
interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
data?: {
bookings: BookingsGetOutput["bookings"];
recurringInfo: BookingsGetOutput["recurringInfo"];
totalCount: BookingsGetOutput["totalCount"];
};
allowedFilterIds: string[];
isPending: boolean;
hasError: boolean;
errorMessage?: string;
}
function BookingCalendarInner({
status,
permissions,
data,
allowedFilterIds,
hasError,
errorMessage,
}: BookingCalendarInnerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
const goToPreviousWeek = () => {
setCurrentWeekStart(currentWeekStart.subtract(1, "week"));
};
const goToNextWeek = () => {
setCurrentWeekStart(currentWeekStart.add(1, "week"));
};
const goToToday = () => {
setCurrentWeekStart(getWeekStart(dayjs(), userWeekStart));
};
const ErrorView = errorMessage ? (
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
) : undefined;
const columns = useMemo(() => {
return buildFilterColumns({ t, permissions, status }).filter((column) =>
allowedFilterIds.includes(column.id || "")
);
}, [allowedFilterIds, t, permissions, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const rowData = useBookingCalendarData({ data, status });
const table = useReactTable<RowData>({
data: rowData,
columns,
initialState: {
columnVisibility: getFilterColumnVisibility(),
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
// Extract bookings from table data
const bookings = useMemo(() => {
return rowData
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking);
}, [rowData]);
return (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<WeekPicker
currentWeekStart={currentWeekStart}
userWeekStart={userWeekStart}
onDateChange={setCurrentWeekStart}
/>
{allowedFilterIds.length > 0 && <DataTableFilters.FilterBar table={table} />}
</div>
<div className="flex items-center gap-2">
<Button color="secondary" onClick={goToToday} className="capitalize">
{t("today")}
</Button>
<ButtonGroup combined>
<Button color="secondary" onClick={goToPreviousWeek}>
<span className="sr-only">{t("view_previous_week")}</span>
<Icon name="chevron-left" className="h-4 w-4" />
</Button>
<Button color="secondary" onClick={goToNextWeek}>
<span className="sr-only">{t("view_next_week")}</span>
<Icon name="chevron-right" className="h-4 w-4" />
</Button>
</ButtonGroup>
<ViewToggleButton />
</div>
</div>
{hasError && ErrorView ? (
ErrorView
) : (
<BookingCalendarView
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={setCurrentWeekStart}
/>
)}
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
</>
);
}
export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
const { canReadOthersBookings } = props.permissions;
const { userIds } = useBookingFilters();
const { currentWeekStart } = useCurrentWeekStart();
const allowedFilterIds = useCalendarAllowedFilters({
canReadOthersBookings,
});
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
limit: 100, // Use max limit for calendar view
filters: {
statuses: STATUSES,
userIds,
// Always fetch only the current week for calendar view
afterStartDate: currentWeekStart.startOf("day").toISOString(),
beforeEndDate: currentWeekStart.add(6, "day").endOf("day").toISOString(),
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
}
);
// Automatically fetch all pages until no more data
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
useEffect(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten all pages into a single data object
const data = useMemo(() => {
if (!query.data?.pages) return undefined;
// Combine all bookings from all pages
const allBookings = query.data.pages.flatMap((page) => page.bookings);
// recurringInfo is the same across all pages (queried globally by user, not per page)
const recurringInfo = query.data.pages[0]?.recurringInfo ?? [];
// totalCount is the same across all pages
const totalCount = query.data.pages[0]?.totalCount ?? 0;
return { bookings: allBookings, recurringInfo, totalCount };
}, [query.data?.pages]);
const bookings = useMemo(() => data?.bookings ?? [], [data?.bookings]);
return (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingCalendarInner
{...props}
data={data}
allowedFilterIds={allowedFilterIds}
isPending={query.isPending}
hasError={!!query.error}
errorMessage={query.error?.message}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -6,44 +6,27 @@ import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useBanners } from "@calcom/features/shell/banners/useBanners";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { Icon } from "@calcom/ui/components/icon";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
type BookingsCalendarViewProps = {
type BookingCalendarViewProps = {
bookings: BookingOutput[];
currentWeekStart: dayjs.Dayjs;
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
isPending?: boolean;
};
export function BookingsCalendarView({
export function BookingCalendarView({
bookings,
currentWeekStart,
onWeekStartChange,
isPending = false,
}: BookingsCalendarViewProps) {
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const { t } = useLocale();
}: BookingCalendarViewProps) {
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const { timezone } = useTimePreferences();
const { resolvedTheme, forcedTheme } = useGetTheme();
const goToPreviousWeek = () => {
onWeekStartChange(currentWeekStart.subtract(1, "week"));
};
const goToNextWeek = () => {
onWeekStartChange(currentWeekStart.add(1, "week"));
};
const goToToday = () => {
onWeekStartChange(dayjs().startOf("week"));
};
const { bannersHeight } = useBanners();
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
@@ -84,58 +67,17 @@ export function BookingsCalendarView({
options: {
status: booking.status,
...(eventTypeColor && { color: eventTypeColor }),
bookingId: booking.id,
bookingUid: booking.uid,
},
};
});
}, [bookings, currentWeekStart, resolvedTheme, forcedTheme]);
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
const startMonth = weekStart.format("MMM");
const endMonth = weekEnd.format("MMM");
const year = weekEnd.format("YYYY");
const weekRange =
startMonth === endMonth ? (
<>
<span className="text-emphasis">{`${startMonth} ${weekStart.format("D")} - ${weekEnd.format(
"D"
)}`}</span>
<span className="text-muted">, {year}</span>
</>
) : (
<>
<span className="text-emphasis">{`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`}</span>
<span className="text-muted">, {year}</span>
</>
);
return (
<div className="border-subtle flex h-[calc(100vh-260px)] min-h-[600px] flex-col rounded-2xl border">
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{weekRange}</h2>
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
</div>
<div className="flex items-center gap-2">
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
{t("today")}
</Button>
<ButtonGroup combined>
<Button color="secondary" onClick={goToPreviousWeek}>
<span className="sr-only">{t("view_previous_week")}</span>
<Icon name="chevron-left" className="h-4 w-4" />
</Button>
<Button color="secondary" onClick={goToNextWeek}>
<span className="sr-only">{t("view_next_week")}</span>
<Icon name="chevron-right" className="h-4 w-4" />
</Button>
</ButtonGroup>
</div>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-2xl">
<>
<div
className="border-subtle flex flex-1 flex-col overflow-y-auto overflow-x-hidden rounded-2xl border"
style={{ height: `calc(100vh - 6rem - ${bannersHeight}px)` }}>
<Calendar
timezone={timezone}
sortEvents
@@ -150,14 +92,15 @@ export function BookingsCalendarView({
showBorder={false}
borderColor="subtle"
onEventClick={(event) => {
const bookingId = event.options?.bookingId;
if (bookingId) {
setSelectedBookingId(bookingId);
const bookingUid = event.options?.bookingUid;
if (bookingUid) {
setSelectedBookingUid(bookingUid);
}
}}
showTimezone
hideHeader
/>
</div>
</div>
</>
);
}
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { z } from "zod";
@@ -8,9 +9,16 @@ import { useBookingLocation } from "@calcom/features/bookings/hooks";
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
import { formatPrice } from "@calcom/lib/currencyConversions";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { bookingMetadataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { BookingStatus } from "@calcom/prisma/enums";
import {
bookingMetadataSchema,
eventTypeBookingFields,
EventTypeMetaDataSchema,
} from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -26,9 +34,14 @@ import {
SheetTitle,
} from "@calcom/ui/components/sheet";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
import { RejectBookingButton } from "../../../components/booking/RejectBookingButton";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import type { BookingListingStatus } from "../../../components/booking/types";
import { usePaymentStatus } from "../hooks/usePaymentStatus";
import {
useBookingDetailsSheetStore,
useBookingDetailsSheetStoreApi,
@@ -86,35 +99,52 @@ function BookingDetailsSheetInner({
}: BookingDetailsSheetInnerProps) {
const { t } = useLocale();
// Fetch additional booking details for reschedule information
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
{ uid: booking.uid },
{
enabled: Boolean(booking.rescheduled || booking.fromReschedule),
// Keep data fresh but don't refetch too aggressively
staleTime: 5 * 60 * 1000, // 5 minutes
}
);
// Get navigation state directly from the store
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const handleClose = () => {
setSelectedBookingId(null);
setSelectedBookingUid(null);
};
const storeApi = useBookingDetailsSheetStoreApi();
const handleNext = () => {
const nextId = storeApi.getState().getNextBookingId();
if (nextId !== null) {
setSelectedBookingId(nextId);
const nextUid = storeApi.getState().getNextBookingUid();
if (nextUid !== null) {
setSelectedBookingUid(nextUid);
}
};
const handlePrevious = () => {
const prevId = storeApi.getState().getPreviousBookingId();
if (prevId !== null) {
setSelectedBookingId(prevId);
const prevUid = storeApi.getState().getPreviousBookingUid();
if (prevUid !== null) {
setSelectedBookingUid(prevUid);
}
};
const startTime = dayjs(booking.startTime).tz(userTimeZone);
const endTime = dayjs(booking.endTime).tz(userTimeZone);
const getStatusBadge = () => {
const statusBadge = useMemo(() => {
if (booking.rescheduled) {
return {
variant: "red" as const,
label: t("rescheduled"),
};
}
switch (booking.status) {
case "ACCEPTED":
return { variant: "green" as const, label: t("confirmed") };
@@ -127,9 +157,9 @@ function BookingDetailsSheetInner({
default:
return { variant: "gray" as const, label: booking.status };
}
};
}, [booking.status, booking.rescheduled, t]);
const statusBadge = getStatusBadge();
const isPending = booking.status === BookingStatus.PENDING;
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
@@ -148,16 +178,41 @@ function BookingDetailsSheetInner({
.map(([question, answer]) => [question, answer] as [string, unknown])
: [];
const reason = booking.assignmentReason?.[0];
const reasonTitle = reason && assignmentReasonBadgeTitleMap(reason.reasonEnum);
return (
<Sheet open={true} onOpenChange={handleClose}>
<SheetContent className="overflow-y-auto">
<SheetHeader
showCloseButton={false}
rightContent={
<Sheet open={true} onOpenChange={handleClose} modal={false}>
<SheetContent
className="overflow-y-auto"
hideOverlay
onInteractOutside={(e) => {
// Check if the click is on a booking list item
const target = e.target as HTMLElement;
const isBookingListItem = target.closest("[data-booking-list-item]");
if (isBookingListItem) {
// Prevent closing when clicking a booking list item
// The item's onClick will handle opening the sheet with the new booking
e.preventDefault();
}
// If clicking elsewhere, allow the default behavior (close the sheet)
}}>
<SheetHeader showCloseButton={false} className="mt-0 w-full">
<div className="flex items-center justify-between gap-x-4">
<div className="flex min-w-0 flex-col gap-y-1">
<BookingHeaderBadges
statusBadge={statusBadge}
reasonTitle={reasonTitle}
booking={booking}
recurringInfo={recurringInfo}
/>
</div>
<div className="flex gap-2">
<Button
variant="icon"
color="minimal"
size="sm"
color="secondary"
StartIcon="chevron-up"
disabled={!hasPrevious}
onClick={(e) => {
@@ -167,7 +222,8 @@ function BookingDetailsSheetInner({
/>
<Button
variant="icon"
color="minimal"
size="sm"
color="secondary"
StartIcon="chevron-down"
disabled={!hasNext}
onClick={(e) => {
@@ -175,33 +231,45 @@ function BookingDetailsSheetInner({
handleNext();
}}
/>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="x"
onClick={(e) => {
e.preventDefault();
handleClose();
}}
/>
</div>
}>
<div className="flex items-center gap-2">
<Badge variant={statusBadge.variant} className="capitalize">
{statusBadge.label}
</Badge>
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
{recurringInfo && (
<Badge variant="gray">
<Icon name="repeat" className="mr-1 h-3 w-3" />
{recurringInfo.count}
</Badge>
)}
</div>
</SheetHeader>
<SheetBody className="stack-y-6">
<div className="stack-y-6">
<div className="stack-y-1">
<SheetTitle className="text-2xl font-semibold">{booking.title}</SheetTitle>
<p className="text-subtle text-sm">
{startTime.format("dddd, MMMM D, YYYY h:mma")} - {endTime.format("h:mma")} (
{userTimeZone || startTime.format("Z")})
</p>
<SheetBody className="-mt-3">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<SheetTitle className="text-emphasis flex items-center gap-3 text-xl font-semibold">
<div className="bg-emphasis w-0.5 shrink-0 self-stretch rounded-lg"></div>
<span>{booking.title}</span>
</SheetTitle>
</div>
<RescheduleRequestMessage booking={booking} />
<WhenSection
rescheduled={booking.rescheduled || false}
startTime={startTime}
endTime={endTime}
timeZone={userTimeZone}
previousBooking={bookingDetails?.previousBooking}
/>
<OldRescheduledBookingInfo
booking={booking}
rescheduledToBooking={bookingDetails?.rescheduledToBooking}
/>
<NewRescheduledBookingInfo booking={booking} />
<CancelledBookingInfo booking={booking} />
<WhoSection booking={booking} />
@@ -209,49 +277,64 @@ function BookingDetailsSheetInner({
<RecurringInfoSection recurringInfo={recurringInfo} />
<PaymentSection booking={booking} />
<AssignmentReasonSection booking={booking} />
{booking.payment?.[0] && <PaymentSection booking={booking} payment={booking.payment[0]} />}
<SlotsSection booking={booking} />
<AdditionalNotesSection booking={booking} />
<CustomQuestionsSection
customResponses={customResponses}
bookingFields={booking.eventType?.bookingFields}
/>
<DescriptionSection booking={booking} />
</div>
</SheetBody>
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
<Button color="secondary" StartIcon="x" onClick={handleClose}>
{t("close")}
</Button>
<div className="flex w-full min-w-0 flex-row flex-wrap items-center justify-end gap-2 px-4 pb-4 pt-4">
{isPending ? (
<>
<RejectBookingButton
bookingId={booking.id}
bookingUid={booking.uid}
recurringEventId={booking.recurringEventId}
isRecurring={!!booking.recurringEventId}
/>
<AcceptBookingButton
bookingId={booking.id}
bookingUid={booking.uid}
recurringEventId={booking.recurringEventId}
isRecurring={!!booking.recurringEventId}
/>
</>
) : (
!booking.rescheduled && (
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
)
)}
<div className="flex gap-2">
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
userTimeZone,
userTimeFormat: userTimeFormat ?? null,
userEmail,
},
isToday: false,
}}
usePortal={false}
context="details"
/>
</div>
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
userTimeZone,
userTimeFormat: userTimeFormat ?? null,
userEmail,
},
isToday: false,
}}
usePortal={false}
context="details"
/>
</div>
</SheetFooter>
</SheetContent>
@@ -259,17 +342,62 @@ function BookingDetailsSheetInner({
);
}
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
function WhenSection({
rescheduled,
startTime,
endTime,
timeZone,
previousBooking,
}: {
rescheduled: boolean;
startTime: dayjs.Dayjs;
endTime: dayjs.Dayjs;
timeZone?: string;
previousBooking?: { uid: string; startTime: Date; endTime: Date } | null;
}) {
const { t } = useLocale();
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
return null;
}
return (
<Section title={t("when")}>
{previousBooking?.startTime && previousBooking?.endTime && (
<div className="text-default flex flex-col text-sm line-through opacity-60">
<DisplayTimestamp
startTime={previousBooking.startTime}
endTime={previousBooking.endTime}
timeZone={timeZone}
/>
</div>
)}
<div
className={classNames(
"text-emphasis flex flex-col text-sm font-medium",
rescheduled && "line-through"
)}>
<DisplayTimestamp startTime={startTime} endTime={endTime} timeZone={timeZone} />
</div>
</Section>
);
}
function DisplayTimestamp({
startTime,
endTime,
timeZone,
}: {
startTime: Date | dayjs.Dayjs;
endTime: Date | dayjs.Dayjs;
timeZone?: string;
}) {
const start = startTime instanceof Date ? dayjs(startTime).tz(timeZone) : startTime;
const end = endTime instanceof Date ? dayjs(endTime).tz(timeZone) : endTime;
return (
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
{t("reschedule_request_sent")}
</Badge>
<>
<span>{start.format("dddd, MMMM D, YYYY")}</span>
<span>
{start.format("h:mma")} - {end.format("h:mma")} ({timeZone || start.format("Z")})
</span>
</>
);
}
@@ -277,35 +405,52 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
return (
<Section title={t("who")}>
<div className="stack-y-4">
<div className="mt-2 flex flex-col gap-3">
{booking.user && (
<div className="flex items-center gap-4">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(null, booking.user.name)}
alt={booking.user.name || ""}
imageSrc={
booking.user.avatarUrl
? getUserAvatarUrl(booking.user)
: getPlaceholderAvatar(null, booking.user.name || booking.user.email)
}
alt={booking.user.name || booking.user.email || ""}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-emphasis truncate text-sm font-medium">{booking.user.name}</p>
<Badge variant="blue" size="sm">
<p className="text-emphasis truncate text-sm leading-[1.2]">
{booking.user.name || booking.user.email}
</p>
<Badge variant="purple" size="sm" className="capitalize">
{t("host")}
</Badge>
</div>
<p className="text-default truncate text-sm">{booking.user.email}</p>
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
</div>
</div>
)}
{booking.attendees.map((attendee, idx) => (
<div key={idx} className="flex items-center gap-4">
<Avatar size="md" imageSrc={getPlaceholderAvatar(null, attendee.name)} alt={attendee.name} />
<div className="min-w-0 flex-1">
<p className="text-emphasis truncate text-sm font-medium">{attendee.name}</p>
<p className="text-default truncate text-sm">{attendee.email}</p>
{booking.attendees.map((attendee, idx) => {
const name = attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
return (
<div key={idx} className="flex items-center gap-4">
<Avatar
size="md"
imageSrc={
attendee.user?.avatarUrl
? getUserAvatarUrl(attendee.user)
: getPlaceholderAvatar(null, name)
}
alt={name}
/>
<div className="min-w-0 flex-1">
<p className="text-emphasis truncate text-sm leading-[1.2]">{name}</p>
<p className="text-default truncate text-sm leading-[1.2]">{attendee.email}</p>
</div>
</div>
</div>
))}
);
})}
</div>
</Section>
);
@@ -321,6 +466,10 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
bookingStatus: booking.status,
});
if (booking.rescheduled) {
return null;
}
if (!locationToDisplay) {
return null;
}
@@ -345,7 +494,7 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
/>
)}
<div className="flex min-w-0 items-baseline gap-1">
<span className="text-default shrink-0">{provider?.label}:</span>
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
<a
href={locationToDisplay}
target="_blank"
@@ -372,7 +521,7 @@ function RecurringInfoSection({
return (
<Section title={t("recurring_event")}>
<p className="text-default text-sm">
<p className="text-emphasis text-sm font-medium">
{getEveryFreqFor({
t,
recurringEvent: recurringInfo.recurringEvent,
@@ -383,19 +532,60 @@ function RecurringInfoSection({
);
}
function PaymentSection({ booking }: { booking: BookingOutput }) {
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.paid || !Array.isArray(booking.payment) || booking.payment.length === 0) {
if (!booking.assignmentReason || booking.assignmentReason.length === 0) {
return null;
}
const payment = booking.payment[0];
// we fetch only one assignment reason.
const reason = booking.assignmentReason[0];
if (!reason.reasonString) {
return null;
}
return (
<Section title={t("assignment_reason")}>
<div className="text-emphasis text-sm font-medium">{reason.reasonString}</div>
</Section>
);
}
function PaymentSection({
booking,
payment,
}: {
booking: BookingOutput;
payment: NonNullable<BookingOutput["payment"]>[number];
}) {
const { t } = useLocale();
// Get refund policy from event type metadata
const parsedEventTypeMetadata = booking.eventType?.metadata
? EventTypeMetaDataSchema.safeParse(booking.eventType.metadata)
: null;
const eventTypeMetadata = parsedEventTypeMetadata?.success ? parsedEventTypeMetadata.data : null;
const refundPolicy = eventTypeMetadata?.apps?.stripe?.refundPolicy;
const refundDaysCount = eventTypeMetadata?.apps?.stripe?.refundDaysCount;
const paymentStatusMessage = usePaymentStatus({
bookingStatus: booking.status,
startTime: booking.startTime,
eventTypeTeamId: booking.eventType?.teamId,
userId: booking.user?.id,
payment,
refundPolicy,
refundDaysCount,
});
const formattedPrice = formatPrice(payment.amount, payment.currency);
return (
<Section title={t("payment")}>
<p className="text-default text-sm">{formattedPrice}</p>
<p className="text-emphasis text-sm font-medium">{formattedPrice}</p>
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
</Section>
);
}
@@ -416,7 +606,7 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
return (
<Section title={t("slots")}>
<p className="text-default text-sm">{t("slots_taken", { takenSeats, totalSeats })}</p>
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
</Section>
);
}
@@ -470,7 +660,7 @@ function CustomQuestionsSection({
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
return (
<Section key={idx} title={title}>
<p className="text-default text-sm">{String(answer)}</p>
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
</Section>
);
})}
@@ -478,7 +668,124 @@ function CustomQuestionsSection({
);
}
function DescriptionSection({ booking }: { booking: BookingOutput }) {
// Old booking that was rescheduled away
function OldRescheduledBookingInfo({
booking,
rescheduledToBooking,
}: {
booking: BookingOutput;
rescheduledToBooking?: { uid: string } | null;
}) {
const { t } = useLocale();
// Only show for old rescheduled bookings
if (!booking.rescheduled) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const rescheduledBy = booking.rescheduledBy;
const cancelledBy = booking.cancelledBy;
return (
<>
{rescheduledToBooking?.uid && (
<Section title={t("rescheduled")}>
<Link href={`/booking/${rescheduledToBooking.uid}`}>
<div className="text-default flex items-center gap-1 text-sm underline">
{t("view_booking")}
<Icon name="external-link" className="h-4 w-4" />
</div>
</Link>
</Section>
)}
{rescheduledBy && (
<Section title={t("rescheduled_by")}>
<p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>
</Section>
)}
{cancellationReason && (
<Section title={t("reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
</Section>
)}
{cancelledBy && (
<Section title={t("cancelled_by")}>
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
</Section>
)}
</>
);
}
// New booking that replaced an old one
function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
// Only show for new bookings that replaced an old one
if (!booking.fromReschedule) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const rescheduledBy = booking.rescheduler;
return (
<>
<Section title={t("rescheduled_by")}>
{rescheduledBy && <p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>}
<Link href={`/booking/${booking.fromReschedule}`}>
<div className="text-default flex items-center gap-1 text-sm underline">
{t("original_booking")}
<Icon name="external-link" className="h-4 w-4" />
</div>
</Link>
</Section>
{cancellationReason && (
<Section title={t("reschedule_reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
</Section>
)}
</>
);
}
// Purely cancelled or rejected (not rescheduled)
function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
// Only show for cancelled/rejected bookings that were NOT rescheduled
const isCancelled = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
const wasRescheduled = booking.rescheduled === true;
if (!isCancelled || wasRescheduled) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const cancelledBy = booking.cancelledBy;
if (!cancellationReason && !cancelledBy) {
return null;
}
return (
<>
{cancelledBy && (
<Section title={t("cancelled_by")}>
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
</Section>
)}
{cancellationReason && (
<Section title={t("reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
</Section>
)}
</>
);
}
function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.description) {
@@ -486,12 +793,54 @@ function DescriptionSection({ booking }: { booking: BookingOutput }) {
}
return (
<Section title={t("description")}>
<p className="text-default whitespace-pre-wrap text-sm">{booking.description}</p>
<Section title={t("additional_notes")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{booking.description}</p>
</Section>
);
}
function BookingHeaderBadges({
statusBadge,
reasonTitle,
booking,
recurringInfo,
}: {
statusBadge: { variant: "red" | "green" | "orange" | "gray"; label: string };
reasonTitle: string | undefined;
booking: BookingOutput;
recurringInfo: { count: number; recurringEvent: RecurringEvent } | null;
}) {
const { t } = useLocale();
const payment = booking.payment?.[0];
return (
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant={statusBadge.variant} className="capitalize">
{statusBadge.label}
</Badge>
{reasonTitle && (
<Badge variant="gray" className="capitalize">
{reasonTitle}
</Badge>
)}
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
{booking.paid && !payment ? (
<Badge variant="orange">{t("error_collecting_card")}</Badge>
) : booking.paid ? (
<Badge variant="green" data-testid="paid_badge">
{payment?.paymentOption === "HOLD" ? t("card_held") : t("paid")}
</Badge>
) : null}
{recurringInfo && (
<Badge variant="gray">
<Icon name="repeat" className="mr-1 h-3 w-3" />
{recurringInfo.count}
</Badge>
)}
</div>
);
}
function Section({
title,
className,
@@ -502,8 +851,8 @@ function Section({
children: React.ReactNode;
}) {
return (
<div className={classNames("stack-y-1", className)}>
<h3 className="text-subtle text-xs font-semibold">{title}</h3>
<div className={classNames("flex flex-col gap-1", className)}>
<h3 className="text-subtle text-xs font-medium">{title}</h3>
{children}
</div>
);
@@ -2,7 +2,7 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
import { DataTableWrapper } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
@@ -18,7 +18,7 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
unconfirmed: "unconfirmed_bookings",
};
type BookingsListViewProps = {
type BookingListViewProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
isPending: boolean;
@@ -27,14 +27,14 @@ type BookingsListViewProps = {
hasError?: boolean;
};
export function BookingsList({
export function BookingList({
status,
table,
isPending,
totalRowCount,
ErrorView,
hasError,
}: BookingsListViewProps) {
}: BookingListViewProps) {
const { t } = useLocale();
return (
@@ -48,18 +48,7 @@ export function BookingsList({
totalRowCount={totalRowCount}
variant="compact"
paginationMode="standard"
ToolbarLeft={
<>
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
separatorClassName="py-4 pl-6 text-xs uppercase leading-4"
LoaderView={<SkeletonLoader />}
EmptyView={
<div className="flex items-center justify-center pt-2 xl:pt-0">
@@ -0,0 +1,266 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import {
useDataTable,
DataTableFilters,
DataTableSegment,
useDisplayedFilterCount,
} from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Alert } from "@calcom/ui/components/alert";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ToggleGroup } from "@calcom/ui/components/form";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import {
BookingDetailsSheetStoreProvider,
useBookingDetailsSheetStore,
} from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingList } from "./BookingList";
import { ViewToggleButton } from "./ViewToggleButton";
interface FilterButtonProps {
table: ReturnType<typeof useReactTable<RowData>>;
displayedFilterCount: number;
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
}
function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
const { t } = useLocale();
if (displayedFilterCount === 0) {
return <DataTableFilters.AddFilterButton table={table} />;
}
return (
<Button
color="secondary"
StartIcon="list-filter"
className="h-full"
size="sm"
onClick={() => setShowFilters((value) => !value)}>
{t("filter")}
<Badge variant="gray" className="ml-1">
{displayedFilterCount}
</Badge>
</Button>
);
}
interface BookingListContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
bookingsV3Enabled: boolean;
}
interface BookingListInnerProps extends BookingListContainerProps {
data?: BookingsGetOutput;
isPending: boolean;
hasError: boolean;
errorMessage?: string;
totalRowCount?: number;
}
function BookingListInner({
status,
permissions,
bookingsV3Enabled,
data,
isPending,
hasError,
errorMessage,
totalRowCount,
}: BookingListInnerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const router = useRouter();
const [showFilters, setShowFilters] = useState(true);
const ErrorView = errorMessage ? (
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
) : undefined;
const handleBookingClick = useCallback(
(bookingUid: string) => {
setSelectedBookingUid(bookingUid);
},
[setSelectedBookingUid]
);
const columns = useBookingListColumns({
user,
status,
canReadOthersBookings: permissions.canReadOthersBookings,
bookingsV3Enabled,
handleBookingClick,
});
const finalData = useBookingListData({ data, status, userTimeZone: user?.timeZone });
const getFacetedUniqueValues = useFacetedUniqueValues();
const displayedFilterCount = useDisplayedFilterCount();
const { currentTab, tabOptions } = useBookingStatusTab();
useEffect(() => {
if (displayedFilterCount === 0) {
// reset to true, so it shows filters as soon as any filter is applied
setShowFilters(true);
}
}, [displayedFilterCount]);
const table = useReactTable<RowData>({
data: finalData,
columns,
initialState: {
columnVisibility: {
eventTypeId: false,
teamId: false,
userId: false,
attendeeName: false,
attendeeEmail: false,
dateRange: false,
bookingUid: false,
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
const isEmpty = !data?.bookings || data.bookings.length === 0;
return (
<>
<div className="flex flex-wrap items-center gap-2">
{/* Desktop: full width on first row, Mobile: full width on first row with horizontal scroll */}
<div className="w-full md:w-auto">
<div className="overflow-x-auto md:overflow-visible">
<ToggleGroup
value={currentTab}
onValueChange={(value) => {
if (!value) return;
const selectedTab = tabOptions.find((tab) => tab.value === value);
if (selectedTab?.href) {
router.push(selectedTab.href);
}
}}
options={tabOptions}
/>
</div>
</div>
{/* Desktop: second item on first row, Mobile: first item on second row */}
<FilterButton
table={table}
displayedFilterCount={displayedFilterCount}
setShowFilters={setShowFilters}
/>
{/* Desktop: auto-pushed to right via flex-grow spacer, Mobile: continue on second row */}
<div className="hidden grow md:block" />
<DataTableSegment.Select shortLabel />
{bookingsV3Enabled && <ViewToggleButton />}
</div>
{displayedFilterCount > 0 && showFilters && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.AddFilterButton table={table} variant="minimal" />
{/* Desktop: auto-pushed to right via flex-grow spacer */}
<div className="hidden flex-grow md:block" />
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
</div>
)}
{status === "upcoming" && !isEmpty && (
<WipeMyCalActionButton className="mt-4" bookingStatus={status} bookingsEmpty={isEmpty} />
)}
<div className="mt-4">
<BookingList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
</div>
{bookingsV3Enabled && (
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
)}
</>
);
}
export function BookingListContainer(props: BookingListContainerProps) {
const { limit, offset } = useDataTable();
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
useBookingFilters();
const query = trpc.viewer.bookings.get.useQuery(
{
limit,
offset,
filters: {
statuses: [props.status],
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
},
{
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
}
);
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
return (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingListInner
{...props}
data={query.data}
isPending={query.isPending}
hasError={!!query.error}
errorMessage={query.error?.message}
totalRowCount={query.data?.totalCount}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -1,89 +0,0 @@
"use client";
import type { Table as ReactTable } from "@tanstack/react-table";
import { useCallback } from "react";
import dayjs from "@calcom/dayjs";
import {
DataTableFilters,
DataTableSegment,
useDataTable,
ColumnFilterType,
} from "@calcom/features/data-table";
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
import type { RowData, BookingListingStatus, BookingOutput } from "../types";
import { BookingsCalendarView } from "./BookingsCalendarView";
type BookingsCalendarProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
isPending?: boolean;
currentWeekStart: dayjs.Dayjs;
setCurrentWeekStart: (
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
) => Promise<URLSearchParams>;
bookings: BookingOutput[];
ErrorView?: React.ReactNode;
hasError?: boolean;
};
const COLUMN_IDS_TO_HIDE = ["dateRange"];
export function BookingsCalendar({
table,
isPending = false,
currentWeekStart,
setCurrentWeekStart,
bookings,
ErrorView,
hasError,
}: BookingsCalendarProps) {
const { updateFilter } = useDataTable();
const handleWeekStartChange = useCallback(
(newWeekStart: dayjs.Dayjs) => {
setCurrentWeekStart(newWeekStart);
// Always set the date range to match the current week exactly
const startDate = newWeekStart.toDate();
const endDate = newWeekStart.add(6, "day").toDate();
updateFilter("dateRange", {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
preset: CUSTOM_PRESET.value,
},
});
},
[updateFilter, setCurrentWeekStart]
);
return (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<DataTableFilters.FilterBar table={table} columnIdsToHide={COLUMN_IDS_TO_HIDE} />
</div>
<div className="flex items-center gap-2">
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</div>
</div>
{hasError && ErrorView ? (
ErrorView
) : (
<BookingsCalendarView
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
isPending={isPending}
/>
)}
</>
);
}
@@ -1,162 +0,0 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsCalendar } from "./BookingsCalendar";
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
interface BookingsCalendarContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data?: BookingsGetOutput;
isPending?: boolean;
ErrorView?: React.ReactNode;
hasError?: boolean;
}
export function BookingsCalendarContainer({
status,
permissions,
data,
isPending = false,
ErrorView,
hasError,
}: BookingsCalendarContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const columns = useMemo(() => {
return buildFilterColumns({ t, permissions, status });
}, [t, permissions, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
/**
* Transform raw booking data into RowData format for the calendar view
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - Attaches recurring info and isToday flag to each booking
*/
const rowData = useMemo<RowData[]>(() => {
if (!data?.bookings) {
return [];
}
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
// Key: recurringEventId, Value: array of all bookings in that series
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
// Deduplicate recurring bookings for specific status tabs
// This ensures we show only ONE booking per recurring series instead of all occurrences
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
// Non-recurring bookings are always shown
if (!booking.recurringEventId) {
return true;
}
// If we've already encountered this recurring series
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
// Store this occurrence but DON'T display it (return false to filter out)
shownBookings[booking.recurringEventId].push(booking);
return false;
}
// First occurrence of this recurring series - show it and start tracking
shownBookings[booking.recurringEventId] = [booking];
}
return true;
};
return data.bookings.filter(filterBookings).map((booking) => {
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
const today = dayjs().tz(user?.timeZone).format("YYYY-MM-DD");
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
return {
type: "data" as const,
booking,
isToday: bookingDateStr === today,
recurringInfo: data.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
};
});
}, [data, status, user?.timeZone]);
const table = useReactTable<RowData>({
data: rowData,
columns,
initialState: {
columnVisibility: getFilterColumnVisibility(),
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
// Extract bookings from table data and filter by current week
const bookings = useMemo(() => {
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
return rowData
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking)
.filter((booking) => {
const bookingStart = dayjs(booking.startTime);
return (
(bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) &&
bookingStart.isBefore(weekEnd.endOf("day"))
);
});
}, [rowData, currentWeekStart]);
return (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingsCalendar
status={status}
table={table}
isPending={isPending}
currentWeekStart={currentWeekStart}
setCurrentWeekStart={setCurrentWeekStart}
bookings={bookings}
ErrorView={ErrorView}
hasError={hasError}
/>
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -1,346 +0,0 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import { useMemo, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import { ColumnFilterType } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import BookingListItem from "@calcom/web/components/booking/BookingListItem";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import {
BookingDetailsSheetStoreProvider,
useBookingDetailsSheetStore,
} from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsList } from "./BookingsList";
interface BookingsListContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data?: BookingsGetOutput;
isPending: boolean;
enableDetailsSheet: boolean;
totalRowCount?: number;
ErrorView?: React.ReactNode;
hasError?: boolean;
}
function BookingsListInner({
status,
permissions,
data,
isPending,
enableDetailsSheet,
totalRowCount,
ErrorView,
hasError,
}: BookingsListContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const handleBookingClick = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
// Define table columns for filtering (hidden columns used for filter UI)
const columns = useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
id: "eventTypeId",
header: t("event_type"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
id: "teamId",
header: t("team"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
columnHelper.display({
id: "customView",
cell: (props) => {
if (props.row.original.type === "data") {
const { booking, recurringInfo, isToday } = props.row.original;
return (
<BookingListItem
key={booking.id}
isToday={isToday}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...(enableDetailsSheet && { onClick: () => handleBookingClick(booking.id) })}
{...booking}
/>
);
} else if (props.row.original.type === "today") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("today")}
</p>
);
} else if (props.row.original.type === "next") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("next")}
</p>
);
}
},
}),
];
}, [user, status, t, permissions.canReadOthersBookings, enableDetailsSheet, handleBookingClick]);
/**
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - For "upcoming" status, filters out today's bookings (they're shown in separate "Today" section)
*/
const flatData = useMemo<RowData[]>(() => {
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
// Key: recurringEventId, Value: array of all bookings in that series
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
// Deduplicate recurring bookings for specific status tabs
// This ensures we show only ONE booking per recurring series instead of all occurrences
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
// Non-recurring bookings are always shown
if (!booking.recurringEventId) {
return true;
}
// If we've already encountered this recurring series
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
// Store this occurrence but DON'T display it (return false to filter out)
shownBookings[booking.recurringEventId].push(booking);
return false;
}
// First occurrence of this recurring series - show it and start tracking
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
// For "upcoming" tab, exclude today's bookings (they're shown separately in the "Today" section)
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
}
return true;
};
return (
data?.bookings.filter(filterBookings).map((booking) => ({
type: "data",
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
isToday: false,
})) || []
);
}, [data, status, user?.timeZone]);
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
const bookingsToday = useMemo<RowData[]>(() => {
return (data?.bookings ?? [])
.filter(
(booking: BookingOutput) =>
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
)
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
isToday: true,
}));
}, [data, user?.timeZone]);
// Combine data with section separators for "upcoming" tab
const finalData = useMemo<RowData[]>(() => {
// For other statuses, just return the flat list
if (status !== "upcoming") {
return flatData;
}
// For "upcoming" status, organize into "Today" and "Next" sections
const merged: RowData[] = [];
if (bookingsToday.length > 0) {
merged.push({ type: "today" as const }, ...bookingsToday);
}
if (flatData.length > 0) {
merged.push({ type: "next" as const }, ...flatData);
}
return merged;
}, [bookingsToday, flatData, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data: finalData,
columns,
initialState: {
columnVisibility: {
eventTypeId: false,
teamId: false,
userId: false,
attendeeName: false,
attendeeEmail: false,
dateRange: false,
bookingUid: false,
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
return (
<>
<BookingsList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
{enableDetailsSheet && (
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
)}
</>
);
}
export function BookingsListContainer({
status,
permissions,
data,
isPending,
enableDetailsSheet,
totalRowCount,
ErrorView,
hasError,
}: BookingsListContainerProps) {
// Extract bookings from data for BookingDetailsSheet
const bookings = useMemo(() => data?.bookings ?? [], [data]);
return (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingsListInner
status={status}
permissions={permissions}
data={data}
isPending={isPending}
enableDetailsSheet={enableDetailsSheet}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -54,11 +54,7 @@ export function JoinMeetingButton({
onClick={handleClick}>
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
className="h-4 w-4 flex-shrink-0 rounded-sm"
alt={`${provider.label} logo`}
/>
<img src={provider.iconUrl} className="h-4 w-4 shrink-0 rounded-sm" alt={`${provider.label} logo`} />
)}
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
</Button>
@@ -3,13 +3,12 @@
import { useQueryState } from "nuqs";
import { useEffect } from "react";
import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { viewParser, type BookingView } from "~/bookings/lib/viewParser";
import { viewParser, type BookingView } from "../lib/viewParser";
export function ViewToggleButton() {
const { t } = useLocale();
@@ -17,17 +16,18 @@ export function ViewToggleButton() {
"view",
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
);
const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const isMobile = !useMediaQuery("(min-width: 640px)");
const isMobile = useMediaQuery("(max-width: 768px)");
useEffect(() => {
// Force list view on mobile
if (isMobile && view === "calendar") {
// Clear dateRange filter when forcing calendar to list view, same as manual toggle
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
setView("list");
}
}, [isMobile, view, setView, setActiveFilters]);
}, [isMobile, view, setView]);
if (isMobile) {
return null;
}
return (
<div className="hidden sm:block">
@@ -35,14 +35,7 @@ export function ViewToggleButton() {
value={view}
onValueChange={(value) => {
if (!value) return;
const newView = value as BookingView;
// When switching from calendar to list view, remove the dateRange filter
if (view === "calendar" && newView === "list") {
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
}
setView(newView);
setView(value as BookingView);
}}
options={[
{
@@ -0,0 +1,39 @@
import { useState, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { DatePicker } from "@calcom/ui/components/form";
import { getWeekStart } from "../lib/weekUtils";
interface WeekPickerProps {
currentWeekStart: dayjs.Dayjs;
userWeekStart: number;
onDateChange: (date: dayjs.Dayjs) => void;
}
export function WeekPicker({ currentWeekStart, userWeekStart, onDateChange }: WeekPickerProps) {
const [selectedDate, setSelectedDate] = useState<Date>(currentWeekStart.toDate());
useEffect(() => {
setSelectedDate(currentWeekStart.toDate());
}, [currentWeekStart]);
const handleDateChange = (date: Date) => {
setSelectedDate(date);
const selectedDayjs = dayjs(date);
const newWeekStart = getWeekStart(selectedDayjs, userWeekStart);
onDateChange(newWeekStart);
};
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
const startMonth = weekStart.format("MMM");
const endMonth = weekEnd.format("MMM");
const year = weekEnd.format("YYYY");
const weekRange =
startMonth === endMonth
? `${startMonth} ${weekStart.format("D")} - ${weekEnd.format("D")}, ${year}`
: `${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}, ${year}`;
return <DatePicker date={selectedDate} onDatesChange={handleDateChange} minDate={null} label={weekRange} />;
}
@@ -0,0 +1,74 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { BookingListingStatus, BookingOutput, BookingsGetOutput, RowData } from "../types";
interface UseBookingCalendarDataParams {
data?: {
bookings: BookingsGetOutput["bookings"];
recurringInfo: BookingsGetOutput["recurringInfo"];
totalCount: BookingsGetOutput["totalCount"];
};
status: BookingListingStatus;
}
/**
* Custom hook to transform raw booking data into RowData format for the calendar view
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - Attaches recurring info and isToday flag to each booking
*/
export function useBookingCalendarData({ data, status }: UseBookingCalendarDataParams) {
const user = useMeQuery().data;
const rowData = useMemo<RowData[]>(() => {
if (!data?.bookings) {
return [];
}
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
// Key: recurringEventId, Value: array of all bookings in that series
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
// Deduplicate recurring bookings for specific status tabs
// This ensures we show only ONE booking per recurring series instead of all occurrences
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
// Non-recurring bookings are always shown
if (!booking.recurringEventId) {
return true;
}
// If we've already encountered this recurring series
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
// Store this occurrence but DON'T display it (return false to filter out)
shownBookings[booking.recurringEventId].push(booking);
return false;
}
// First occurrence of this recurring series - show it and start tracking
shownBookings[booking.recurringEventId] = [booking];
}
return true;
};
return data.bookings.filter(filterBookings).map((booking) => {
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
const today = dayjs().tz(user?.timeZone).format("YYYY-MM-DD");
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
return {
type: "data" as const,
booking,
isToday: bookingDateStr === today,
recurringInfo: data.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
};
});
}, [data, status, user?.timeZone]);
return rowData;
}
@@ -4,29 +4,29 @@ import type { BookingOutput } from "../types";
export function useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
selectedBookingUid,
setSelectedBookingUid,
}: {
bookings: BookingOutput[];
selectedBookingId: number | null;
setSelectedBookingId: (bookingId: number | null) => void;
selectedBookingUid: string | null;
setSelectedBookingUid: (bookingUid: string | null) => void;
}) {
const currentIndex = useMemo(
() => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId),
[bookings, selectedBookingId]
() => bookings.findIndex((booking) => selectedBookingUid && booking.uid === selectedBookingUid),
[bookings, selectedBookingUid]
);
const onPrevious = useCallback(() => {
if (currentIndex >= 1) {
setSelectedBookingId(bookings[currentIndex - 1].id);
setSelectedBookingUid(bookings[currentIndex - 1].uid);
}
}, [bookings, currentIndex, setSelectedBookingId]);
}, [bookings, currentIndex, setSelectedBookingUid]);
const onNext = useCallback(() => {
if (currentIndex >= 0 && currentIndex < bookings.length - 1) {
setSelectedBookingId(bookings[currentIndex + 1].id);
setSelectedBookingUid(bookings[currentIndex + 1].uid);
}
}, [bookings, currentIndex, setSelectedBookingId]);
}, [bookings, currentIndex, setSelectedBookingUid]);
return {
onPrevious,
@@ -0,0 +1,26 @@
import {
useFilterValue,
ZMultiSelectFilterValue,
ZDateRangeFilterValue,
ZTextFilterValue,
} from "@calcom/features/data-table";
export function useBookingFilters() {
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const userIds = useFilterValue("userId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
return {
eventTypeIds,
teamIds,
userIds,
dateRange,
attendeeName,
attendeeEmail,
bookingUid,
};
}
@@ -0,0 +1,152 @@
import { createColumnHelper } from "@tanstack/react-table";
import { useMemo } from "react";
import { ColumnFilterType } from "@calcom/features/data-table";
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import BookingListItem from "@calcom/web/components/booking/BookingListItem";
import type { RowData, BookingListingStatus } from "../types";
export function useBookingListColumns({
user,
status,
canReadOthersBookings,
bookingsV3Enabled,
handleBookingClick,
}: {
user: ReturnType<typeof useMeQuery>["data"];
status: BookingListingStatus;
canReadOthersBookings: boolean;
bookingsV3Enabled: boolean;
handleBookingClick: (bookingUid: string) => void;
}) {
const { t } = useLocale();
return useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.accessor((row) => !isSeparatorRow(row) && row.booking.eventType.id, {
id: "eventTypeId",
header: t("event_type"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => !isSeparatorRow(row) && row.booking.eventType.team?.id, {
id: "teamId",
header: t("team"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => !isSeparatorRow(row) && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.accessor((row) => !isSeparatorRow(row) && row.booking.uid, {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
columnHelper.display({
id: "customView",
cell: (props) => {
const row = props.row.original;
// Separator rows are handled automatically by DataTable
if (isSeparatorRow(row)) {
return null;
}
const { booking, recurringInfo, isToday } = row;
return (
<BookingListItem
key={booking.id}
isToday={isToday}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...(bookingsV3Enabled && { onClick: () => handleBookingClick(booking.uid) })}
{...booking}
/>
);
},
}),
];
}, [user, status, t, canReadOthersBookings, bookingsV3Enabled, handleBookingClick]);
}
@@ -0,0 +1,115 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type {
RowData,
BookingRowData,
BookingListingStatus,
BookingOutput,
BookingsGetOutput,
} from "../types";
/**
* Transform raw bookings into final data structure with separators
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - For "upcoming" status, organizes into "Today" and "Next" sections
*/
export function useBookingListData({
data,
status,
userTimeZone,
}: {
data?: BookingsGetOutput;
status: BookingListingStatus;
userTimeZone?: string;
}) {
const { t } = useLocale();
/**
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - For "upcoming" status, filters out today's bookings (they're shown in separate "Today" section)
*/
const flatData = useMemo<BookingRowData[]>(() => {
const todayDateString = dayjs().tz(userTimeZone).format("YYYY-MM-DD");
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
// Key: recurringEventId, Value: array of all bookings in that series
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
// Deduplicate recurring bookings for specific status tabs
// This ensures we show only ONE booking per recurring series instead of all occurrences
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
// Non-recurring bookings are always shown
if (!booking.recurringEventId) {
return true;
}
// If we've already encountered this recurring series
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
// Store this occurrence but DON'T display it (return false to filter out)
shownBookings[booking.recurringEventId].push(booking);
return false;
}
// First occurrence of this recurring series - show it and start tracking
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
// For "upcoming" tab, exclude today's bookings (they're shown separately in the "Today" section)
return dayjs(booking.startTime).tz(userTimeZone).format("YYYY-MM-DD") !== todayDateString;
}
return true;
};
return (
data?.bookings.filter(filterBookings).map((booking) => ({
type: "data" as const,
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
isToday: false,
})) || []
);
}, [data, status, userTimeZone]);
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
const bookingsToday = useMemo<BookingRowData[]>(() => {
const todayDateString = dayjs().tz(userTimeZone).format("YYYY-MM-DD");
return (data?.bookings ?? [])
.filter(
(booking: BookingOutput) =>
dayjs(booking.startTime).tz(userTimeZone).format("YYYY-MM-DD") === todayDateString
)
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
isToday: true,
}));
}, [data, userTimeZone]);
// Combine data with section separators for "upcoming" tab
const finalData = useMemo<RowData[]>(() => {
// For other statuses, just return the flat list
if (status !== "upcoming") {
return flatData;
}
// For "upcoming" status, organize into "Today" and "Next" sections
const merged: RowData[] = [];
if (bookingsToday.length > 0) {
merged.push({ type: "separator" as const, label: t("today") }, ...bookingsToday);
}
if (flatData.length > 0) {
merged.push({ type: "separator" as const, label: t("next") }, ...flatData);
}
return merged;
}, [bookingsToday, flatData, status, t]);
return finalData;
}
@@ -0,0 +1,64 @@
import { useSearchParams, usePathname } from "next/navigation";
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export function useBookingStatusTab() {
const { t } = useLocale();
const searchParams = useSearchParams();
const pathname = usePathname();
const tabOptions = useMemo(() => {
const queryString = searchParams?.toString() || "";
const baseTabConfigs = [
{
value: "upcoming",
label: "upcoming",
path: "/bookings/upcoming",
dataTestId: "upcoming",
},
{
value: "unconfirmed",
label: "unconfirmed",
path: "/bookings/unconfirmed",
dataTestId: "unconfirmed",
},
{
value: "recurring",
label: "recurring",
path: "/bookings/recurring",
dataTestId: "recurring",
},
{
value: "past",
label: "past",
path: "/bookings/past",
dataTestId: "past",
},
{
value: "cancelled",
label: "cancelled",
path: "/bookings/cancelled",
dataTestId: "cancelled",
},
];
return baseTabConfigs.map((tabConfig) => ({
value: tabConfig.value,
label: t(tabConfig.label),
dataTestId: tabConfig.dataTestId,
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
}));
}, [searchParams, t]);
const currentTab = useMemo(() => {
const pathMatch = pathname?.match(/\/bookings\/(\w+)/);
return pathMatch?.[1] || "upcoming";
}, [pathname]);
return {
currentTab,
tabOptions,
};
}
@@ -0,0 +1,35 @@
import { useMemo, useEffect, useRef } from "react";
import { ColumnFilterType, useDataTable } from "@calcom/features/data-table";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
/**
* Custom hook to manage allowed filters for calendar view
* - Users without permission to read others' bookings: no filters
* - Users with permission to read others' bookings: userId filter with their own ID as default
*/
export function useCalendarAllowedFilters({ canReadOthersBookings }: { canReadOthersBookings: boolean }) {
const { data: user } = useMeQuery();
const { updateFilter, clearAll } = useDataTable();
const allowedFilterIds = useMemo(() => (canReadOthersBookings ? ["userId"] : []), [canReadOthersBookings]);
const hasInitialized = useRef(false);
useEffect(() => {
if (!user || hasInitialized.current) return;
hasInitialized.current = true;
// Clear all filters first (in case coming from list view with different filters)
clearAll();
// Then set the appropriate filter for users with permission
if (canReadOthersBookings) {
updateFilter("userId", {
type: ColumnFilterType.MULTI_SELECT,
data: [user.id],
});
}
}, [user, canReadOthersBookings, updateFilter, clearAll]);
return allowedFilterIds;
}
@@ -0,0 +1,42 @@
import { createParser, useQueryState } from "nuqs";
import dayjs from "@calcom/dayjs";
import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { getWeekStart } from "../lib/weekUtils";
/**
* Parser for the weekStart query parameter
* This parser simply parses the date from the URL and ensures it's at the start of the day.
* The week start logic based on user preference is applied when determining the default value.
*/
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("day") : dayjs().startOf("day");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
/**
* Custom hook to manage the current week start based on user preferences
* @returns Object containing currentWeekStart state and userWeekStart preference
*/
export function useCurrentWeekStart() {
const user = useMeQuery().data;
// Get the user's preferred week start day (0-6, where 0 = Sunday)
const userWeekStart = weekdayToWeekIndex(user?.weekStart);
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(getWeekStart(dayjs(), userWeekStart))
);
return {
currentWeekStart,
setCurrentWeekStart,
userWeekStart,
};
}
@@ -0,0 +1,89 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RefundPolicy } from "@calcom/lib/payment/types";
import { BookingStatus } from "@calcom/prisma/enums";
interface Payment {
success: boolean;
refunded: boolean;
paymentOption?: string | null;
}
interface UsePaymentStatusParams {
bookingStatus: BookingStatus;
startTime: Date | string;
eventTypeTeamId?: number | null;
userId?: number | null;
payment: Payment;
refundPolicy?: string | null;
refundDaysCount?: number | null;
}
/**
* Hook to determine the appropriate payment status message for a booking
* based on payment status and refund policy.
*/
export function usePaymentStatus({
bookingStatus,
startTime,
eventTypeTeamId,
userId,
payment,
refundPolicy,
refundDaysCount,
}: UsePaymentStatusParams) {
const { t } = useLocale();
const message = useMemo(() => {
const isCancelled = bookingStatus === BookingStatus.CANCELLED || bookingStatus === BookingStatus.REJECTED;
if (!isCancelled) {
return null;
}
// Payment not completed and not refunded
if (!payment.success && !payment.refunded) {
return t("booking_with_payment_cancelled");
}
// Payment completed but not refunded
if (payment.success && !payment.refunded) {
// Handle missing team or event type owner (same in processPaymentRefund.ts)
if (!eventTypeTeamId && !userId) {
return t("booking_with_payment_cancelled_no_refund");
}
// Handle DAYS policy with expired refund window
if (refundPolicy === RefundPolicy.DAYS && refundDaysCount) {
const bookingStartTime = new Date(startTime);
const cancelTime = new Date();
const daysDiff = Math.floor(
(cancelTime.getTime() - bookingStartTime.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysDiff > refundDaysCount) {
return t("booking_with_payment_cancelled_refund_window_expired");
}
return t("booking_with_payment_cancelled_already_paid");
}
// Handle NEVER policy
if (refundPolicy === RefundPolicy.NEVER) {
return t("booking_with_payment_cancelled_no_refund");
}
// Handle ALWAYS policy or default
return t("booking_with_payment_cancelled_already_paid");
}
// Payment refunded
if (payment.refunded) {
return t("booking_with_payment_cancelled_refunded");
}
return null;
}, [bookingStatus, startTime, eventTypeTeamId, userId, payment, refundPolicy, refundDaysCount, t]);
return message;
}
@@ -0,0 +1,10 @@
import { useQueryState } from "nuqs";
export function useSelectedBookingUid() {
return useQueryState("uid", {
defaultValue: null,
parse: (value) => (value ? value : null),
serialize: (value) => (value ? String(value) : ""),
clearOnDefault: true,
});
}
@@ -1 +1,3 @@
export const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
export type BookingListingStatus = (typeof validStatuses)[number];
@@ -0,0 +1,19 @@
import type { Dayjs } from "@calcom/dayjs";
/**
* Gets the start of the week for a given date based on the user's preferred week start day.
* This is useful when dayjs's built-in startOf('week') doesn't respect user preferences.
*
* @param date - The date to get the week start for
* @param weekStart - The day the week starts on (0 = Sunday, 1 = Monday, etc.)
* @returns The start of the week as a dayjs object
*
* @example
* // If today is Thursday (day 4) and user prefers Monday start (1):
* const weekStart = getWeekStart(dayjs(), 1); // Returns the Monday of current week
*/
export const getWeekStart = (date: Dayjs, weekStart: number = 0): Dayjs => {
const currentDay = date.day();
const diff = (currentDay - weekStart + 7) % 7;
return date.subtract(diff, "day").startOf("day");
};
@@ -3,23 +3,23 @@
import React from "react";
import { createStore, useStore } from "zustand";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import { useSelectedBookingUid } from "../hooks/useSelectedBookingUid";
import type { BookingOutput } from "../types";
interface BookingDetailsSheetStore {
// State
selectedBookingId: number | null;
selectedBookingUid: string | null;
bookings: BookingOutput[];
// Actions
setSelectedBookingId: (id: number | null) => void;
setSelectedBookingUid: (uid: string | null) => void;
setBookings: (bookings: BookingOutput[]) => void;
clearSelection: () => void;
// Computed getters (used via selectors)
getSelectedBooking: () => BookingOutput | null;
getNextBookingId: () => number | null;
getPreviousBookingId: () => number | null;
getNextBookingUid: () => string | null;
getPreviousBookingUid: () => string | null;
hasNext: () => boolean;
hasPrevious: () => boolean;
}
@@ -29,54 +29,54 @@ type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetS
const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => {
return createStore<BookingDetailsSheetStore>((set, get) => ({
// Initial state
selectedBookingId: null,
selectedBookingUid: null,
bookings: initialBookings,
// Actions
setSelectedBookingId: (id) => set({ selectedBookingId: id }),
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
setBookings: (bookings) => set({ bookings }),
clearSelection: () => set({ selectedBookingId: null }),
clearSelection: () => set({ selectedBookingUid: null }),
// Computed getters
getSelectedBooking: () => {
const state = get();
if (!state.selectedBookingId) return null;
return state.bookings.find((booking) => booking.id === state.selectedBookingId) ?? null;
if (!state.selectedBookingUid) return null;
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
},
getNextBookingId: () => {
getNextBookingUid: () => {
const state = get();
if (!state.selectedBookingId) return null;
if (!state.selectedBookingUid) return null;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null;
return state.bookings[currentIndex + 1].id;
return state.bookings[currentIndex + 1].uid;
},
getPreviousBookingId: () => {
getPreviousBookingUid: () => {
const state = get();
if (!state.selectedBookingId) return null;
if (!state.selectedBookingUid) return null;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
if (currentIndex <= 0) return null;
return state.bookings[currentIndex - 1].id;
return state.bookings[currentIndex - 1].uid;
},
hasNext: () => {
const state = get();
if (!state.selectedBookingId) return false;
if (!state.selectedBookingUid) return false;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
return currentIndex >= 0 && currentIndex < state.bookings.length - 1;
},
hasPrevious: () => {
const state = get();
if (!state.selectedBookingId) return false;
if (!state.selectedBookingUid) return false;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
return currentIndex > 0;
},
}));
@@ -92,7 +92,7 @@ export function BookingDetailsSheetStoreProvider({
bookings: BookingOutput[];
}) {
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
const [selectedBookingIdFromUrl, setSelectedBookingIdToUrl] = useSelectedBookingId();
const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid();
// Update bookings when they change
React.useEffect(() => {
@@ -102,22 +102,22 @@ export function BookingDetailsSheetStoreProvider({
// Sync Store → URL
React.useEffect(() => {
const unsubscribe = store.subscribe((state) => {
const storeId = state.selectedBookingId;
if (storeId !== selectedBookingIdFromUrl) {
setSelectedBookingIdToUrl(storeId);
const storeUid = state.selectedBookingUid;
if (storeUid !== selectedBookingUidFromUrl) {
setSelectedBookingUidToUrl(storeUid);
}
});
return unsubscribe;
}, [selectedBookingIdFromUrl, setSelectedBookingIdToUrl, store]);
}, [selectedBookingUidFromUrl, setSelectedBookingUidToUrl, store]);
// Sync URL → Store
React.useEffect(() => {
const currentStoreId = store.getState().selectedBookingId;
if (currentStoreId !== selectedBookingIdFromUrl) {
store.getState().setSelectedBookingId(selectedBookingIdFromUrl);
const currentStoreUid = store.getState().selectedBookingUid;
if (currentStoreUid !== selectedBookingUidFromUrl) {
store.getState().setSelectedBookingUid(selectedBookingUidFromUrl);
}
}, [selectedBookingIdFromUrl, store]);
}, [selectedBookingUidFromUrl, store]);
return (
<BookingDetailsSheetStoreContext.Provider value={store}>
+12 -11
View File
@@ -1,11 +1,14 @@
import type { DataTableRow } from "@calcom/features/data-table/lib/separator";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { validStatuses } from "~/bookings/lib/validStatuses";
import type { validStatuses } from "./lib/validStatuses";
export type BookingsGetOutput = RouterOutputs["viewer"]["bookings"]["get"];
export type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
export type BookingAttendee = BookingOutput["attendees"][0];
export type RecurringInfo = {
recurringEventId: string | null;
count: number;
@@ -13,15 +16,13 @@ export type RecurringInfo = {
bookings: { [key: string]: Date[] };
};
export type RowData =
| {
type: "data";
booking: BookingOutput;
isToday: boolean;
recurringInfo?: RecurringInfo;
}
| {
type: "today" | "next";
};
export type BookingRowData = {
type: "data";
booking: BookingOutput;
isToday: boolean;
recurringInfo?: RecurringInfo;
};
export type RowData = DataTableRow<BookingRowData>;
export type BookingListingStatus = (typeof validStatuses)[number];
@@ -36,7 +36,6 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { RefundPolicy } from "@calcom/lib/payment/types";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
@@ -57,6 +56,7 @@ import CancelBooking from "@calcom/web/components/booking/CancelBooking";
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
import { timeZone } from "@calcom/web/lib/clock";
import { usePaymentStatus } from "../hooks/usePaymentStatus";
import type { PageProps } from "./bookings-single-view.getServerSideProps";
const stringToBoolean = z
@@ -384,6 +384,22 @@ export default function Success(props: PageProps) {
const canCancel = !eventType?.disableCancelling;
const canReschedule = !eventType?.disableRescheduling;
const paymentStatusMessage = usePaymentStatus({
bookingStatus: bookingInfo.status,
startTime: bookingInfo.startTime,
eventTypeTeamId: eventType?.teamId,
userId: eventType?.owner?.id,
payment: props.paymentStatus
? {
success: props.paymentStatus.success,
refunded: props.paymentStatus.refunded,
paymentOption: props.paymentStatus.paymentOption,
}
: { success: false, refunded: false },
refundPolicy: eventType?.metadata?.apps?.stripe?.refundPolicy,
refundDaysCount: eventType?.metadata?.apps?.stripe?.refundDaysCount,
});
const successPageHeadline = (() => {
if (isAwaitingPayment && !isCancelled) {
return props.paymentStatus?.paymentOption === "HOLD"
@@ -462,7 +478,8 @@ export default function Success(props: PageProps) {
<div
className={classNames(
"inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
!isBackgroundTransparent && " bg-default dark:bg-cal-muted border-booker border-booker-width",
!isBackgroundTransparent &&
" bg-default dark:bg-cal-muted border-booker border-booker-width",
"px-8 pb-4 pt-5 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
)}
role="dialog"
@@ -519,47 +536,7 @@ export default function Success(props: PageProps) {
</div>
{props.paymentStatus &&
(bookingInfo.status === BookingStatus.CANCELLED ||
bookingInfo.status === BookingStatus.REJECTED) && (
<h4>
{!props.paymentStatus.success &&
!props.paymentStatus.refunded &&
t("booking_with_payment_cancelled")}
{props.paymentStatus.success &&
!props.paymentStatus.refunded &&
(() => {
const refundPolicy = eventType?.metadata?.apps?.stripe?.refundPolicy;
const refundDaysCount = eventType?.metadata?.apps?.stripe?.refundDaysCount;
// Handle missing team or event type owner (same in processPaymentRefund.ts)
if (!eventType?.teamId && !eventType?.owner) {
return t("booking_with_payment_cancelled_no_refund");
}
// Handle DAYS policy with expired refund window
else if (refundPolicy === RefundPolicy.DAYS && refundDaysCount) {
const startTime = new Date(bookingInfo.startTime);
const cancelTime = new Date();
const daysDiff = Math.floor(
(cancelTime.getTime() - startTime.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysDiff > refundDaysCount) {
return t("booking_with_payment_cancelled_refund_window_expired");
}
}
// Handle NEVER policy
else if (refundPolicy === RefundPolicy.NEVER) {
return t("booking_with_payment_cancelled_no_refund");
}
// Handle ALWAYS policy
else {
return t("booking_with_payment_cancelled_already_paid");
}
})()}
{props.paymentStatus.refunded && t("booking_with_payment_cancelled_refunded")}
</h4>
)}
bookingInfo.status === BookingStatus.REJECTED) && <h4>{paymentStatusMessage}</h4>}
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
{(isCancelled || reschedule) && cancellationReason && (
@@ -711,7 +688,7 @@ export default function Success(props: PageProps) {
<>
<div className="mt-9 font-medium">{t("additional_notes")}</div>
<div className="col-span-2 mb-2 mt-9">
<p className="whitespace-pre-line wrap-break-word">{bookingInfo.description}</p>
<p className="wrap-break-word whitespace-pre-line">{bookingInfo.description}</p>
</div>
</>
)}
@@ -738,7 +715,7 @@ export default function Success(props: PageProps) {
<div className="col-span-2 mb-2 mt-2">
{Object.entries(utmParams).filter(([_, value]) => Boolean(value)).length >
0 ? (
<ul className="list-disc stack-y-1 p-1 pl-5 sm:w-80">
<ul className="stack-y-1 list-disc p-1 pl-5 sm:w-80">
{Object.entries(utmParams)
.filter(([_, value]) => Boolean(value))
.map(([key, value]) => (
@@ -892,6 +869,7 @@ export default function Success(props: PageProps) {
currentUserEmail={currentUserEmail}
isHost={isHost}
internalNotePresets={props.internalNotePresets}
renderContext="booking-single-view"
/>
</>
))}
@@ -1053,7 +1031,7 @@ export default function Success(props: PageProps) {
</button>
))}
</div>
<div className="my-4 stack-y-1 text-center">
<div className="stack-y-1 my-4 text-center">
<h2 className="font-cal text-lg">{t("submitted_feedback")}</h2>
<p className="text-sm">{rateValue < 4 ? t("how_can_we_improve") : t("most_liked")}</p>
</div>
+19 -149
View File
@@ -1,37 +1,22 @@
"use client";
import dynamic from "next/dynamic";
import { useSearchParams, usePathname } from "next/navigation";
import { usePathname } from "next/navigation";
import { useQueryState } from "nuqs";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import {
DataTableProvider,
type SystemFilterSegment,
useDataTable,
ColumnFilterType,
useFilterValue,
ZMultiSelectFilterValue,
ZDateRangeFilterValue,
ZTextFilterValue,
} from "@calcom/features/data-table";
import { DataTableProvider, type SystemFilterSegment, ColumnFilterType } from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
import { HorizontalTabs } from "@calcom/ui/components/navigation";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import classNames from "@calcom/ui/classNames";
import type { validStatuses } from "~/bookings/lib/validStatuses";
import { viewParser } from "~/bookings/lib/viewParser";
import { BookingListContainer } from "../components/BookingListContainer";
import type { validStatuses } from "../lib/validStatuses";
import { viewParser } from "../lib/viewParser";
import { BookingsListContainer } from "../components/BookingsListContainer";
const BookingsCalendarContainer = dynamic(() =>
import("../components/BookingsCalendarContainer").then((mod) => ({
default: mod.BookingsCalendarContainer,
const BookingCalendarContainer = dynamic(() =>
import("../components/BookingCalendarContainer").then((mod) => ({
default: mod.BookingCalendarContainer,
}))
);
@@ -87,134 +72,19 @@ function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsPro
const [_view] = useQueryState("view", viewParser.withDefault("list"));
// Force view to be "list" if calendar view is disabled
const view = bookingsV3Enabled ? _view : "list";
const { t } = useLocale();
const searchParams = useSearchParams();
const tabs: HorizontalTabItemProps[] = useMemo(() => {
const queryString = searchParams?.toString() || "";
const baseTabConfigs = [
{
name: "upcoming",
path: "/bookings/upcoming",
"data-testid": "upcoming",
},
{
name: "unconfirmed",
path: "/bookings/unconfirmed",
"data-testid": "unconfirmed",
},
{
name: "recurring",
path: "/bookings/recurring",
"data-testid": "recurring",
},
{
name: "past",
path: "/bookings/past",
"data-testid": "past",
},
{
name: "cancelled",
path: "/bookings/cancelled",
"data-testid": "cancelled",
},
];
return baseTabConfigs.map((tabConfig) => ({
name: tabConfig.name,
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
"data-testid": tabConfig["data-testid"],
}));
}, [searchParams]);
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const userIds = useFilterValue("userId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
const { limit, offset } = useDataTable();
// Only apply pagination for list view, calendar view needs all bookings
const shouldPaginate = view === "list";
const queryLimit = shouldPaginate ? limit : 100; // Use max limit for calendar view
const queryOffset = shouldPaginate ? offset : 0; // Reset offset for calendar view
const query = trpc.viewer.bookings.get.useQuery(
{
limit: queryLimit,
offset: queryOffset,
filters: {
status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
},
{
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
}
);
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
const isPending = query.isPending;
const totalRowCount = query.data?.totalCount;
const hasError = !!query.error;
const errorView = query.error ? (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
) : undefined;
return (
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between lg:hidden">
<HorizontalTabs
tabs={tabs.map((tab) => ({
...tab,
name: t(tab.name),
}))}
<div className={classNames(view === "calendar" && "-mb-8")}>
{view === "list" && (
<BookingListContainer
status={status}
permissions={permissions}
bookingsV3Enabled={bookingsV3Enabled}
/>
</div>
<main className="w-full">
<div className="flex w-full flex-col">
{status === "upcoming" && !isEmpty && (
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
)}
{view === "list" && (
<BookingsListContainer
status={status}
permissions={permissions}
data={query.data}
isPending={isPending}
totalRowCount={totalRowCount}
enableDetailsSheet={bookingsV3Enabled}
ErrorView={errorView}
hasError={hasError}
/>
)}
{bookingsV3Enabled && view === "calendar" && (
<BookingsCalendarContainer
status={status}
permissions={permissions}
data={query.data}
isPending={isPending}
ErrorView={errorView}
hasError={hasError}
/>
)}
</div>
</main>
)}
{bookingsV3Enabled && view === "calendar" && (
<BookingCalendarContainer status={status} permissions={permissions} />
)}
</div>
);
}
+1 -1
View File
@@ -117,7 +117,7 @@
"next-seo": "^6.0.0",
"next-themes": "^0.2.0",
"nodemailer": "^6.7.8",
"nuqs": "^2.7.2",
"nuqs": "^2.8.2",
"openid-client": "6.5.0",
"otplib": "^12.0.1",
"posthog-js": "^1.274.1",
-118
View File
@@ -2,7 +2,6 @@ import { expect } from "@playwright/test";
import { MembershipRole } from "@calcom/prisma/enums";
import { applySelectFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
@@ -51,123 +50,6 @@ test.describe("Booking Filters", () => {
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeVisible();
});
test("Query params should be preserved when switching between bookings tabs", async ({ page, users }) => {
const owner = await users.create(
{ name: "Owner User" },
{
hasTeam: true,
isOrg: true,
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
}
);
await owner.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await applySelectFilter(page, "userId", "Owner User");
await expect(page).toHaveURL(/.*userId.*/);
const urlWithFilters = page.url();
expect(urlWithFilters).toContain("userId");
const searchParams = new URL(urlWithFilters).searchParams;
const activeFilters = searchParams.get("activeFilters");
expect(activeFilters).toBeTruthy();
const pastBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("past-test").click();
await expect(page).toHaveURL(/\/bookings\/past/);
await pastBookingsGetResponse;
const pastUrl = page.url();
expect(pastUrl).toContain("/bookings/past");
expect(pastUrl).toContain("userId");
const pastSearchParams = new URL(pastUrl).searchParams;
expect(pastSearchParams.get("activeFilters")).toBe(activeFilters);
const cancelledBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("cancelled-test").click();
await expect(page).toHaveURL(/\/bookings\/cancelled/);
await cancelledBookingsGetResponse;
const cancelledUrl = page.url();
expect(cancelledUrl).toContain("/bookings/cancelled");
expect(cancelledUrl).toContain("userId");
const cancelledSearchParams = new URL(cancelledUrl).searchParams;
expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters);
});
test("Query params should NOT be preserved when navigating from a non-bookings page", async ({
page,
users,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
});
await owner.apiLogin();
await page.goto(`/event-types?someParam=value`);
await expect(page).toHaveURL(/.*someParam=value.*/);
const upcomingBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("bookings-test").click();
await page.getByTestId("upcoming-test").click();
await expect(page).toHaveURL(/\/bookings\/upcoming/);
await upcomingBookingsGetResponse;
const upcomingUrl = page.url();
expect(upcomingUrl).toContain("/bookings/upcoming");
expect(upcomingUrl).not.toContain("someParam=value");
});
test("Query params should NOT be preserved when navigating from a bookings page to a non-bookings page", async ({
page,
users,
}) => {
const owner = await users.create(
{ name: "Owner User" },
{
hasTeam: true,
isOrg: true,
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
}
);
await owner.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await applySelectFilter(page, "userId", "Owner User");
await expect(page).toHaveURL(/.*userId.*/);
const urlWithFilters = page.url();
expect(urlWithFilters).toContain("userId");
await page.getByTestId("event_types_page_title-test").click();
await expect(page).toHaveURL(/\/event-types/);
const eventTypeSearchParams = new URL(page.url()).searchParams;
expect(eventTypeSearchParams.get("activeFilters")).toBeFalsy();
});
test("Filter segments with removed team members should not cause stuck UI", async ({
page,
users,
+21 -42
View File
@@ -18,6 +18,7 @@ import {
selectFirstAvailableTimeSlotNextMonth,
testEmail,
testName,
cancelBookingFromBookingsList,
} from "./lib/testUtils";
const freeUserObj = { name: `Free-user-${randomString(3)}` };
@@ -105,7 +106,7 @@ test.describe("free user", () => {
await page.goto(`/${free.username}`);
});
test("cannot book same slot multiple times", async ({ page, users, emails }) => {
test("cannot book same slot multiple times", async ({ page, users }) => {
const [user] = users.get();
const bookerObj = {
@@ -124,7 +125,7 @@ test.describe("free user", () => {
// Make sure we're navigated to the success page
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const { title: eventTitle } = await user.getFirstEventAsOwner();
await user.getFirstEventAsOwner();
await page.goto(bookingUrl);
@@ -206,23 +207,12 @@ test.describe("pro user", () => {
await pro.apiLogin();
await page.goto("/bookings/upcoming");
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
// Click the cancel option in the dropdown
await page.locator('[data-testid="cancel"]').click();
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking/");
await cancelBookingFromBookingsList({
page,
nth: 0,
reason: "Test reason",
});
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
await page.locator('[data-testid="confirm_cancel"]').click();
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
await expect(cancelledHeadline).toBeVisible();
await expect(page.locator(`[data-testid="attendee-email-${testEmail}"]`)).toHaveText(testEmail);
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
await page.goto(`/${pro.username}`);
await bookFirstEvent(page);
});
@@ -241,25 +231,18 @@ test.describe("pro user", () => {
await pro.apiLogin();
await page.goto("/bookings/upcoming");
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
// Click the cancel option in the dropdown
await page.locator('[data-testid="cancel"]').click();
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking/");
const { bookingUid } = await cancelBookingFromBookingsList({
page,
nth: 0,
reason: "Test reason",
});
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
await page.locator('[data-testid="confirm_cancel"]').click();
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
await expect(cancelledHeadline).toBeVisible();
const bookingCancelledId = new URL(page.url()).pathname.split("/booking/")[1];
await pro.getFirstEventAsOwner();
const { slug: eventSlug } = await pro.getFirstEventAsOwner();
await page.goto(`/reschedule/${bookingCancelledId}`);
await page.goto(`/reschedule/${bookingUid}`);
expect(page.url()).not.toContain("rescheduleUid");
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
await expect(cancelledHeadline).toBeVisible();
});
@@ -280,7 +263,7 @@ test.describe("pro user", () => {
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
});
test("can book an unconfirmed event multiple times", async ({ page, users }) => {
test("can book an unconfirmed event multiple times", async ({ page }) => {
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -297,7 +280,6 @@ test.describe("pro user", () => {
test("booking an unconfirmed event with the same email brings you to the original request", async ({
page,
users,
}) => {
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -312,7 +294,7 @@ test.describe("pro user", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("can book with multiple guests", async ({ page, users }) => {
test("can book with multiple guests", async ({ page }) => {
const additionalGuests = ["test@gmail.com", "test2@gmail.com"];
await page.click('[data-testid="event-type-link"]');
@@ -335,7 +317,7 @@ test.describe("pro user", () => {
await Promise.all(promises);
});
test("Time slots should be reserved when selected", async ({ context, page, browser }) => {
test("Time slots should be reserved when selected", async ({ page, browser }) => {
const initialUrl = page.url();
await page.locator('[data-testid="event-type-link"]').first().click();
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -417,10 +399,7 @@ test.describe("prefill", () => {
});
});
test("Persist the field values when going back and coming back to the booking form", async ({
page,
users,
}) => {
test("Persist the field values when going back and coming back to the booking form", async ({ page }) => {
await page.goto("/pro/30min");
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "John Doe");
@@ -434,7 +413,7 @@ test.describe("prefill", () => {
await expect(page.locator('[name="notes"]')).toHaveValue("Test notes");
});
test("logged out", async ({ page, users }) => {
test("logged out", async ({ page }) => {
await page.goto("/pro/30min");
await test.step("from query params", async () => {
@@ -708,11 +687,11 @@ test("Should throw error when both seatsPerTimeSlot and recurringEvent are set",
});
test.describe("GTM container", () => {
test.beforeEach(async ({ page, users }) => {
test.beforeEach(async ({ users }) => {
await users.create();
});
test("global GTM should not be loaded on private booking link", async ({ page, users, emails, prisma }) => {
test("global GTM should not be loaded on private booking link", async ({ page, users, prisma }) => {
const [user] = users.get();
const eventType = await user.getFirstEventAsOwner();
@@ -10,6 +10,7 @@ import {
doOnOrgDomain,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
cancelBookingFromBookingsList,
} from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
@@ -57,18 +58,11 @@ test("dynamic booking", async ({ page, users }) => {
await test.step("Can cancel the recently created booking", async () => {
await page.goto("/bookings/upcoming");
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
// Click the cancel option in the dropdown
await page.locator('[data-testid="cancel"]').click();
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
await cancelBookingFromBookingsList({
page,
nth: 0,
reason: "Test reason",
});
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
await page.locator('[data-testid="confirm_cancel"]').click();
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
await expect(cancelledHeadline).toBeVisible();
});
});
+1 -1
View File
@@ -202,7 +202,7 @@ export async function expectSegmentCleared(page: Page) {
// Check that no segment is selected (button shows default text)
const segmentSelect = page.getByTestId("filter-segment-select");
const buttonText = await segmentSelect.textContent();
expect(buttonText?.trim()).toEqual("Saved filters");
expect(["Saved filters", "Saved"]).toContain(buttonText?.trim());
}
/**
+1 -3
View File
@@ -57,9 +57,7 @@ test.describe("Locale-specific pages must not 404", () => {
await user.apiLogin();
const response = await page.goto(`/${locale}/bookings/upcoming`);
expect(response?.status()).not.toBe(404);
await expect(
page.locator("text=See upcoming and past events booked through your event type links.")
).toBeVisible();
await expect(page.getByRole("heading", { name: "No upcoming bookings" })).toBeVisible();
});
test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => {
+29 -4
View File
@@ -4,7 +4,6 @@ import { createHash } from "crypto";
import EventEmitter from "events";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
import type { Messages } from "mailhog";
import { totp } from "otplib";
import { v4 as uuid } from "uuid";
@@ -213,7 +212,7 @@ export async function setupManagedEvent({
addManagedEventToTeamMates: true,
managedEventUnlockedFields: unlockedFields,
});
const memberUser = users.get().find((u) => u.name === teamMateName)!;
const { team } = await adminUser.getFirstTeamMembership();
const managedEvent = await adminUser.getFirstTeamEvent(team.id, SchedulingType.MANAGED);
@@ -371,7 +370,7 @@ async function createUserWithSeatedEvent(users: Fixtures["users"]) {
},
],
});
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
return { user, eventType };
}
@@ -425,7 +424,9 @@ export function goToUrlWithErrorHandling({ page, url }: { page: Page; url: strin
page.on("requestfailed", onRequestFailed);
try {
await page.goto(url);
} catch (e) {}
} catch {
// do nothing
}
page.off("requestfailed", onRequestFailed);
resolve({ success: true, url: page.url() });
});
@@ -595,3 +596,27 @@ export async function setupOrgMember(users: CreateUsersFixture) {
return { orgMember, org, team, teamEvent, userEvent };
}
export async function cancelBookingFromBookingsList({
page,
nth,
reason,
}: {
page: Page;
reason: string;
nth: number;
}) {
await page.locator('[data-testid="booking-actions-dropdown"]').nth(nth).click();
const bookingUid = await page.locator('[data-testid="cancel"]').getAttribute("data-booking-uid");
// Click the cancel option in the dropdown
await page.locator('[data-testid="cancel"]').click();
await page.locator('[data-testid="cancel_reason"]').fill(reason);
await page.locator('[data-testid="confirm_cancel"]').click();
await expect(
page.locator('[data-testid="toast-success"]').filter({ hasText: "Booking Canceled" })
).toBeVisible();
return {
bookingUid,
};
}
+8 -8
View File
@@ -252,12 +252,12 @@ test.describe("authorized user sees correct translations (de)", async () => {
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByRole("heading", { name: "Buchungen", exact: true });
const locator = page.locator("h2", { hasText: "Es gibt noch keine bevorstehende Buchungen" });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
const locator = page.getByText("No upcoming bookings", { exact: true });
await expect(locator).toHaveCount(0);
}
});
@@ -271,12 +271,12 @@ test.describe("authorized user sees correct translations (de)", async () => {
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByRole("heading", { name: "Buchungen", exact: true });
const locator = page.locator("h2", { hasText: "Es gibt noch keine bevorstehende Buchungen" });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
const locator = page.getByText("No upcoming bookings", { exact: true });
await expect(locator).toHaveCount(0);
}
});
@@ -324,7 +324,7 @@ test.describe("authorized user sees correct translations (pt-br)", async () => {
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByRole("heading", { name: "Reservas", exact: true });
const locator = page.locator("h2", { hasText: "Ainda não tem reservas próximos" });
await expect(locator).toHaveCount(1);
}
@@ -343,7 +343,7 @@ test.describe("authorized user sees correct translations (pt-br)", async () => {
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByRole("heading", { name: "Reservas", exact: true });
const locator = page.locator("h2", { hasText: "Ainda não tem reservas próximos" });
await expect(locator).toHaveCount(1);
}
@@ -396,7 +396,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{
const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true });
const locator = page.locator("h2", { hasText: "لا توجد عمليات حجز القادم" });
await expect(locator).toHaveCount(1);
}
@@ -415,7 +415,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{
const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true });
const locator = page.locator("h2", { hasText: "لا توجد عمليات حجز القادم" });
await expect(locator).toHaveCount(1);
}
+18 -19
View File
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -25,7 +25,7 @@ test.afterEach(({ users }) => users.deleteAll());
test.describe("Reschedule Tests", async () => {
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
const user = await users.create();
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.ACCEPTED,
});
@@ -57,8 +57,8 @@ test.describe("Reschedule Tests", async () => {
bookings,
}) => {
const user = await users.create();
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.ACCEPTED,
startTime: dayjs().subtract(2, "day").toDate(),
endTime: dayjs().subtract(2, "day").add(30, "minutes").toDate(),
@@ -105,7 +105,7 @@ test.describe("Reschedule Tests", async () => {
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
const user = await users.create();
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.ACCEPTED,
rescheduled: true,
@@ -173,7 +173,7 @@ test.describe("Reschedule Tests", async () => {
const user = await users.create();
await user.apiLogin();
await user.installStripePersonal({ skip: true });
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
rescheduled: true,
@@ -196,7 +196,7 @@ test.describe("Reschedule Tests", async () => {
},
},
});
const payment = await payments.create(booking.id);
await payments.create(booking.id);
await page.goto(`/reschedule/${booking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -218,7 +218,7 @@ test.describe("Reschedule Tests", async () => {
await user.apiLogin();
await user.installStripePersonal({ skip: true });
await users.logout();
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
rescheduled: true,
@@ -226,7 +226,7 @@ test.describe("Reschedule Tests", async () => {
paid: true,
});
const payment = await payments.create(booking.id);
await payments.create(booking.id);
await page.goto(`/reschedule/${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -238,7 +238,7 @@ test.describe("Reschedule Tests", async () => {
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
@@ -259,7 +259,7 @@ test.describe("Reschedule Tests", async () => {
test("Opt in event should be ACCEPTED when rescheduled by OWNER", async ({ page, users, bookings }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
@@ -330,20 +330,19 @@ test.describe("Reschedule Tests", async () => {
test("Should load Valid Cal video url after rescheduling Opt in events", async ({
page,
users,
bookings,
browser,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!process.env.DAILY_API_KEY, "DAILY_API_KEY is needed for this test");
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const confirmBooking = async (bookingId: number) => {
const confirmBooking = async (bookingUid: string) => {
const [authedContext, authedPage] = await user.apiLoginOnNewBrowser(browser);
await authedPage.goto("/bookings/upcoming");
await submitAndWaitForResponse(authedPage, "/api/trpc/bookings/confirm?batch=1", {
action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(),
action: () => authedPage.locator(`[data-booking-uid="${bookingUid}"][data-testid="confirm"]`).click(),
});
await authedContext.close();
};
@@ -359,7 +358,7 @@ test.describe("Reschedule Tests", async () => {
const currentBooking = await prisma.booking.findFirstOrThrow({ where: { uid: bookingUID } });
expect(currentBooking).not.toBeUndefined();
await confirmBooking(currentBooking.id);
await confirmBooking(currentBooking.uid);
await page.goto(`/reschedule/${currentBooking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -372,7 +371,7 @@ test.describe("Reschedule Tests", async () => {
});
expect(newBooking).not.toBeUndefined();
expect(newBooking.status).toBe(BookingStatus.PENDING);
await confirmBooking(newBooking.id);
await confirmBooking(newBooking.uid);
const booking = await prisma.booking.findFirstOrThrow({ where: { id: newBooking.id } });
expect(booking).not.toBeUndefined();
@@ -469,7 +468,7 @@ test.describe("Reschedule Tests", async () => {
});
const profileUsername = (await orgMember.getFirstProfile()).username;
const eventType = orgMember.eventTypes[0];
const orgSlug = org.slug!;
const booking = await bookings.create(orgMember.id, orgMember.username, eventType.id);
@@ -510,7 +509,7 @@ test.describe("Reschedule Tests", async () => {
roleInOrganization: MembershipRole.MEMBER,
});
const eventType = orgMember.eventTypes[0];
const orgSlug = org.slug!;
const booking = await bookings.create(orgMember.id, orgMember.username, eventType.id);
@@ -4097,6 +4097,8 @@
"booking_response": "Booking Response",
"list_view": "List view",
"calendar_view": "Calendar view",
"assignment_reason": "Assignment Reason",
"saved": "Saved",
"booking_history": "Booking History",
"booking_history_description": "View the history of actions performed on this booking",
"audit_action": {
@@ -22,7 +22,9 @@ type GetOptions = {
take: number;
skip: number;
filters: {
// Support both singular 'status' (for API v2) and plural 'statuses' (/bookings page)
status?: InputByStatus;
statuses?: InputByStatus[];
teamIds?: number[] | undefined;
userIds?: number[] | undefined;
eventTypeIds?: number[] | undefined;
@@ -36,12 +38,21 @@ type GetOptions = {
const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, skip, sort }: GetOptions) => {
const { prisma, user, kysely } = ctx;
// Support both singular 'status' and plural 'statuses' for backward compatibility
// Note: filters can be undefined at runtime despite the type definition, e.g., when
// API endpoints are called without query parameters (found in managed-user-bookings.e2e-spec.ts).
// Use optional chaining to handle this defensively.
const statusesFilter = filters?.statuses ?? (filters?.status ? [filters.status] : bookingListingByStatus);
const { bookings, recurringInfo, totalCount } = await getBookings({
user,
prisma,
kysely,
bookingListingByStatus,
filters: filters,
filters: {
...filters,
statuses: statusesFilter,
},
sort,
take,
skip,
@@ -3,7 +3,11 @@ import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { Booking } from "@calcom/prisma/client";
import { RRTimestampBasis, BookingStatus } from "@calcom/prisma/enums";
import { bookingMinimalSelect } from "@calcom/prisma/selects/booking";
import {
bookingMinimalSelect,
bookingAuthorizationCheckSelect,
bookingDetailsSelect,
} from "@calcom/prisma/selects/booking";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
type ManagedEventReassignmentCreateParams = {
@@ -501,6 +505,48 @@ export class BookingRepository {
});
}
async findByUidForAuthorizationCheck({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: bookingUid,
},
select: bookingAuthorizationCheckSelect,
});
}
async findByUidForDetails({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: bookingUid,
},
select: bookingDetailsSelect,
});
}
async findRescheduledToBooking({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findFirst({
where: {
fromReschedule: bookingUid,
},
select: {
uid: true,
},
});
}
async findPreviousBooking({ fromReschedule }: { fromReschedule: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: fromReschedule,
},
select: {
uid: true,
startTime: true,
endTime: true,
},
});
}
async getActiveRecurringBookingsFromDate({
recurringEventId,
fromDate,
@@ -1563,7 +1609,7 @@ export class BookingRepository {
},
});
}
findByUidIncludeEventTypeAndReferences({ bookingUid }: { bookingUid: string }) {
return this.prismaClient.booking.findUniqueOrThrow({
where: {
@@ -1618,7 +1664,7 @@ export class BookingRepository {
},
});
}
async updateBookingStatus({
bookingId,
status,
@@ -1649,7 +1695,6 @@ export class BookingRepository {
});
}
/**
* Cancels a booking as part of the Managed Event reassignment flow.
* Callers only pass domain data; repository handles persistence details.
@@ -1724,8 +1769,8 @@ export class BookingRepository {
location,
smsReminderNumber,
responses: responses ?? undefined,
customInputs: customInputs as unknown as Prisma.InputJsonValue ?? undefined,
metadata: metadata as unknown as Prisma.InputJsonValue ?? undefined,
customInputs: (customInputs as unknown as Prisma.InputJsonValue) ?? undefined,
metadata: (metadata as unknown as Prisma.InputJsonValue) ?? undefined,
idempotencyKey,
iCalUID,
iCalSequence,
@@ -64,8 +64,8 @@ export class BookingAccessService {
const booking = bookingUid
? await bookingRepo.findByUidIncludeEventType({ bookingUid })
: bookingId
? await bookingRepo.findByIdIncludeEventType({ bookingId })
: null;
? await bookingRepo.findByIdIncludeEventType({ bookingId })
: null;
if (!booking) return false;
@@ -131,4 +131,62 @@ export class BookingAccessService {
return false;
}
/**
* Checks if a user has access to view booking details using:
* 1. Owner/host check
* 2. PBAC permission check for team bookings
*/
async checkBookingAccessWithPBAC({
userId,
bookingUid,
}: {
userId: number;
bookingUid: string;
}): Promise<boolean> {
const bookingRepo = new BookingRepository(this.prismaClient);
const booking = await bookingRepo.findByUidForAuthorizationCheck({ bookingUid });
if (!booking) {
return false;
}
// Check 1: User is the owner of the booking
const isOwner = booking.userId === userId;
if (isOwner) {
return true;
}
// Check 2: User is a host (checking eventType.users and eventType.hosts)
const attendeeEmails = new Set(booking.attendees?.map((attendee) => attendee.email) || []);
const isHostViaEventTypeUsers = booking.eventType?.users?.some(
(user) => user.id === userId && attendeeEmails.has(user.email)
);
const isHostViaEventTypeHosts = booking.eventType?.hosts?.some(
(host) => host.user?.id === userId && attendeeEmails.has(host.user.email)
);
if (isHostViaEventTypeUsers || isHostViaEventTypeHosts) {
return true;
}
// Check 3: PBAC permission check for team bookings
if (!booking.eventType?.teamId) {
// No team associated with booking and user is not owner/host
return false;
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId,
teamId: booking.eventType.teamId,
permission: "booking.read",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
return hasPermission;
}
}
@@ -0,0 +1,48 @@
import { ErrorWithCode } from "@calcom/lib/errors";
import type { PrismaClient } from "@calcom/prisma";
import { BookingRepository } from "../repositories/BookingRepository";
import { BookingAccessService } from "./BookingAccessService";
export class BookingDetailsService {
private bookingRepo: BookingRepository;
private bookingAccessService: BookingAccessService;
constructor(prismaClient: PrismaClient) {
this.bookingRepo = new BookingRepository(prismaClient);
this.bookingAccessService = new BookingAccessService(prismaClient);
}
async getBookingDetails({ userId, bookingUid }: { userId: number; bookingUid: string }) {
const hasAccess = await this.bookingAccessService.checkBookingAccessWithPBAC({
userId,
bookingUid,
});
if (!hasAccess) {
throw ErrorWithCode.Factory.Forbidden("You do not have permission to view this booking");
}
const booking = await this.bookingRepo.findByUidForDetails({ bookingUid });
if (!booking) {
throw ErrorWithCode.Factory.BookingNotFound("Booking not found");
}
const [rescheduledToBooking, previousBooking] = await Promise.all([
// For old rescheduled bookings, find the new booking
booking.rescheduled
? this.bookingRepo.findRescheduledToBooking({ bookingUid: booking.uid })
: Promise.resolve(null),
// For new bookings that replaced an old one, fetch the previous booking's schedule
booking.fromReschedule
? this.bookingRepo.findPreviousBooking({ fromReschedule: booking.fromReschedule })
: Promise.resolve(null),
]);
return {
rescheduledToBooking,
previousBooking,
};
}
}
@@ -52,7 +52,7 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
<div
ref={containerNavRef}
className={classNames(
"bg-default dark:bg-cal-muted sticky top-(--calendar-dates-sticky-offset,0px) z-80 flex-none border-b",
"bg-default dark:bg-cal-muted top-(--calendar-dates-sticky-offset,0px) z-80 sticky flex-none border-b",
borderColor === "subtle" ? "border-b-subtle" : "border-b-default",
showBorder && (borderColor === "subtle" ? "border-r-subtle border-r" : "border-r-default border-r")
)}>
@@ -101,7 +101,7 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
<span
className={classNames(
"items-center justify-center p-1",
isToday && "bg-brand-default text-brand rounded-md"
isToday && "bg-brand-default text-brand ml-1 rounded-md"
)}>
{day.format("DD")}
</span>
@@ -1,6 +1,7 @@
import { cva } from "class-variance-authority";
import dayjs from "@calcom/dayjs";
import type { BookingStatus } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Tooltip } from "@calcom/ui/components/tooltip";
@@ -16,15 +17,15 @@ type EventProps = {
};
const eventClasses = cva(
"group flex h-full w-full overflow-hidden rounded-[6px] px-[6px] text-xs leading-5 opacity-80 border-default font-medium",
"group flex h-full w-full overflow-hidden rounded-[6px] px-[6px] text-xs leading-5 border-default font-medium",
{
variants: {
status: {
ACCEPTED: "bg-subtle hover:bg-emphasis text-emphasis border-[1px] border-default",
PENDING: "bg-muted text-emphasis border-[1px] border-dashed border-default",
REJECTED: "bg-muted border-[1px] text-subtle line-through",
CANCELLED: "bg-muted border-[1px] text-subtle line-through",
AWAITING_HOST: "bg-muted text-emphasis border-[1px] border-dashed border-default",
PENDING: "bg-subtle text-subtle border-[1px] border-dashed border-default",
REJECTED: "bg-subtle border-[1px] border-dashed text-subtle line-through",
CANCELLED: "bg-subtle border-[1px] border-dashed text-subtle line-through",
AWAITING_HOST: "bg-subtle text-emphasis border-[1px] border-dashed border-default",
},
disabled: {
true: "hover:cursor-default",
@@ -42,6 +43,22 @@ const eventClasses = cva(
}
);
const STATUS_COLOR_MAP: Record<BookingStatus, string> = {
ACCEPTED: "bg-green-500",
PENDING: "bg-orange-500",
REJECTED: "bg-red-500",
CANCELLED: "bg-red-500",
AWAITING_HOST: "bg-blue-500",
};
// Maps booking status to color classes for the color bar
// Follows the same pattern as BookingDetailsSheet badge variants
const getStatusColorClass = (status?: BookingStatus): string | undefined => {
if (!status) return undefined;
return STATUS_COLOR_MAP[status];
};
export function Event({
event,
currentlySelectedEventId,
@@ -53,6 +70,9 @@ export function Event({
const selected = currentlySelectedEventId === event.id;
const { options } = event;
// Use custom color if provided, otherwise derive from status
const colorClass = !options?.color ? getStatusColorClass(options?.status) : undefined;
const Component = onEventClick ? "button" : "div";
const dayOfWeek = dayjs(event.start).day();
@@ -61,8 +81,10 @@ export function Event({
const tooltipContent = (
<div className="flex min-w-[200px] max-w-[300px] flex-col gap-1 py-1">
<div className="flex items-start gap-2">
{options?.color && (
<div className="mt-1 h-3 w-1 shrink-0 rounded-sm" style={{ backgroundColor: options.color }}></div>
{(options?.color || colorClass) && (
<div
className={classNames("mt-1 h-3 w-1 shrink-0 rounded-sm", colorClass)}
style={options?.color ? { backgroundColor: options.color } : undefined}></div>
)}
<div className="flex-1">
<div className="font-semibold leading-tight">{event.title}</div>
@@ -103,10 +125,10 @@ export function Event({
style={{
transition: "all 100ms ease-out",
}}>
{options?.color && (
{(options?.color || colorClass) && (
<div
className="-ml-1.5 mr-1.5 h-full w-[3px] shrink-0"
style={{ backgroundColor: options.color }}></div>
className={classNames("-ml-1.5 mr-1.5 h-full w-[3px] shrink-0", colorClass)}
style={options?.color ? { backgroundColor: options.color } : undefined}></div>
)}
<div className={classNames("flex w-full", displayType !== "single-line" && "flex-col py-1")}>
{displayType === "single-line" && (
@@ -13,7 +13,7 @@ export interface CalendarEvent {
allDay?: boolean;
color?: string;
className?: string;
bookingId?: number;
bookingUid?: string;
"data-test-id"?: string;
borderOnly?: boolean;
};
@@ -362,7 +362,7 @@ function DataTableBody<TData>({
return (
<TableBody
className="border-subtle relative grid border-t"
className="border-muted relative grid border-t"
data-testid={testId}
style={{ height: tableHeight }}>
{rowsToRender.map(({ row, virtualItem }) => {
@@ -382,7 +382,7 @@ function DataTableBody<TData>({
transform: `translateY(${virtualItem.start}px)`,
}),
}}
className="hover:bg-subtle border-subtle flex w-full border-b">
className="hover:bg-subtle border-muted flex w-full border-b">
<SeparatorRowRenderer separator={row.original as SeparatorRow} className={separatorClassName} />
</TableRow>
);
@@ -15,7 +15,7 @@ import { useDataTable, useFilterableColumns } from "../../hooks";
export interface AddFilterButtonProps<TData> {
table: Table<TData>;
variant?: "base" | "sm";
variant?: "base" | "minimal";
}
function AddFilterButtonComponent<TData>(
@@ -31,7 +31,7 @@ function AddFilterButtonComponent<TData>(
(column) => !activeFilters?.some((filter) => filter.f === column.id)
);
if (variant === "sm" && availableColumns.length === 0) {
if (variant === "minimal" && availableColumns.length === 0) {
return null;
}
@@ -44,13 +44,13 @@ function AddFilterButtonComponent<TData>(
ref={ref}
color="secondary"
data-testid="add-filter-button"
StartIcon="sliders-horizontal"
StartIcon="list-filter"
className="h-full">
{t("filter")}
</Button>
</PopoverTrigger>
)}
{variant === "sm" && (
{variant === "minimal" && (
<Tooltip content={t("add_filter")}>
<PopoverTrigger asChild>
<Button ref={ref} color="secondary" data-testid="add-filter-button" className="h-full">
@@ -78,7 +78,7 @@ function AddFilterButtonComponent<TData>(
addFilter(column.id);
setOpen(false);
}}
className="flex items-center justify-between px-4 py-2 rounded-none"
className="flex items-center justify-between rounded-none px-4 py-2"
data-testid={`add-filter-item-${column.id}`}>
<span>{startCase(column.title)}</span>
{showHiddenIndicator && <Icon name="eye-off" className="h-4 w-4 opacity-50" />}
@@ -1,9 +1,8 @@
"use client";
import { type Table } from "@tanstack/react-table";
import { useMemo } from "react";
import { useDataTable } from "../../hooks";
import { useDisplayedFilterCount } from "../../hooks";
import { ActiveFilters } from "./ActiveFilters";
import { AddFilterButton } from "./AddFilterButton";
@@ -13,18 +12,13 @@ interface FilterBarProps<TData> {
}
export function FilterBar<TData>({ table, columnIdsToHide }: FilterBarProps<TData>) {
const { activeFilters } = useDataTable();
const displayedFilterCount = useMemo(() => {
return (activeFilters ?? []).filter((filter) =>
columnIdsToHide ? !columnIdsToHide.includes(filter.f) : true
).length;
}, [activeFilters, columnIdsToHide]);
const displayedFilterCount = useDisplayedFilterCount({ columnIdsToHide });
return (
<>
{displayedFilterCount === 0 && <AddFilterButton table={table} />}
<ActiveFilters table={table} columnIdsToHide={columnIdsToHide} />
{displayedFilterCount > 0 && <AddFilterButton table={table} variant="sm" />}
{displayedFilterCount > 0 && <AddFilterButton table={table} variant="minimal" />}
</>
);
}
@@ -35,7 +35,11 @@ type SubmenuItem = {
enabledForSystemSegment?: boolean;
};
export function FilterSegmentSelect() {
interface Props {
shortLabel?: boolean;
}
export function FilterSegmentSelect({ shortLabel }: Props = {}) {
const { t } = useLocale();
const session = useSession();
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
@@ -155,7 +159,7 @@ export function FilterSegmentSelect() {
StartIcon="list-filter"
EndIcon="chevron-down"
data-testid="filter-segment-select">
{selectedSegment?.name || t("saved_filters")}
{selectedSegment?.name || (shortLabel ? t("saved") : t("saved_filters"))}
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
@@ -5,3 +5,4 @@ export * from "./useFilterValue";
export * from "./useColumnFilters";
export * from "./useDataTable";
export * from "./useFilterableColumns";
export * from "./useDisplayedFilterCount";
@@ -0,0 +1,16 @@
import { useMemo } from "react";
import { useDataTable } from "./useDataTable";
type UseDisplayedFilterCountProps = {
columnIdsToHide?: string[];
};
export const useDisplayedFilterCount = ({ columnIdsToHide }: UseDisplayedFilterCountProps = {}) => {
const { activeFilters } = useDataTable();
return useMemo(() => {
return (activeFilters ?? []).filter((filter) =>
columnIdsToHide ? !columnIdsToHide.includes(filter.f) : true
).length;
}, [activeFilters, columnIdsToHide]);
};
@@ -16,14 +16,6 @@ import { NavigationItem, MobileNavigationItem, MobileNavigationMoreItem } from "
export const MORE_SEPARATOR_NAME = "more";
const preserveBookingsQueryParams = ({
prevPathname,
nextPathname,
}: {
prevPathname: string | null;
nextPathname: string;
}) => Boolean(prevPathname?.startsWith("/bookings/")) && nextPathname.startsWith("/bookings/");
const getNavigationItems = (orgBranding: OrganizationBranding): NavigationItemType[] => [
{
name: "event_types_page_title",
@@ -36,38 +28,6 @@ const getNavigationItems = (orgBranding: OrganizationBranding): NavigationItemTy
icon: "calendar",
badge: <UnconfirmedBookingBadge />,
isCurrent: ({ pathname }) => pathname?.startsWith("/bookings") ?? false,
child: [
{
name: "upcoming",
href: "/bookings/upcoming",
preserveQueryParams: preserveBookingsQueryParams,
isCurrent: ({ pathname }) => pathname === "/bookings/upcoming",
},
{
name: "unconfirmed",
href: "/bookings/unconfirmed",
preserveQueryParams: preserveBookingsQueryParams,
isCurrent: ({ pathname }) => pathname === "/bookings/unconfirmed",
},
{
name: "recurring",
href: "/bookings/recurring",
preserveQueryParams: preserveBookingsQueryParams,
isCurrent: ({ pathname }) => pathname === "/bookings/recurring",
},
{
name: "past",
href: "/bookings/past",
preserveQueryParams: preserveBookingsQueryParams,
isCurrent: ({ pathname }) => pathname === "/bookings/past",
},
{
name: "cancelled",
href: "/bookings/cancelled",
preserveQueryParams: preserveBookingsQueryParams,
isCurrent: ({ pathname }) => pathname === "/bookings/cancelled",
},
],
},
{
name: "availability",
@@ -1,7 +1,7 @@
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import React, { Fragment, useState, useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import posthog from "posthog-js";
import React, { Fragment, useState, useEffect } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
@@ -33,29 +33,6 @@ const usePersistedExpansionState = (itemName: string) => {
return [isExpanded, setPersistedExpansion] as const;
};
const useBuildHref = () => {
const pathname = usePathname();
const searchParams = useSearchParams();
const prevPathnameRef = useRef<string | null>(null);
useEffect(() => {
prevPathnameRef.current = pathname;
}, [pathname]);
const buildHref = (childItem: NavigationItemType) => {
if (
childItem.preserveQueryParams &&
childItem.preserveQueryParams({ prevPathname: prevPathnameRef.current, nextPathname: childItem.href })
) {
const params = searchParams?.toString();
return params ? `${childItem.href}?${params}` : childItem.href;
}
return childItem.href;
};
return buildHref;
};
const trackNavigationClick = (itemName: string, parentItemName?: string) => {
posthog.capture("navigation_item_clicked", {
item_name: itemName,
@@ -85,7 +62,6 @@ export type NavigationItemType = {
isChild?: boolean;
pathname: string | null;
}) => boolean;
preserveQueryParams?: (context: { prevPathname: string | null; nextPathname: string }) => boolean;
};
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, pathname }) => {
@@ -104,7 +80,6 @@ export const NavigationItem: React.FC<{
const current = isCurrent({ isChild: !!isChild, item, pathname });
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
const buildHref = useBuildHref();
const isTablet = useMediaQuery("(max-width: 1024px)");
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
@@ -126,7 +101,7 @@ export const NavigationItem: React.FC<{
open={isTooltipOpen}
content={
hasChildren ? (
<div className="pointer-events-auto flex flex-col stack-y-1 p-1">
<div className="stack-y-1 pointer-events-auto flex flex-col p-1">
<span className="text-subtle px-2 text-xs font-semibold uppercase tracking-wide">
{t(item.name)}
</span>
@@ -139,7 +114,7 @@ export const NavigationItem: React.FC<{
return (
<Link
key={childItem.name}
href={buildHref(childItem)}
href={childItem.href}
aria-current={childIsCurrent ? "page" : undefined}
onClick={() => {
setIsTooltipOpen(false);
@@ -211,7 +186,7 @@ export const NavigationItem: React.FC<{
<Link
data-test-id={item.name}
onClick={() => trackNavigationClick(item.name)}
href={isChild ? buildHref(item) : item.href}
href={item.href}
aria-label={t(item.name)}
target={item.target}
className={classNames(
@@ -220,8 +195,9 @@ export const NavigationItem: React.FC<{
? `aria-[aria-current='page']:bg-transparent!`
: `[&[aria-current='page']]:bg-emphasis`,
isChild
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${props.index === 0 ? "mt-0" : "mt-px"
}`
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${
props.index === 0 ? "mt-0" : "mt-px"
}`
: "[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
isLocaleReady
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
@@ -232,7 +208,7 @@ export const NavigationItem: React.FC<{
<Icon
name={item.isLoading ? "rotate-cw" : item.icon}
className={classNames(
"todesktop:!text-blue-500 mr-2 h-4 w-4 shrink-0 rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2 aria-[aria-current='page']:text-inherit",
"todesktop:!text-blue-500 mr-2 h-4 w-4 shrink-0 aria-[aria-current='page']:text-inherit rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2",
item.isLoading && "animate-spin"
)}
aria-hidden="true"
@@ -281,7 +257,7 @@ export const MobileNavigationItem: React.FC<{
key={item.name}
href={item.href}
target={item.target}
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted relative my-2 min-w-0 flex-1 overflow-hidden rounded-md bg-transparent! p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted bg-transparent! relative my-2 min-w-0 flex-1 overflow-hidden rounded-md p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
aria-current={current ? "page" : undefined}>
{item.badge && <div className="absolute right-1 top-1">{item.badge}</div>}
{item.icon && (
@@ -305,7 +281,6 @@ export const MobileNavigationMoreItem: React.FC<{
const { t, isLocaleReady } = useLocale();
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
const buildHref = useBuildHref();
if (!shouldDisplayNavigationItem) return null;
@@ -320,11 +295,7 @@ export const MobileNavigationMoreItem: React.FC<{
className="hover:bg-subtle flex w-full items-center justify-between p-5 text-left transition">
<span className="text-default flex items-center font-semibold">
{item.icon && (
<Icon
name={item.icon}
className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3"
aria-hidden="true"
/>
<Icon name={item.icon} className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
)}
{isLocaleReady ? t(item.name) : <SkeletonText />}
</span>
@@ -335,7 +306,7 @@ export const MobileNavigationMoreItem: React.FC<{
{item.child.map((childItem) => (
<li key={childItem.name} className="border-subtle border-t">
<Link
href={buildHref(childItem)}
href={childItem.href}
className="hover:bg-cal-muted flex items-center p-4 pl-12 transition">
<span className="text-default font-medium">
{isLocaleReady ? t(childItem.name) : <SkeletonText />}
+22 -12
View File
@@ -1,19 +1,29 @@
import { useState, useEffect } from "react";
import { useCallback, useSyncExternalStore } from "react";
const getServerSnapshot = () => {
// Return undefined during SSR to avoid hydration mismatches
return undefined;
};
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
// Use useSyncExternalStore for SSR-safe media query detection
const subscribe = useCallback(
(callback: () => void) => {
const media = window.matchMedia(query);
media.addEventListener("change", callback);
return () => media.removeEventListener("change", callback);
},
[query]
);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
}, [matches, query]);
const getSnapshot = useCallback(() => {
return window.matchMedia(query).matches;
}, [query]);
return matches;
const matches = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
// Return false if undefined (SSR) to maintain backward compatibility
return matches ?? false;
};
export default useMediaQuery;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Booking_fromReschedule_idx" ON "public"."Booking"("fromReschedule");
+1
View File
@@ -894,6 +894,7 @@ model Booking {
@@index([uid])
@@index([status])
@@index([startTime, endTime, status])
@@index([fromReschedule])
}
model Tracking {
+36
View File
@@ -11,3 +11,39 @@ export const bookingMinimalSelect = {
attendees: true,
metadata: true,
} satisfies Prisma.BookingSelect;
export const bookingAuthorizationCheckSelect = {
userId: true,
eventType: {
select: {
teamId: true,
users: {
select: {
id: true,
email: true,
},
},
hosts: {
select: {
user: {
select: {
id: true,
email: true,
},
},
},
},
},
},
attendees: {
select: {
email: true,
},
},
} satisfies Prisma.BookingSelect;
export const bookingDetailsSelect = {
uid: true,
rescheduled: true,
fromReschedule: true,
} satisfies Prisma.BookingSelect;
@@ -6,9 +6,10 @@ import { ZConfirmInputSchema } from "./confirm.schema";
import { ZEditLocationInputSchema } from "./editLocation.schema";
import { ZFindInputSchema } from "./find.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
import { ZGetAuditLogsInputSchema } from "./getAuditLogs.schema";
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
import { ZGetBookingDetailsInputSchema } from "./getBookingDetails.schema";
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
import { ZReportBookingInputSchema } from "./reportBooking.schema";
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
import { bookingsProcedure } from "./util";
@@ -70,6 +71,15 @@ export const bookingsRouter = router({
});
}),
getBookingDetails: authedProcedure.input(ZGetBookingDetailsInputSchema).query(async ({ input, ctx }) => {
const { getBookingDetailsHandler } = await import("./getBookingDetails.handler");
return getBookingDetailsHandler({
ctx,
input,
});
}),
find: publicProcedure.input(ZFindInputSchema).query(async ({ input, ctx }) => {
const { getHandler } = await import("./find.handler");
@@ -35,13 +35,25 @@ type InputByStatus = "upcoming" | "recurring" | "past" | "cancelled" | "unconfir
const log = logger.getSubLogger({ prefix: ["bookings.get"] });
export const getHandler = async ({ ctx, input }: GetOptions) => {
// using offset actually because cursor pagination requires a unique column
// for orderBy, but we don't use a unique column in our orderBy
// Support both offset-based (list) and cursor-based pagination (calendar)
// Cursor is just the offset as a string (fake cursor pagination)
const take = input.limit;
const skip = input.offset;
let skip = input.offset;
// If cursor is provided, parse it to get the offset
if (input.cursor) {
const parsedCursor = parseInt(input.cursor, 10);
if (!isNaN(parsedCursor) && parsedCursor >= 0) {
skip = parsedCursor;
}
}
const { prisma, user } = ctx;
const defaultStatus = "upcoming";
const bookingListingByStatus = [input.filters.status || defaultStatus];
const bookingListingByStatus = input.filters.statuses?.length
? input.filters.statuses
: [input.filters.status || defaultStatus];
const { bookings, recurringInfo, totalCount } = await getAllUserBookings({
ctx: {
@@ -55,10 +67,16 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
filters: input.filters,
});
// Generate next cursor for infinite query support
const nextOffset = skip + take;
const hasMore = nextOffset < totalCount;
const nextCursor = hasMore ? nextOffset.toString() : undefined;
return {
bookings,
recurringInfo,
totalCount,
nextCursor,
};
};
@@ -478,7 +496,11 @@ export async function getBookings({
"Booking.paid",
"Booking.fromReschedule",
"Booking.rescheduled",
"Booking.rescheduledBy",
"Booking.cancelledBy",
"Booking.isRecorded",
"Booking.cancellationReason",
"Booking.rejectionReason",
jsonObjectFrom(
eb
.selectFrom("App_RoutingForms_FormResponse")
@@ -508,6 +530,7 @@ export async function getBookings({
"EventType.hideOrganizerEmail",
"EventType.disableCancelling",
"EventType.disableRescheduling",
"EventType.teamId",
"EventType.parentId",
eb
.cast<SchedulingType | null>(
@@ -563,13 +586,27 @@ export async function getBookings({
jsonArrayFrom(
eb
.selectFrom("Payment")
.select(["Payment.paymentOption", "Payment.amount", "Payment.currency", "Payment.success"])
.select([
"Payment.paymentOption",
"Payment.amount",
"Payment.currency",
"Payment.success",
"Payment.appId",
"Payment.refunded",
])
.whereRef("Payment.bookingId", "=", "Booking.id")
).as("payment"),
jsonObjectFrom(
eb
.selectFrom("users")
.select(["users.id", "users.name", "users.email"])
.select([
"users.id",
"users.name",
"users.email",
"users.avatarUrl",
"users.username",
"users.timeZone",
])
.whereRef("Booking.userId", "=", "users.id")
).as("user"),
jsonArrayFrom(
@@ -750,7 +787,76 @@ export async function getBookings({
};
})
);
return { bookings, recurringInfo, totalCount };
// Enrich attendees with user data
const enrichedBookings = await enrichAttendeesWithUserData(bookings, kysely);
return { bookings: enrichedBookings, recurringInfo, totalCount };
}
type EnrichedUserData = {
name: string | null;
email: string;
avatarUrl: string | null;
username: string | null;
};
/**
* Enriches booking attendees with user data by performing a left outer join
* between attendees and users tables on email addresses.
*
* @param bookings - Array of bookings with attendees to enrich
* @param kysely - Kysely database client instance
* @returns Bookings with attendees enriched with user data (name, email, avatarUrl, username)
*/
async function enrichAttendeesWithUserData<
TBooking extends { attendees: ReadonlyArray<{ id: number; email: string }> }
>(
bookings: TBooking[],
kysely: Kysely<DB>
): Promise<
Array<
Omit<TBooking, "attendees"> & {
attendees: Array<TBooking["attendees"][number] & { user: EnrichedUserData | null }>;
}
>
> {
// Extract all unique attendee emails from bookings
const allAttendees = bookings.flatMap((booking) => booking.attendees);
const uniqueAttendeeIds = Array.from(new Set(allAttendees.map((attendee) => attendee.id)));
// Query attendees with left join to users table
const enrichedAttendees =
uniqueAttendeeIds.length > 0
? await kysely
.selectFrom("Attendee")
.leftJoin("users", "users.email", "Attendee.email")
.select(["Attendee.id", "users.name", "Attendee.email", "users.avatarUrl", "users.username"])
.where("Attendee.id", "in", uniqueAttendeeIds)
.execute()
: [];
// Create a lookup map for O(1) access by attendee ID
const attendeeUserDataMap = new Map<number, EnrichedUserData>(
enrichedAttendees.map((enriched) => [
enriched.id,
{
name: enriched.name,
email: enriched.email,
avatarUrl: enriched.avatarUrl,
username: enriched.username,
},
])
);
// Map over bookings and enrich each attendee with user data
return bookings.map((booking) => ({
...booking,
attendees: booking.attendees.map((attendee) => ({
...attendee,
user: attendeeUserDataMap.get(attendee.id) || null,
})),
}));
}
async function getEventTypeIdsFromTeamIdsFilter(prisma: PrismaClient, teamIds?: number[]) {
@@ -6,7 +6,9 @@ export const ZGetInputSchema = z.object({
filters: z.object({
teamIds: z.number().array().optional(),
userIds: z.number().array().optional(),
// Support both singular 'status' (for API v2) and plural 'statuses' (/bookings page)
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]).optional(),
statuses: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]).array().optional(),
eventTypeIds: z.number().array().optional(),
attendeeEmail: z.union([z.string(), ZTextFilterValue]).optional(),
attendeeName: z.union([z.string(), ZTextFilterValue]).optional(),
@@ -20,6 +22,8 @@ export const ZGetInputSchema = z.object({
}),
limit: z.number().min(1).max(100),
offset: z.number().default(0),
// Cursor for infinite query support (calendar view)
cursor: z.string().optional(),
});
export type TGetInputSchema = z.infer<typeof ZGetInputSchema>;
@@ -0,0 +1,21 @@
import { BookingDetailsService } from "@calcom/features/bookings/services/BookingDetailsService";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TGetBookingDetailsInputSchema } from "./getBookingDetails.schema";
type GetBookingDetailsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetBookingDetailsInputSchema;
};
export const getBookingDetailsHandler = async ({ ctx, input }: GetBookingDetailsOptions) => {
const bookingDetailsService = new BookingDetailsService(prisma);
return await bookingDetailsService.getBookingDetails({
userId: ctx.user.id,
bookingUid: input.uid,
});
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetBookingDetailsInputSchema = z.object({
uid: z.string(),
});
export type TGetBookingDetailsInputSchema = z.infer<typeof ZGetBookingDetailsInputSchema>;
+1 -1
View File
@@ -170,7 +170,7 @@ export const DropdownItem = (props: DropdownItemProps) => {
props.className
)}>
<>
{CustomStartIcon || (StartIcon && <Icon name={StartIcon} className="mr-1 h-4 w-4" />)}
{CustomStartIcon || (StartIcon && <Icon name={StartIcon} className="mr-1 h-4 w-4 shrink-0" />)}
<div className={classNames("w-full text-left text-sm font-medium leading-none", childrenClassName)}>
{children}
</div>
@@ -11,10 +11,11 @@ type Props = {
onDatesChange?: ((date: Date) => void) | undefined;
className?: string;
disabled?: boolean;
minDate?: Date;
minDate?: Date | null;
label?: string;
};
const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => {
const DatePicker = ({ minDate, disabled, date, onDatesChange, className, label }: Props) => {
function handleDayClick(newDate: Date) {
onDatesChange?.(newDate ?? new Date());
}
@@ -42,7 +43,7 @@ const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props
color="secondary"
EndIcon="calendar"
className={classNames("justify-between text-left font-normal", !date && "text-subtle")}>
{date ? <>{format(date, "LLL dd, y")}</> : <span>Pick a date</span>}
{label ?? (date ? <>{format(date, "LLL dd, y")}</> : <span>Pick a date</span>)}
</Button>
</Popover.Trigger>
<Popover.Portal>
@@ -57,7 +57,7 @@ export const ToggleGroup = ({
"0px 2px 3px 0px rgba(0, 0, 0, 0.03), 0px 2px 2px -1px rgba(0, 0, 0, 0.03)",
}}
className={classNames(
`bg-subtle border-subtle rounded-[10px] border p-0.5`,
`bg-muted rounded-[10px] p-0.5`,
orientation === "horizontal" && "inline-flex gap-0.5 rtl:flex-row-reverse",
orientation === "vertical" && "flex w-fit flex-col gap-0.5",
props.className,
@@ -75,7 +75,7 @@ export const ToggleGroup = ({
"aria-checked:bg-default aria-checked:border-subtle rounded-lg border border-transparent p-1.5 text-sm leading-none transition aria-checked:shadow-[0px_2px_3px_0px_rgba(0,0,0,0.03),0px_2px_2px_-1px_rgba(0,0,0,0.03)]",
option.disabled
? "text-gray-400 hover:cursor-not-allowed"
: "text-default [&[aria-checked='false']]:hover:text-emphasis",
: "text-default [&[aria-checked='false']]:hover:text-emphasis [&[aria-checked='false']]:hover:bg-subtle cursor-pointer",
isFullWidth && "w-full"
)}>
<div
+32 -32
View File
@@ -61,48 +61,48 @@ SheetOverlay.displayName = "SheetOverlay";
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitives.Content>,
React.ComponentPropsWithoutRef<typeof SheetPrimitives.Content>
>(({ className, ...props }, forwardedRef) => {
return (
<SheetPortal>
<SheetOverlay>
<SheetPrimitives.Content
ref={forwardedRef}
className={classNames(
// base
"fixed inset-x-0 inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:inset-x-auto sm:right-2 sm:max-w-lg sm:p-6",
// "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm sm:p-6 p-4 shadow-lg rounded-xl border ",
// border color
"border-subtle",
// background color
"bg-default",
// transition
"data-[state=closed]:animate-SheetSlideRightAndFade data-[state=open]:animate-SheetSlideLeftAndFade",
className
)}
{...props}
/>
</SheetOverlay>
</SheetPortal>
React.ComponentPropsWithoutRef<typeof SheetPrimitives.Content> & {
hideOverlay?: boolean;
}
>(({ className, hideOverlay = false, ...props }, forwardedRef) => {
const content = (
<SheetPrimitives.Content
ref={forwardedRef}
className={classNames(
// base
"fixed inset-x-0 inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:inset-x-auto sm:right-2 sm:max-w-lg sm:p-6",
// "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm sm:p-6 p-4 shadow-lg rounded-xl border ",
// border color
"border-subtle",
// background color
"bg-default",
// transition
"data-[state=closed]:animate-SheetSlideRightAndFade data-[state=open]:animate-SheetSlideLeftAndFade",
// Increase z-index when no overlay to ensure it's above other content
hideOverlay && "z-50",
className
)}
{...props}
/>
);
return <SheetPortal>{hideOverlay ? content : <SheetOverlay>{content}</SheetOverlay>}</SheetPortal>;
});
SheetContent.displayName = "SheetContent";
const SheetHeader = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div"> & { showCloseButton?: boolean; rightContent?: React.ReactNode }
>(({ children, className, showCloseButton = true, rightContent, ...props }, ref) => {
React.ComponentPropsWithoutRef<"div"> & { showCloseButton?: boolean }
>(({ children, className, showCloseButton = true, ...props }, ref) => {
return (
<div ref={ref} className="flex items-start justify-between gap-x-4 pb-2" {...props}>
<div className={classNames("mt-1 flex flex-col gap-y-1", className)}>{children}</div>
{rightContent !== undefined
? rightContent
: showCloseButton && (
<SheetPrimitives.Close asChild>
<Button variant="icon" StartIcon="x" color="minimal" className="aspect-square p-1" />
</SheetPrimitives.Close>
)}
{showCloseButton && (
<SheetPrimitives.Close asChild>
<Button variant="icon" StartIcon="x" color="minimal" className="aspect-square p-1" />
</SheetPrimitives.Close>
)}
</div>
);
});
@@ -20,7 +20,7 @@ export type ActionType = {
label: string;
disabled?: boolean;
color?: ButtonBaseProps["color"];
bookingId?: number;
bookingUid?: string;
} & (
| { href: string; onClick?: never; actions?: never }
| { href?: never; onClick: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void; actions?: never }
@@ -61,7 +61,7 @@ export const DropdownActions = ({
data-testid={action.id}
StartIcon={action.icon}
href={action.href}
data-bookingid={action.bookingId}
data-booking-uid={action.bookingUid}
onClick={action.onClick || defaultAction}>
{action.label}
</DropdownItem>
@@ -88,7 +88,7 @@ export const TableActions: FC<Props> = ({ actions }) => {
StartIcon={action.icon}
{...(action?.actions ? { EndIcon: "chevron-down" } : null)}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-booking-uid={action.bookingUid}
color={action.color || "secondary"}>
{action.label}
</Button>
+2 -2
View File
@@ -6,7 +6,7 @@ const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableE
({ className, ...props }, ref) => (
<table
ref={ref}
className={classNames("border-subtle w-full caption-bottom border text-sm", className)}
className={classNames("border-muted w-full caption-bottom border text-sm", className)}
{...props}
/>
)
@@ -39,7 +39,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr
ref={ref}
className={classNames(
"hover:bg-cal-muted data-[state=selected]:bg-subtle border-subtle border-b transition",
"hover:bg-cal-muted data-[state=selected]:bg-subtle border-muted border-b transition",
className
)}
{...props}
+494 -108
View File
@@ -3,9 +3,10 @@ import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { RefundPolicy } from "@calcom/lib/payment/types";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus, AssignmentReasonEnum } from "@calcom/prisma/enums";
import { BookingStatus, AssignmentReasonEnum, PaymentOption } from "@calcom/prisma/enums";
import { seedAttributes, seedRoutingFormResponses, seedRoutingForms } from "./seed-utils";
@@ -35,8 +36,18 @@ function getRandomRatingFeedback() {
return feedbacks[Math.floor(Math.random() * feedbacks.length)];
}
type BookingInput = {
uid: string;
title: string;
description: string;
startTime: string;
endTime: string;
userId: number;
[key: string]: unknown;
};
const shuffle = (
booking: any,
booking: BookingInput,
year: number,
eventTypesToPick: Prisma.EventTypeGetPayload<{
select: {
@@ -46,6 +57,7 @@ const shuffle = (
length: true;
description: true;
teamId: true;
recurringEvent: true;
};
}>[],
usersIdsToPick?: number[]
@@ -60,7 +72,7 @@ const shuffle = (
const endTime = dayjs(startTime).add(Math.floor(Math.random() * randomEvent.length), "minute");
booking.title = randomEvent.title;
booking.description = randomEvent.description;
booking.description = randomEvent.description || "";
booking.startTime = startTime.toISOString();
booking.endTime = endTime.toISOString();
booking.createdAt = startTime.subtract(1, "day").toISOString();
@@ -80,13 +92,11 @@ const shuffle = (
if (usersIdsToPick && usersIdsToPick.length > 0) {
// Pick a random user from all available users instead of just two
booking.userId = usersIdsToPick[Math.floor(Math.random() * usersIdsToPick.length)];
} else {
} else if (randomEvent.userId) {
booking.userId = randomEvent.userId;
}
if (booking.userId === undefined || booking.userId === null) {
} else {
console.log({ randomEvent, usersIdsToPick });
console.log("This should not happen");
throw new Error("No valid userId available for booking");
}
booking.rating = Math.floor(Math.random() * 5) + 1; // Generates a random rating from 1 to 5
@@ -125,24 +135,40 @@ const shuffle = (
return booking;
};
async function createAttendees(bookings: any[]) {
for (const booking of bookings) {
type BookingWithId = {
id: number;
};
async function createAttendees(bookings: BookingWithId[]) {
// Batch create all attendees at once
const allAttendees = bookings.flatMap((booking) =>
Array(Math.floor(Math.random() * 4))
.fill(null)
.map(() => ({
bookingId: booking.id,
timeZone: faker.location.timeZone(),
email: faker.internet.email(),
name: faker.person.fullName(),
}))
);
if (allAttendees.length > 0) {
await prisma.attendee.createMany({
data: Array(Math.floor(Math.random() * 4))
.fill(null)
.map(() => {
return {
bookingId: booking.id,
timeZone: faker.location.timeZone(),
email: faker.internet.email(),
name: faker.person.fullName(),
};
}),
data: allAttendees,
});
}
}
async function createPayments(bookings: any[]) {
type BookingWithEventType = {
id: number;
paid: boolean;
eventType?: {
price?: number;
currency?: string;
};
};
async function _createPayments(bookings: BookingWithEventType[]) {
// Filter for paid bookings
const paidBookings = bookings.filter((booking) => booking.paid);
@@ -178,8 +204,61 @@ async function createPayments(bookings: any[]) {
console.log(`Successfully created payments for ${paidBookings.length} bookings`);
}
async function createPaidEventTypeAndBookings(organizationId: number) {
console.log("Creating paid event type and bookings...");
type PaymentScenario = {
status: BookingStatus;
paid: boolean;
success: boolean;
refunded: boolean;
paymentOption?: PaymentOption;
startTime: dayjs.Dayjs;
cancellationReason?: string;
scenarioName: string;
};
type BookingWithPaymentData = {
id: number;
eventType?: {
price?: number;
currency?: string;
};
paymentSuccess: boolean;
paymentRefunded: boolean;
paymentOption: PaymentOption;
};
async function createPaymentsForScenarios(bookings: BookingWithPaymentData[]) {
console.log(`Creating payments for ${bookings.length} bookings with diverse scenarios...`);
// Batch create all payments at once
const paymentData = bookings.map((booking) => {
const amount = booking.eventType?.price || 5000;
const currency = booking.eventType?.currency || "usd";
const paymentOption = booking.paymentOption || PaymentOption.ON_BOOKING;
return {
uid: uuidv4(),
appId: "stripe",
bookingId: booking.id,
amount,
fee: Math.floor(amount * 0.029) + 30,
currency,
success: booking.paymentSuccess,
refunded: booking.paymentRefunded,
paymentOption,
data: {},
externalId: `ch_${uuidv4().substring(0, 24)}`,
};
});
await prisma.payment.createMany({
data: paymentData,
});
console.log(`Successfully created payments for ${bookings.length} bookings`);
}
async function createDiversePaymentBookings(organizationId: number) {
console.log("\n=== Creating diverse payment event types and bookings ===");
// Find the insights team
const insightsTeam = await prisma.team.findFirst({
@@ -190,7 +269,7 @@ async function createPaidEventTypeAndBookings(organizationId: number) {
});
if (!insightsTeam) {
console.log("Insights team not found, skipping paid event type creation");
console.log("Insights team not found, skipping payment event type creation");
return;
}
@@ -205,108 +284,401 @@ async function createPaidEventTypeAndBookings(organizationId: number) {
});
if (insightsTeamMembers.length === 0) {
console.log("No team members found, skipping paid event type creation");
console.log("No team members found, skipping payment event type creation");
return;
}
// Check if paid event type already exists
let paidEventType = await prisma.eventType.findFirst({
where: {
slug: "paid-consultation",
teamId: insightsTeam.id,
// Create multiple event types with different pricing and refund policies
const eventTypeConfigs = [
{
slug: "standard-consultation",
title: "Standard Consultation",
price: 5000,
refundPolicy: RefundPolicy.ALWAYS,
description: "1-hour consultation with full refund policy",
},
});
{
slug: "premium-consultation",
title: "Premium Consultation",
price: 10000,
refundPolicy: RefundPolicy.DAYS,
refundDaysCount: 7,
description: "Premium 1-hour consultation with 7-day refund window",
},
{
slug: "non-refundable-session",
title: "Non-Refundable Session",
price: 7500,
refundPolicy: RefundPolicy.NEVER,
description: "1-hour session - non-refundable",
},
{
slug: "consultation-with-hold",
title: "Consultation with Payment Hold",
price: 5000,
refundPolicy: RefundPolicy.ALWAYS,
paymentOption: PaymentOption.HOLD,
description: "1-hour consultation with payment hold option",
},
];
// Create paid event type if it doesn't exist
if (!paidEventType) {
paidEventType = await prisma.eventType.create({
data: {
title: "Paid Consultation",
slug: "paid-consultation",
description: "1-hour paid consultation session",
length: 60,
type EventTypeWithRequiredFields = Prisma.EventTypeGetPayload<{
select: {
id: true;
title: true;
slug: true;
description: true;
length: true;
price: true;
currency: true;
metadata: true;
};
}>;
const createdEventTypes: EventTypeWithRequiredFields[] = [];
for (const config of eventTypeConfigs) {
let eventType = await prisma.eventType.findFirst({
where: {
slug: config.slug,
teamId: insightsTeam.id,
schedulingType: "ROUND_ROBIN",
assignAllTeamMembers: true,
price: 5000, // $50.00
currency: "usd",
metadata: {
apps: {
stripe: {
price: 5000,
currency: "usd",
enabled: true,
},
},
},
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
price: true,
currency: true,
metadata: true,
},
});
// Create Host records for all team members
await prisma.host.createMany({
data: insightsTeamMembers.map((member) => ({
userId: member.user.id,
eventTypeId: paidEventType!.id,
isFixed: false,
})),
skipDuplicates: true,
});
if (!eventType) {
type StripeMetadata = {
price: number;
currency: string;
enabled: boolean;
refundPolicy: RefundPolicy;
refundDaysCount?: number;
paymentOption?: PaymentOption;
};
console.log(`Created paid event type: ${paidEventType.title}`);
const stripeConfig: StripeMetadata = {
price: config.price,
currency: "usd",
enabled: true,
refundPolicy: config.refundPolicy,
};
if (config.refundDaysCount) {
stripeConfig.refundDaysCount = config.refundDaysCount;
}
if (config.paymentOption) {
stripeConfig.paymentOption = config.paymentOption;
}
const metadata: Prisma.InputJsonValue = {
apps: {
stripe: stripeConfig,
},
};
const createdEventType = await prisma.eventType.create({
data: {
title: config.title,
slug: config.slug,
description: config.description,
length: 60,
teamId: insightsTeam.id,
schedulingType: "ROUND_ROBIN",
assignAllTeamMembers: true,
price: config.price,
currency: "usd",
metadata,
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
price: true,
currency: true,
metadata: true,
},
});
// Create Host records for all team members
await prisma.host.createMany({
data: insightsTeamMembers.map((member) => ({
userId: member.user.id,
eventTypeId: createdEventType.id,
isFixed: false,
})),
skipDuplicates: true,
});
console.log(`Created event type: ${createdEventType.title}`);
eventType = createdEventType;
}
createdEventTypes.push(eventType);
}
// Create 100 paid bookings
const baseBooking = {
uid: "demoUID",
title: "Paid Consultation",
description: "1-hour paid consultation session",
startTime: dayjs().toISOString(),
endTime: dayjs().toISOString(),
userId: insightsTeamMembers[0].user.id,
// Define payment scenarios with specific distributions
const scenarios: {
eventTypeIndex: number;
count: number;
scenario: Partial<PaymentScenario>;
}[] = [
// Scenario 1: Successful paid bookings (20%)
{
eventTypeIndex: 0,
count: 20,
scenario: {
status: BookingStatus.ACCEPTED,
paid: true,
success: true,
refunded: false,
startTime: dayjs().add(7, "days"),
scenarioName: "Successful Paid",
},
},
// Scenario 2: Awaiting payment (15%)
{
eventTypeIndex: 0,
count: 15,
scenario: {
status: BookingStatus.ACCEPTED,
paid: true,
success: false,
refunded: false,
startTime: dayjs().add(5, "days"),
scenarioName: "Awaiting Payment",
},
},
// Scenario 3: Awaiting payment with HOLD option (10%)
{
eventTypeIndex: 3,
count: 10,
scenario: {
status: BookingStatus.PENDING,
paid: true,
success: false,
refunded: false,
paymentOption: PaymentOption.HOLD,
startTime: dayjs().add(3, "days"),
scenarioName: "Awaiting Payment (HOLD)",
},
},
// Scenario 4: Cancelled with refund completed (10%)
{
eventTypeIndex: 0,
count: 10,
scenario: {
status: BookingStatus.CANCELLED,
paid: true,
success: true,
refunded: true,
startTime: dayjs().subtract(2, "days"),
cancellationReason: "Customer requested refund",
scenarioName: "Cancelled - Refunded",
},
},
// Scenario 5: Cancelled with ALWAYS policy (10%)
{
eventTypeIndex: 0,
count: 10,
scenario: {
status: BookingStatus.CANCELLED,
paid: true,
success: true,
refunded: false,
startTime: dayjs().subtract(1, "days"),
cancellationReason: "Change of plans",
scenarioName: "Cancelled - ALWAYS policy (no refund yet)",
},
},
// Scenario 6: Cancelled with NEVER policy (10%)
{
eventTypeIndex: 2,
count: 10,
scenario: {
status: BookingStatus.CANCELLED,
paid: true,
success: true,
refunded: false,
startTime: dayjs().subtract(3, "days"),
cancellationReason: "Emergency cancellation",
scenarioName: "Cancelled - NEVER policy",
},
},
// Scenario 7: Cancelled DAYS policy - within window (10%)
{
eventTypeIndex: 1,
count: 10,
scenario: {
status: BookingStatus.CANCELLED,
paid: true,
success: true,
refunded: false,
startTime: dayjs().add(5, "days"),
cancellationReason: "Need to reschedule",
scenarioName: "Cancelled - DAYS policy (within window)",
},
},
// Scenario 8: Cancelled DAYS policy - expired (10%)
{
eventTypeIndex: 1,
count: 10,
scenario: {
status: BookingStatus.CANCELLED,
paid: true,
success: true,
refunded: false,
startTime: dayjs().subtract(10, "days"),
cancellationReason: "Late cancellation",
scenarioName: "Cancelled - DAYS policy (expired window)",
},
},
// Scenario 9: Cancelled without success (5%)
{
eventTypeIndex: 0,
count: 5,
scenario: {
status: BookingStatus.CANCELLED,
paid: true,
success: false,
refunded: false,
startTime: dayjs().add(2, "days"),
cancellationReason: "Payment failed, cancelled",
scenarioName: "Cancelled - No payment success",
},
},
];
// Create bookings for each scenario
type ScenarioBooking = {
uid: string;
title: string;
description: string;
startTime: string;
endTime: string;
createdAt: string;
status: BookingStatus;
eventTypeId: number;
userId: number;
paid: boolean;
rescheduled: boolean;
cancellationReason: string | null;
paymentSuccess: boolean;
paymentRefunded: boolean;
paymentOption: PaymentOption;
scenarioName: string;
};
console.log(`Creating 100 paid bookings for event type: ${paidEventType.title}`);
const allScenarioBookings: ScenarioBooking[] = [];
for (const { eventTypeIndex, count, scenario } of scenarios) {
const eventType = createdEventTypes[eventTypeIndex];
for (let i = 0; i < count; i++) {
const startTime = scenario.startTime!;
const endTime = dayjs(startTime).add(60, "minutes");
allScenarioBookings.push({
uid: uuidv4(),
title: eventType.title,
description: eventType.description || "",
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
createdAt: startTime.subtract(1, "day").toISOString(),
status: scenario.status!,
eventTypeId: eventType.id,
userId: insightsTeamMembers[Math.floor(Math.random() * insightsTeamMembers.length)].user.id,
paid: scenario.paid!,
rescheduled: false,
cancellationReason: scenario.cancellationReason || null,
// Store payment details temporarily for payment creation
paymentSuccess: scenario.success!,
paymentRefunded: scenario.refunded!,
paymentOption: scenario.paymentOption || PaymentOption.ON_BOOKING,
scenarioName: scenario.scenarioName!,
});
}
console.log(`Prepared ${count} bookings for scenario: ${scenario.scenarioName}`);
}
console.log(`Creating ${allScenarioBookings.length} bookings across all scenarios...`);
// Insert all bookings
await prisma.booking.createMany({
data: [
...new Array(100).fill(0).map(() => {
const booking = shuffle(
{ ...baseBooking },
dayjs().get("y"),
[paidEventType!],
insightsTeamMembers.map((m) => m.user.id)
);
// Override status for paid bookings - they should always be accepted
booking.status = "ACCEPTED";
booking.paid = true;
booking.rescheduled = false;
return booking;
}),
],
data: allScenarioBookings.map((booking) => {
// Remove temporary payment fields before inserting
const {
paymentSuccess: _ps,
paymentRefunded: _pr,
paymentOption: _po,
scenarioName: _sn,
...bookingData
} = booking;
return bookingData;
}),
});
// Get the paid bookings we just created
const paidBookings = await prisma.booking.findMany({
// Get created bookings with event types
// Use a more specific query to get only the bookings we just created
const eventTypeIds = createdEventTypes.map((et) => et.id);
const createdBookings = await prisma.booking.findMany({
where: {
eventTypeId: paidEventType.id,
paid: true,
eventTypeId: { in: eventTypeIds },
paid: true, // Only get paid bookings since we created them with paid: true
},
include: {
eventType: {
select: {
price: true,
currency: true,
metadata: true,
},
},
},
orderBy: {
id: "desc", // Order by ID is faster than createdAt
},
take: allScenarioBookings.length,
});
// Create attendees for paid bookings
await createAttendees(paidBookings);
// Match created bookings with scenario data to restore payment fields
const bookingsForPayments: BookingWithPaymentData[] = createdBookings
.slice(0, allScenarioBookings.length)
.map((booking, index) => {
const scenarioBooking = allScenarioBookings[allScenarioBookings.length - 1 - index]; // Reverse order since we sorted desc
return {
id: booking.id,
eventType: booking.eventType || undefined,
paymentSuccess: scenarioBooking.paymentSuccess,
paymentRefunded: scenarioBooking.paymentRefunded,
paymentOption: scenarioBooking.paymentOption,
};
});
// Create payments for paid bookings
await createPayments(paidBookings);
// Create attendees for all bookings
await createAttendees(createdBookings);
console.log(`Successfully created ${paidBookings.length} paid bookings with payments`);
// Create payments with appropriate states for each scenario
await createPaymentsForScenarios(bookingsForPayments);
console.log(`Successfully created ${allScenarioBookings.length} bookings with diverse payment scenarios`);
console.log("\nScenario distribution:");
scenarios.forEach(({ count, scenario }) => {
console.log(` - ${scenario.scenarioName}: ${count} bookings`);
});
console.log("=== Diverse payment bookings creation completed ===\n");
}
async function seedBookingAssignments() {
@@ -518,7 +890,7 @@ async function main() {
// After creating or fetching teamEvents and insightsTeamMembers
// Create Host records for all team members for each event with assignAllTeamMembers true
const assignAllTeamMembersEvents = teamEvents.filter((event) => (event as any).assignAllTeamMembers);
const assignAllTeamMembersEvents = teamEvents.filter((event) => event.assignAllTeamMembers);
if (assignAllTeamMembersEvents.length > 0 && insightsTeamMembers.length > 0) {
const hostRecords = assignAllTeamMembersEvents.flatMap((event) =>
insightsTeamMembers.map((member) => ({
@@ -550,9 +922,11 @@ async function main() {
};
// Create past bookings -2y, -1y, -0y
// NOTE: Reduced from 10000 to 1000 to avoid transaction timeout
// Increase these numbers back to 10000 if you need more data for performance testing
await prisma.booking.createMany({
data: [
...new Array(10000).fill(0).map(() =>
...new Array(1000).fill(0).map(() =>
shuffle(
{ ...baseBooking },
dayjs().get("y") - 2,
@@ -565,7 +939,7 @@ async function main() {
await prisma.booking.createMany({
data: [
...new Array(10000).fill(0).map(() =>
...new Array(1000).fill(0).map(() =>
shuffle(
{ ...baseBooking },
dayjs().get("y") - 1,
@@ -578,7 +952,7 @@ async function main() {
await prisma.booking.createMany({
data: [
...new Array(10000).fill(0).map(() =>
...new Array(1000).fill(0).map(() =>
shuffle(
{ ...baseBooking },
dayjs().get("y"),
@@ -600,10 +974,15 @@ async function main() {
if (!attributes) {
throw new Error("Attributes not found");
}
if (!javascriptEventId || !salesEventId) {
throw new Error("Required event types not found");
}
const seededForm = await seedRoutingForms(
insightsTeam.id,
owner?.user.id ?? insightsTeamMembers[0].user.id,
attributes
attributes,
javascriptEventId,
salesEventId
);
if (seededForm) {
@@ -612,8 +991,8 @@ async function main() {
await seedBookingAssignments();
// Create paid event type and bookings separately
await createPaidEventTypeAndBookings(organization.id);
// Create diverse payment scenarios for testing
await createDiversePaymentBookings(organization.id);
}
async function runMain() {
@@ -652,18 +1031,15 @@ async function createPerformanceData() {
});
if (createExtraMembers) {
const insightsRandomUsers: Prisma.UserCreateManyArgs["data"] = [];
const insightsRandomUsers: { email: string; name: string; username: string }[] = [];
const numInsightsUsers = 50; // Change this value to adjust the number of insights users to create
const passwordHash = await hashPassword("insightsuser");
for (let i = 0; i < numInsightsUsers; i++) {
const timestamp = Date.now();
const email = `insightsuser${timestamp}@example.com`;
const insightsUser = {
email,
password: {
create: {
hash: await hashPassword("insightsuser"),
},
},
name: `Insights User ${timestamp}`,
username: `insights-user-${timestamp}`,
completedOnboarding: true,
@@ -674,6 +1050,7 @@ async function createPerformanceData() {
await prisma.user.createMany({
data: insightsRandomUsers,
});
// Lets find the ids of the users we just created
extraMembersIds = await prisma.user.findMany({
where: {
@@ -685,6 +1062,14 @@ async function createPerformanceData() {
id: true,
},
});
// Create passwords for all users
await prisma.userPassword.createMany({
data: extraMembersIds.map((user) => ({
userId: user.id,
hash: passwordHash,
})),
});
}
if (createExtraMembers) {
@@ -761,6 +1146,7 @@ async function createPerformanceData() {
startTime: dayjs().toISOString(),
endTime: dayjs().toISOString(),
eventTypeId: singleEventsCreated[0].id,
userId: extraMembersIds[0].id,
};
await prisma.booking.createMany({
+7 -7
View File
@@ -4515,7 +4515,7 @@ __metadata:
node-html-parser: ^6.1.10
node-mocks-http: ^1.11.0
nodemailer: ^6.7.8
nuqs: ^2.7.2
nuqs: ^2.8.2
openid-client: 6.5.0
otplib: ^12.0.1
postcss: ^8.4.18
@@ -38515,9 +38515,9 @@ __metadata:
languageName: node
linkType: hard
"nuqs@npm:^2.7.2":
version: 2.7.2
resolution: "nuqs@npm:2.7.2"
"nuqs@npm:^2.8.2":
version: 2.8.2
resolution: "nuqs@npm:2.8.2"
dependencies:
"@standard-schema/spec": 1.0.0
peerDependencies:
@@ -38525,8 +38525,8 @@ __metadata:
"@tanstack/react-router": ^1
next: ">=14.2.0"
react: ">=18.2.0 || ^19.0.0-0"
react-router: ^6 || ^7
react-router-dom: ^6 || ^7
react-router: ^5 || ^6 || ^7
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
"@remix-run/react":
optional: true
@@ -38538,7 +38538,7 @@ __metadata:
optional: true
react-router-dom:
optional: true
checksum: b6735af6841da60029bd882787536db8db3855a9fd25b1d55e37faf033da45fae8b256ba03d6b972f44dbfb9dc205e2b93ef2f339bc9ac842e6508de6e29d016
checksum: 22fc5c7e7145742ce328856dfd7da4f0a03f001890311de491f568b1e402fa8d84ea0875df21f98102090be18882d91a5ab3953a5864c27d07e9097f0a4dafa9
languageName: node
linkType: hard