import Link from "next/link";
import { useState, useEffect, useRef } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import type { getEventLocationValue } from "@calcom/app-store/locations";
import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { formatTime } from "@calcom/lib/dayjs";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import type { AssignmentReason } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
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 {
Dropdown,
DropdownItem,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/components/dropdown";
import { Icon } from "@calcom/ui/components/icon";
import { MeetingTimeInTimezones } from "@calcom/ui/components/popover";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import { WrongAssignmentDialog } from "../dialog/WrongAssignmentDialog";
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
import { useBookingDetailsSheetStore } from "../../modules/bookings/store/bookingDetailsSheetStore";
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 {
shouldShowPendingActions,
shouldShowRecurringCancelAction,
shouldShowIndividualReportButton,
type BookingActionContext,
getReportAction,
isActionDisabled,
} from "./actions/bookingActions";
import type { BookingItemProps } from "./types";
type ParsedBooking = ReturnType;
type TeamEvent = Ensure, "team">;
type TeamEventBooking = Omit & {
eventType: TeamEvent;
};
type ReroutableBooking = Ensure;
function buildParsedBooking(booking: BookingItemProps) {
// The way we fetch bookings there could be eventType object even without an eventType, but id confirms its existence
const bookingEventType = booking.eventType.id
? (booking.eventType as Ensure<
typeof booking.eventType,
// It would only ensure that the props are present, if they are optional in the original type. So, it is safe to assert here.
"id" | "length" | "title" | "slug" | "schedulingType" | "team"
>)
: null;
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
return {
...booking,
eventType: bookingEventType,
metadata: bookingMetadata,
};
}
const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooking => {
// We support only team bookings for now for rerouting
// Though `routedFromRoutingFormReponse` could be there for a non-team booking, we don't want to support it for now.
// Let's not support re-routing for a booking without an event-type for now.
// Such a booking has its event-type deleted and there might not be something to reroute to.
return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
};
const ConditionalLink = ({
children,
onClick,
bookingLink,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
bookingLink: string;
className?: string;
}) => {
const { t } = useLocale();
if (onClick) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
};
return (
{children}
);
}
return (
{children}
);
};
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const itemRef = useRef(null);
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const { onClick } = booking;
const {
t,
i18n: { language },
} = useLocale();
// Get selected booking UID from store
// The provider should always be available when BookingListItem is rendered (bookingsV3Enabled is true)
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
const isSelected = !!selectedBookingUid && selectedBookingUid === booking.uid;
// Scroll into view when this booking becomes selected
useEffect(() => {
if (isSelected && itemRef.current) {
itemRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [isSelected]);
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);
const isBookingInPast = new Date(booking.endTime) < new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED;
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
const isRejected = booking.status === BookingStatus.REJECTED;
const isPending = booking.status === BookingStatus.PENDING;
const isRescheduled = booking.fromReschedule !== null;
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
const isAttendee = !!userSeat;
const paymentAppData = getPaymentAppData(booking.eventType);
const location = booking.location as ReturnType;
const locationVideoCallUrl = parsedBooking.metadata?.videoCallUrl;
const { resolvedTheme, forcedTheme } = useGetTheme();
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
const eventTypeColor =
booking.eventType.eventTypeColor &&
booking.eventType.eventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"];
const locationToDisplay = getSuccessPageLocationMessage(
locationVideoCallUrl ? locationVideoCallUrl : location,
t,
booking.status
);
const provider = guessEventLocationType(location);
const isDisabledCancelling = booking.eventType.disableCancelling;
const isDisabledRescheduling = booking.eventType.disableRescheduling;
const cardCharged = booking?.payment[0]?.success;
const getSeatReferenceUid = () => {
return userSeat?.referenceUid;
};
const actionContext: BookingActionContext = {
booking,
isUpcoming,
isOngoing,
isBookingInPast,
isCancelled,
isConfirmed,
isRejected,
isPending,
isRescheduled,
isRecurring,
isTabRecurring,
isTabUnconfirmed,
isBookingFromRoutingForm,
isDisabledCancelling,
isDisabledRescheduling,
isCalVideoLocation:
!booking.location ||
booking.location === "integrations:daily" ||
(typeof booking.location === "string" && booking.location.trim() === ""),
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
isAttendee,
cardCharged,
attendeeList,
getSeatReferenceUid,
t,
} as BookingActionContext;
const RequestSentMessage = () => {
return (
{t("reschedule_request_sent")}
);
};
const bookingYear = dayjs(booking.startTime).year();
const currentYear = dayjs().year();
const isDifferentYear = bookingYear !== currentYear;
const startTime = dayjs(booking.startTime)
.tz(userTimeZone)
.locale(language)
.format(isUpcoming ? (isDifferentYear ? "ddd, D MMM YYYY" : "ddd, D MMM") : "D MMMM YYYY");
// Getting accepted recurring dates to show
const recurringDates = booking.recurringInfo?.bookings[BookingStatus.ACCEPTED]
.concat(booking.recurringInfo?.bookings[BookingStatus.CANCELLED])
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
const bookingLink = buildBookingLink({
bookingUid: booking.uid,
allRemainingBookings: isTabRecurring,
email: booking.attendees?.[0]?.email,
});
const title = booking.title;
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
const isOpenWrongAssignmentDialog = useBookingActionsStoreContext(
(state) => state.isOpenWrongAssignmentDialog
);
const setIsOpenWrongAssignmentDialog = useBookingActionsStoreContext(
(state) => state.setIsOpenWrongAssignmentDialog
);
const setIsOpenRoutingTraceSheet = useBookingActionsStoreContext(
(state) => state.setIsOpenRoutingTraceSheet
);
const reportAction = getReportAction(actionContext);
const reportActionWithHandler = {
...reportAction,
onClick: () => setIsOpenReportDialog(true),
};
return (
{eventTypeColor &&
}
{startTime}
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
{!isPending && (
)}
{/* Time and Badges for mobile */}
{startTime}
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
{isPending && (
{t("unconfirmed")}
)}
{booking.eventType?.team && (
{booking.eventType.team.name}
)}
{showPendingPayment && (
{t("pending_payment")}
)}
{isRescheduled && (
{t("rescheduled")}
)}
{recurringDates !== undefined && (
)}
{title}
{showPendingPayment && (
{t("pending_payment")}
)}
{booking.description && (
"{booking.description}"
)}
{booking.attendees.length !== 0 && (
)}
{!isPending && (
)}
{isCancelled && booking.rescheduled && (
)}
{shouldShowPendingActions(actionContext) && (
)}
{shouldShowRecurringCancelAction(actionContext) && (
{
e.stopPropagation();
setIsCancelDialogOpen(true);
}}
StartIcon="circle-x"
disabled={isActionDisabled("cancel", actionContext)}
data-booking-uid={booking.uid}
color="destructive">
{t("cancel_all_remaining")}
)}
{isCancelled && booking.rescheduled && (
)}
{shouldShowIndividualReportButton(actionContext) && (
)}
0 ? () => setIsOpenRoutingTraceSheet(true) : undefined
}
/>
{isBookingFromRoutingForm && (
)}
);
}
const BookingItemBadges = ({
booking,
isPending,
isRejected,
recurringDates,
userTimeFormat,
userTimeZone,
isRescheduled,
onAssignmentReasonClick,
}: {
booking: BookingItemProps;
isPending: boolean;
isRejected: boolean;
recurringDates: Date[] | undefined;
userTimeFormat: number | null | undefined;
userTimeZone: string | undefined;
isRescheduled: boolean;
onAssignmentReasonClick?: () => void;
}) => {
const { t } = useLocale();
return (
{isPending && (
{t("unconfirmed")}
)}
{isRescheduled && (
{t("rescheduled")}
)}
{isRejected && !isRescheduled && booking.assignmentReasonSortedByCreatedAt.length === 0 && (
{t("rejected")}
)}
{booking.eventType?.team && (
{booking.eventType.team.name}
)}
{booking?.assignmentReasonSortedByCreatedAt.length > 0 && (
)}
{booking.report && (
{(() => {
const reasonKey = `report_reason_${booking.report.reason.toLowerCase()}`;
const reasonText = t(reasonKey);
return booking.report.description
? `${reasonText}: ${booking.report.description}`
: reasonText;
})()}
}>
{t("reported")}
)}
{booking.paid && !booking.payment[0] ? (
{t("error_collecting_card")}
) : booking.paid ? (
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
) : null}
{recurringDates !== undefined && (
)}
);
};
interface RecurringBookingsTooltipProps {
booking: BookingItemProps;
recurringDates: Date[];
userTimeZone: string | undefined;
userTimeFormat: number | null | undefined;
}
const RecurringBookingsTooltip = ({
booking,
recurringDates,
userTimeZone,
userTimeFormat,
}: RecurringBookingsTooltipProps) => {
const {
t,
i18n: { language },
} = useLocale();
const now = new Date();
const recurringCount = recurringDates.filter((recurringDate) => {
return (
recurringDate >= now &&
!booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
.map((date) => date.toString())
.includes(recurringDate.toString())
);
}).length;
return (
(booking.recurringInfo &&
booking.eventType?.recurringEvent?.freq != null &&
(booking.listingStatus === "recurring" ||
booking.listingStatus === "unconfirmed" ||
booking.listingStatus === "cancelled") && (
{
const pastOrCancelled =
aDate < now ||
booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
.map((date) => date.toString())
.includes(aDate.toString());
return (
{formatTime(aDate, userTimeFormat, userTimeZone)}
{" - "}
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
);
})}>
{booking.status === BookingStatus.ACCEPTED
? `${t("event_remaining_other", {
count: recurringCount,
})}`
: getEveryFreqFor({
t,
recurringEvent: booking.eventType.recurringEvent,
recurringCount: booking.recurringInfo.count,
})}
)) ||
null
);
};
interface UserProps {
id: number;
name: string | null;
email: string;
}
const FirstAttendee = ({
user,
currentEmail,
hideOrganizerEmail,
}: {
user: UserProps;
currentEmail: string | null | undefined;
hideOrganizerEmail?: boolean;
}) => {
const { t } = useLocale();
if (user.email === currentEmail) {
return {t("you")}
;
}
if (hideOrganizerEmail) {
return {user.name || ""} ;
}
return (
e.stopPropagation()}>
{user.name || user.email}
);
};
type NoShowProps = {
bookingUid: string;
isBookingInPast: boolean;
};
const Attendee = (
attendeeProps: BookingAttendee &
NoShowProps & {
hideOrganizerEmail?: boolean;
organizerEmail?: string | null;
eventTypeHosts?: Array<{ user: { email: string } | null }> | null;
}
) => {
const {
email,
name,
bookingUid,
isBookingInPast,
noShow,
phoneNumber,
user,
hideOrganizerEmail,
organizerEmail,
eventTypeHosts,
} = attendeeProps;
const { t } = useLocale();
const utils = trpc.useUtils();
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
const displayName = user?.name || name || user?.email || email;
const isTeamMemberOrHost =
email === organizerEmail || eventTypeHosts?.some((host) => host.user?.email === email);
const shouldHideEmail = hideOrganizerEmail && isTeamMemberOrHost;
return (
e.stopPropagation()}
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
{noShow ? (
<>
{displayName}
>
) : (
<>{displayName}>
)}
{!isSmsCalEmail(email) && !shouldHideEmail && (
{
setOpenDropdown(false);
e.stopPropagation();
}}>
{t("email")}
)}
{
e.preventDefault();
const isEmailCopied = isSmsCalEmail(email);
copyToClipboard(isEmailCopied ? email : (phoneNumber ?? ""));
setOpenDropdown(false);
showToast(isEmailCopied ? t("email_copied") : t("phone_number_copied"), "success");
}}>
{!isCopied ? t("copy") : t("copied")}
{isBookingInPast && (
{
e.preventDefault();
setOpenDropdown(false);
noShowMutation.mutate({ bookingUid, attendees: [{ noShow: !noShow, email }] });
}}
StartIcon={noShow ? "eye" : "eye-off"}>
{noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
)}
);
};
type GroupedAttendeeProps = {
attendees: BookingAttendee[];
bookingUid: string;
};
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
const { bookingUid, attendees } = groupedAttendeeProps;
const { t } = useLocale();
const utils = trpc.useUtils();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(t(data.message), "success");
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
type FormValues = {
attendees: Array<{ id: number; email: string; noShow: boolean }>;
};
const { control, handleSubmit } = useForm({
defaultValues: {
attendees: attendees.map((a) => ({ id: a.id, email: a.email, noShow: a.noShow || false })),
},
mode: "onBlur",
});
const { fields } = useFieldArray({
control,
name: "attendees",
});
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);
};
const [openDropdown, setOpenDropdown] = useState(false);
return (
e.stopPropagation()}
className="radix-state-open:text-blue-500 transition hover:text-blue-500 focus:outline-none">
{t("plus_more", { count: attendees.length - 1 })}
{t("mark_as_no_show_title")}
);
};
const GroupedGuests = ({ guests }: { guests: BookingAttendee[] }) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { t } = useLocale();
const { copyToClipboard, isCopied } = useCopy();
const [selectedEmail, setSelectedEmail] = useState("");
return (
{
setOpenDropdown(value);
setSelectedEmail("");
}}>
e.stopPropagation()}
className="radix-state-open:text-blue-500 transition hover:text-blue-500 focus:outline-none">
{t("plus_more", { count: guests.length - 1 })}
{t("guests")}
{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 (
{
e.preventDefault();
setSelectedEmail(guest.email);
}}>
{hasName ? (
<>
{displayName}
{guest.email}
>
) : (
{guest.email}
)}
);
})}
{
setOpenDropdown(false);
e.stopPropagation();
}}>
{t("email")}
{
e.preventDefault();
copyToClipboard(selectedEmail);
showToast(t("email_copied"), "success");
}}>
{!isCopied ? t("copy") : t("copied")}
);
};
const DisplayAttendees = ({
attendees,
user,
currentEmail,
bookingUid,
isBookingInPast,
hideOrganizerEmail,
organizerEmail,
eventTypeHosts,
}: {
attendees: BookingAttendee[];
user: UserProps | null;
currentEmail?: string | null;
bookingUid: string;
isBookingInPast: boolean;
hideOrganizerEmail?: boolean;
organizerEmail?: string | null;
eventTypeHosts?: Array<{ user: { email: string } | null }> | null;
}) => {
const { t } = useLocale();
attendees.sort((a, b) => a.id - b.id);
return (
e.stopPropagation()}>
{user && (
)}
{attendees.length > 1 ?
, :
{t("and")} }
{attendees.length > 1 && (
<>
{t("and")}
{attendees.length > 2 ? (
(
))}>
{isBookingInPast ? (
) : (
)}
) : (
)}
>
)}
);
};
const AssignmentReasonTooltip = ({
assignmentReason,
onClick,
}: {
assignmentReason: AssignmentReason;
onClick?: () => void;
}) => {
const { t } = useLocale();
const badgeTitle = assignmentReasonBadgeTitleMap(assignmentReason.reasonEnum);
return (
{assignmentReason.reasonString}
}>
{t(badgeTitle)}
);
};
// Wrap BookingListItem with BookingActionsStoreProvider to provide isolated store for each booking
const BookingListItemWithProvider = (props: BookingItemProps) => {
return (
);
};
export default BookingListItemWithProvider;