Files
Eunjae LeeGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
896dfd50fd fix: prevent BookingDetailsSheet flicker when switching bookings (#27894)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-13 11:53:22 +01:00

976 lines
30 KiB
TypeScript

"use client";
import dayjs from "@calcom/dayjs";
import { useBookingLocation } from "@calcom/features/bookings/hooks";
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
import { formatPrice } from "@calcom/lib/currencyConversions";
import { formatToLocalizedTimezone } from "@calcom/lib/dayjs";
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 { BookingStatus } from "@calcom/prisma/enums";
import {
bookingMetadataSchema,
EventTypeMetaDataSchema,
eventTypeBookingFields,
} 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";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { SegmentedControl } from "@calcom/ui/components/segmented-control";
import {
Sheet,
SheetBody,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@calcom/ui/components/sheet";
import { ExternalLinkIcon, RepeatIcon } from "@coss/ui/icons";
import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import Link from "next/link";
import { useMemo, useRef } from "react";
import type { z } from "zod";
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import { RejectBookingButton } from "../../../components/booking/RejectBookingButton";
import type { BookingListingStatus } from "../../../components/booking/types";
import { usePaymentStatus } from "../hooks/usePaymentStatus";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
import { JoinMeetingButton } from "./JoinMeetingButton";
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
interface BookingDetailsSheetProps {
userTimeZone?: string;
userTimeFormat?: number;
userId?: number;
userEmail?: string;
bookingAuditEnabled?: boolean;
}
export function BookingDetailsSheet({
userTimeZone,
userTimeFormat,
userId,
userEmail,
bookingAuditEnabled = false,
}: BookingDetailsSheetProps) {
const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking());
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
const lastBookingRef = useRef<BookingOutput | null>(null);
if (booking) {
lastBookingRef.current = booking;
}
if (!selectedBookingUid) {
lastBookingRef.current = null;
}
const displayBooking = booking ?? lastBookingRef.current;
if (!displayBooking) return null;
return (
<BookingActionsStoreProvider>
<BookingDetailsSheetInner
booking={displayBooking}
userTimeZone={userTimeZone}
userTimeFormat={userTimeFormat}
userId={userId}
userEmail={userEmail}
bookingAuditEnabled={bookingAuditEnabled}
/>
</BookingActionsStoreProvider>
);
}
interface BookingDetailsSheetInnerProps {
booking: BookingOutput;
userTimeZone?: string;
userTimeFormat?: number;
userId?: number;
userEmail?: string;
bookingAuditEnabled?: boolean;
}
function useActiveSegment(bookingAuditEnabled: boolean) {
const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore((state) => [
state.activeSegment,
state.setActiveSegment,
]);
const getDerivedActiveSegment = ({
activeSegment,
bookingAuditEnabled,
}: {
activeSegment: "info" | "history" | null;
bookingAuditEnabled: boolean;
}) => {
if (!bookingAuditEnabled && activeSegment === "history") {
return "info";
}
return activeSegment ?? "info";
};
const derivedActiveSegment = getDerivedActiveSegment({ activeSegment, bookingAuditEnabled });
const setDerivedActiveSegment = (segment: "info" | "history") => {
setActiveSegmentInStore(getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled }));
};
return [derivedActiveSegment, setDerivedActiveSegment] as const;
}
function BookingDetailsSheetInner({
booking,
userTimeZone,
userTimeFormat,
userId,
userEmail,
bookingAuditEnabled = false,
}: BookingDetailsSheetInnerProps) {
const { t } = useLocale();
const [activeSegment, setActiveSegment] = useActiveSegment(bookingAuditEnabled);
// Fetch additional booking details for reschedule information
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
{ uid: booking.uid },
{
// Keep data fresh but don't refetch too aggressively
staleTime: 5 * 60 * 1000, // 5 minutes
}
);
// Get navigation state from the store in a single selector
const navigation = useBookingDetailsSheetStore((state) => {
const hasNextInArray = state.hasNextInArray();
const hasPreviousInArray = state.hasPreviousInArray();
const isLastInArray = state.isLastInArray();
const isFirstInArray = state.isFirstInArray();
return {
navigateNext: state.navigateNext,
navigatePrevious: state.navigatePrevious,
isTransitioning: state.isTransitioning,
setSelectedBookingUid: state.setSelectedBookingUid,
setActiveSegment: state.setActiveSegment,
canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
};
});
const handleClose = () => {
navigation.setSelectedBookingUid(null);
navigation.setActiveSegment(null);
};
const handleNext = () => {
navigation.navigateNext();
};
const handlePrevious = () => {
navigation.navigatePrevious();
};
const startTime = dayjs(booking.startTime).tz(userTimeZone);
const endTime = dayjs(booking.endTime).tz(userTimeZone);
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") };
case "PENDING":
return { variant: "orange" as const, label: t("pending") };
case "CANCELLED":
return { variant: "red" as const, label: t("cancelled") };
case "REJECTED":
return { variant: "red" as const, label: t("rejected") };
default:
return { variant: "gray" as const, label: booking.status };
}
}, [booking.status, booking.rescheduled, t]);
const isPending = booking.status === BookingStatus.PENDING;
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
const recurringInfo =
booking.recurringEventId && booking.eventType?.recurringEvent
? {
count: booking.eventType.recurringEvent.count,
recurringEvent: booking.eventType.recurringEvent,
}
: null;
const customResponses = booking.responses
? Object.entries(booking.responses as Record<string, unknown>)
.filter(([fieldName]) => shouldShowFieldInCustomResponses(fieldName))
.map(([question, answer]) => [question, answer] as [string, unknown])
: [];
const reason =
booking.assignmentReasonSortedByCreatedAt?.[booking.assignmentReasonSortedByCreatedAt.length - 1];
const reasonTitle = reason && assignmentReasonBadgeTitleMap(reason.reasonEnum);
return (
<Sheet open={true} onOpenChange={handleClose} modal={false}>
<SheetContent
className="overflow-y-auto pb-0 sm:pb-0"
hideOverlay
onInteractOutside={(e) => {
const target = e.target as HTMLElement;
const isBookingItem =
target.closest("[data-booking-list-item]") || target.closest("[data-booking-calendar-event]");
if (isBookingItem) {
e.preventDefault();
}
}}>
<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"
size="sm"
color="secondary"
StartIcon="chevron-up"
disabled={!navigation.canGoPrev || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handlePrevious();
}}
/>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-down"
disabled={!navigation.canGoNext || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handleNext();
}}
/>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="x"
onClick={(e) => {
e.preventDefault();
handleClose();
}}
/>
</div>
</div>
</SheetHeader>
<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>
{bookingAuditEnabled && (
<SegmentedControl
data={[
{ value: "info", label: t("info") },
{ value: "history", label: t("history") },
]}
value={activeSegment}
onChange={(value) => setActiveSegment(value)}
/>
)}
{activeSegment === "info" && (
<>
<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} />
<WhereSection booking={booking} meta={bookingMetadata} />
<RecurringInfoSection recurringInfo={recurringInfo} />
<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}
/>
<TrackingSection tracking={bookingDetails?.tracking} />
</>
)}
{bookingAuditEnabled && activeSegment === "history" && (
<BookingHistory bookingUid={booking.uid} />
)}
</div>
</SheetBody>
<SheetFooter className="bg-muted border-subtle -mx-4 border-t pt-0 sm:-mx-6">
<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}
/>
)
)}
<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>
</Sheet>
);
}
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();
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 {
i18n: { language },
} = useLocale();
const start = startTime instanceof Date ? dayjs(startTime).tz(timeZone) : startTime;
const end = endTime instanceof Date ? dayjs(endTime).tz(timeZone) : endTime;
const localizedTimezone = timeZone
? formatToLocalizedTimezone(start, language, timeZone) ?? timeZone
: start.format("Z");
return (
<>
<span>{start.format("dddd, MMMM D, YYYY")}</span>
<span>
{start.format("h:mma")} - {end.format("h:mma")} ({localizedTimezone})
</span>
</>
);
}
function WhoSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
return (
<Section title={t("who")}>
<div className="mt-2 flex flex-col gap-3">
{booking.user && (
<div className="flex items-center gap-4">
<Avatar
size="md"
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 leading-[1.2]">
{booking.eventType?.hideOrganizerEmail
? booking.user.name || t("organizer")
: booking.user.name || booking.user.email}
</p>
<Badge variant="purple" size="sm" className="capitalize">
{t("host")}
</Badge>
</div>
{!booking.eventType?.hideOrganizerEmail && (
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
)}
</div>
</div>
)}
{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>
{attendee.phoneNumber && (
<p className="text-default truncate text-sm leading-[1.2]">{attendee.phoneNumber}</p>
)}
<p className="text-default truncate text-sm leading-[1.2]">{attendee.email}</p>
</div>
</div>
);
})}
</div>
</Section>
);
}
function WhereSection({ booking, meta }: { booking: BookingOutput; meta: BookingMetaData | null }) {
const { t } = useLocale();
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
location: booking.location,
videoCallUrl: meta?.videoCallUrl,
t,
bookingStatus: booking.status,
});
if (booking.rescheduled) {
return null;
}
if (!locationToDisplay) {
return null;
}
if (!isLocationURL) {
return (
<Section title={t("where")}>
<p className="text-default text-sm">{locationToDisplay}</p>
</Section>
);
}
return (
<Section title={t("where")}>
<div className="flex items-center gap-2 text-sm">
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
className="h-4 w-4 shrink-0 rounded-sm"
alt={`${provider?.label} logo`}
/>
)}
<div className="flex min-w-0 items-baseline gap-1">
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
<a
href={locationToDisplay}
target="_blank"
rel="noopener noreferrer"
className="truncate text-blue-600 hover:underline">
{locationToDisplay}
</a>
</div>
</div>
</Section>
);
}
function RecurringInfoSection({
recurringInfo,
}: {
recurringInfo: { count: number; recurringEvent: RecurringEvent } | null;
}) {
const { t } = useLocale();
if (!recurringInfo || recurringInfo.count <= 0) {
return null;
}
return (
<Section title={t("recurring_event")}>
<p className="text-emphasis text-sm font-medium">
{getEveryFreqFor({
t,
recurringEvent: recurringInfo.recurringEvent,
recurringCount: recurringInfo.count,
})}
</p>
</Section>
);
}
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.assignmentReasonSortedByCreatedAt || booking.assignmentReasonSortedByCreatedAt.length === 0) {
return null;
}
const reason =
booking.assignmentReasonSortedByCreatedAt[booking.assignmentReasonSortedByCreatedAt.length - 1];
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-emphasis text-sm font-medium">{formattedPrice}</p>
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
</Section>
);
}
function SlotsSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (
!booking.eventType?.seatsShowAvailabilityCount ||
!booking.eventType?.seatsPerTimeSlot ||
booking.eventType.seatsPerTimeSlot <= 0
) {
return null;
}
const totalSeats = booking.eventType.seatsPerTimeSlot;
const takenSeats = booking.attendees.length;
return (
<Section title={t("slots")}>
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
</Section>
);
}
function CustomQuestionsSection({
customResponses,
bookingFields: rawBookingFields,
}: {
customResponses: [string, unknown][];
bookingFields?: unknown;
}) {
const { t } = useLocale();
// Parse and memoize booking fields
const bookingFields = useMemo(() => {
if (!rawBookingFields) return undefined;
const parsed = eventTypeBookingFields.safeParse(rawBookingFields);
return parsed.success ? parsed.data : undefined;
}, [rawBookingFields]);
// Filter out responses with falsy answers or empty arrays
const validResponses = useMemo(
(): [string, unknown][] =>
customResponses.filter(([, answer]) => {
if (!answer) return false;
if (Array.isArray(answer) && answer.length === 0) return false;
return true;
}),
[customResponses]
);
// Memoize field label lookups
const fieldLabels = useMemo(() => {
if (!bookingFields) return new Map<string, string>();
return new Map<string, string>(
bookingFields.map(
(field) => [field.name, field.label || t(field.defaultLabel || field.name)] as [string, string]
)
);
}, [bookingFields, t]);
if (validResponses.length === 0) {
return null;
}
return (
<>
{validResponses.map(([fieldName, answer], idx) => {
const fieldNameStr = String(fieldName);
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
return (
<Section key={idx} title={title}>
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
</Section>
);
})}
</>
);
}
// 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")}
<ExternalLinkIcon 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")}
<ExternalLinkIcon 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) {
return null;
}
return (
<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">
<RepeatIcon className="mr-1 h-3 w-3" />
{recurringInfo.count}
</Badge>
)}
</div>
);
}
function TrackingSection({
tracking,
}: {
tracking?: {
utm_source: string | null;
utm_medium: string | null;
utm_campaign: string | null;
utm_term: string | null;
utm_content: string | null;
} | null;
}) {
const { t } = useLocale();
if (!tracking) {
return null;
}
const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value));
if (utmEntries.length === 0) {
return null;
}
return (
<Section title={t("utm_params")}>
<div className="text-default text-sm">
{utmEntries.map(([key, value]) => (
<div key={key} className="mb-1 last:mb-0">
<span className="font-medium">{key}</span>:{" "}
<code className="bg-subtle text-default rounded px-1 py-0.5 font-mono text-xs">{value}</code>
</div>
))}
</div>
</Section>
);
}
function Section({
title,
className,
children,
}: {
title: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={classNames("flex flex-col gap-1", className)}>
<h3 className="text-subtle text-xs font-medium">{title}</h3>
{children}
</div>
);
}