-
+
{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 (
+
+ {reason.reasonString}
+
+ );
+}
+
+function PaymentSection({
+ booking,
+ payment,
+}: {
+ booking: BookingOutput;
+ payment: NonNullable[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 (
- {formattedPrice}
+ {formattedPrice}
+ {paymentStatusMessage && {paymentStatusMessage}
}
);
}
@@ -416,7 +606,7 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
return (
- {t("slots_taken", { takenSeats, totalSeats })}
+ {t("slots_taken", { takenSeats, totalSeats })}
);
}
@@ -470,7 +660,7 @@ function CustomQuestionsSection({
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
return (
- {String(answer)}
+ {String(answer)}
);
})}
@@ -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 && (
+
+
+
+ {t("view_booking")}
+
+
+
+
+ )}
+ {rescheduledBy && (
+
+ )}
+ {cancellationReason && (
+
+ )}
+ {cancelledBy && (
+
+ )}
+ >
+ );
+}
+
+// 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 (
+ <>
+
+ {rescheduledBy && {rescheduledBy}
}
+
+
+ {t("original_booking")}
+
+
+
+
+ {cancellationReason && (
+
+ )}
+ >
+ );
+}
+
+// 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 && (
+
+ )}
+ {cancellationReason && (
+
+ )}
+ >
+ );
+}
+
+function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.description) {
@@ -486,12 +793,54 @@ function DescriptionSection({ booking }: { booking: BookingOutput }) {
}
return (
-
- {booking.description}
+
);
}
+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 (
+
+
+ {statusBadge.label}
+
+ {reasonTitle && (
+
+ {reasonTitle}
+
+ )}
+ {booking.eventType.team && {booking.eventType.team.name}}
+ {booking.paid && !payment ? (
+ {t("error_collecting_card")}
+ ) : booking.paid ? (
+
+ {payment?.paymentOption === "HOLD" ? t("card_held") : t("paid")}
+
+ ) : null}
+ {recurringInfo && (
+
+
+ {recurringInfo.count}
+
+ )}
+
+ );
+}
+
function Section({
title,
className,
@@ -502,8 +851,8 @@ function Section({
children: React.ReactNode;
}) {
return (
-
-
{title}
+
+
{title}
{children}
);
diff --git a/apps/web/modules/bookings/components/BookingsList.tsx b/apps/web/modules/bookings/components/BookingList.tsx
similarity index 78%
rename from apps/web/modules/bookings/components/BookingsList.tsx
rename to apps/web/modules/bookings/components/BookingList.tsx
index d2dc796d6a..438263e20e 100644
--- a/apps/web/modules/bookings/components/BookingsList.tsx
+++ b/apps/web/modules/bookings/components/BookingList.tsx
@@ -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
= {
unconfirmed: "unconfirmed_bookings",
};
-type BookingsListViewProps = {
+type BookingListViewProps = {
status: BookingListingStatus;
table: ReactTable;
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={
- <>
-
- >
- }
- ToolbarRight={
- <>
-
-
-
- >
- }
+ separatorClassName="py-4 pl-6 text-xs uppercase leading-4"
LoaderView={}
EmptyView={
diff --git a/apps/web/modules/bookings/components/BookingListContainer.tsx b/apps/web/modules/bookings/components/BookingListContainer.tsx
new file mode 100644
index 0000000000..33e5835203
--- /dev/null
+++ b/apps/web/modules/bookings/components/BookingListContainer.tsx
@@ -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
>;
+ displayedFilterCount: number;
+ setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
+}
+
+function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
+ const { t } = useLocale();
+
+ if (displayedFilterCount === 0) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+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 ? (
+
+ ) : 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({
+ 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 (
+ <>
+
+ {/* Desktop: full width on first row, Mobile: full width on first row with horizontal scroll */}
+
+
+ {
+ if (!value) return;
+ const selectedTab = tabOptions.find((tab) => tab.value === value);
+ if (selectedTab?.href) {
+ router.push(selectedTab.href);
+ }
+ }}
+ options={tabOptions}
+ />
+
+
+
+ {/* Desktop: second item on first row, Mobile: first item on second row */}
+
+
+ {/* Desktop: auto-pushed to right via flex-grow spacer, Mobile: continue on second row */}
+
+
+
+ {bookingsV3Enabled &&
}
+
+ {displayedFilterCount > 0 && showFilters && (
+
+
+
+
+ {/* Desktop: auto-pushed to right via flex-grow spacer */}
+
+
+
+
+
+ )}
+ {status === "upcoming" && !isEmpty && (
+
+ )}
+
+
+
+
+ {bookingsV3Enabled && (
+
+ )}
+ >
+ );
+}
+
+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 (
+
+
+
+ );
+}
diff --git a/apps/web/modules/bookings/components/BookingsCalendar.tsx b/apps/web/modules/bookings/components/BookingsCalendar.tsx
deleted file mode 100644
index e97e07379a..0000000000
--- a/apps/web/modules/bookings/components/BookingsCalendar.tsx
+++ /dev/null
@@ -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;
- isPending?: boolean;
- currentWeekStart: dayjs.Dayjs;
- setCurrentWeekStart: (
- value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
- ) => Promise;
- 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 (
- <>
-
- {hasError && ErrorView ? (
- ErrorView
- ) : (
-
- )}
- >
- );
-}
diff --git a/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx b/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx
deleted file mode 100644
index 9bc19b095b..0000000000
--- a/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx
+++ /dev/null
@@ -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(() => {
- 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 = {};
-
- 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({
- 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 => 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 (
-
-
-
-
-
- );
-}
diff --git a/apps/web/modules/bookings/components/BookingsListContainer.tsx b/apps/web/modules/bookings/components/BookingsListContainer.tsx
deleted file mode 100644
index d9568570b4..0000000000
--- a/apps/web/modules/bookings/components/BookingsListContainer.tsx
+++ /dev/null
@@ -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();
-
- 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 (
- handleBookingClick(booking.id) })}
- {...booking}
- />
- );
- } else if (props.row.original.type === "today") {
- return (
-
- {t("today")}
-
- );
- } else if (props.row.original.type === "next") {
- return (
-
- {t("next")}
-
- );
- }
- },
- }),
- ];
- }, [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(() => {
- // 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 = {};
-
- 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(() => {
- 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(() => {
- // 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({
- 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 (
- <>
-
-
- {enableDetailsSheet && (
-
- )}
- >
- );
-}
-
-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 (
-
-
-
- );
-}
diff --git a/apps/web/modules/bookings/components/JoinMeetingButton.tsx b/apps/web/modules/bookings/components/JoinMeetingButton.tsx
index 8ff4d4ed29..dd1cdc46d9 100644
--- a/apps/web/modules/bookings/components/JoinMeetingButton.tsx
+++ b/apps/web/modules/bookings/components/JoinMeetingButton.tsx
@@ -54,11 +54,7 @@ export function JoinMeetingButton({
onClick={handleClick}>
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
-
+
)}
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx b/apps/web/modules/bookings/components/ViewToggleButton.tsx
similarity index 58%
rename from apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx
rename to apps/web/modules/bookings/components/ViewToggleButton.tsx
index 92b1237933..111e7caf57 100644
--- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx
+++ b/apps/web/modules/bookings/components/ViewToggleButton.tsx
@@ -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 (
@@ -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={[
{
diff --git a/apps/web/modules/bookings/components/WeekPicker.tsx b/apps/web/modules/bookings/components/WeekPicker.tsx
new file mode 100644
index 0000000000..407cd5e70e
--- /dev/null
+++ b/apps/web/modules/bookings/components/WeekPicker.tsx
@@ -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
(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 ;
+}
diff --git a/apps/web/modules/bookings/hooks/useBookingCalendarData.ts b/apps/web/modules/bookings/hooks/useBookingCalendarData.ts
new file mode 100644
index 0000000000..0f254ffe0a
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useBookingCalendarData.ts
@@ -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(() => {
+ 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 = {};
+
+ 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;
+}
diff --git a/apps/web/modules/bookings/hooks/useBookingCursor.ts b/apps/web/modules/bookings/hooks/useBookingCursor.ts
index 96dbfa80bd..2ef1b0b9ea 100644
--- a/apps/web/modules/bookings/hooks/useBookingCursor.ts
+++ b/apps/web/modules/bookings/hooks/useBookingCursor.ts
@@ -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,
diff --git a/apps/web/modules/bookings/hooks/useBookingFilters.ts b/apps/web/modules/bookings/hooks/useBookingFilters.ts
new file mode 100644
index 0000000000..00478bcf4e
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useBookingFilters.ts
@@ -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,
+ };
+}
diff --git a/apps/web/modules/bookings/hooks/useBookingListColumns.tsx b/apps/web/modules/bookings/hooks/useBookingListColumns.tsx
new file mode 100644
index 0000000000..1a0d8e2fb5
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useBookingListColumns.tsx
@@ -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["data"];
+ status: BookingListingStatus;
+ canReadOthersBookings: boolean;
+ bookingsV3Enabled: boolean;
+ handleBookingClick: (bookingUid: string) => void;
+}) {
+ const { t } = useLocale();
+
+ return useMemo(() => {
+ const columnHelper = createColumnHelper();
+
+ 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 (
+ handleBookingClick(booking.uid) })}
+ {...booking}
+ />
+ );
+ },
+ }),
+ ];
+ }, [user, status, t, canReadOthersBookings, bookingsV3Enabled, handleBookingClick]);
+}
diff --git a/apps/web/modules/bookings/hooks/useBookingListData.ts b/apps/web/modules/bookings/hooks/useBookingListData.ts
new file mode 100644
index 0000000000..d1e4cffa94
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useBookingListData.ts
@@ -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(() => {
+ 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 = {};
+
+ 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(() => {
+ 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(() => {
+ // 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;
+}
diff --git a/apps/web/modules/bookings/hooks/useBookingStatusTab.ts b/apps/web/modules/bookings/hooks/useBookingStatusTab.ts
new file mode 100644
index 0000000000..750edb4ddc
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useBookingStatusTab.ts
@@ -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,
+ };
+}
diff --git a/apps/web/modules/bookings/hooks/useCalendarAllowedFilters.ts b/apps/web/modules/bookings/hooks/useCalendarAllowedFilters.ts
new file mode 100644
index 0000000000..91aac7c4a5
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useCalendarAllowedFilters.ts
@@ -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;
+}
diff --git a/apps/web/modules/bookings/hooks/useCurrentWeekStart.ts b/apps/web/modules/bookings/hooks/useCurrentWeekStart.ts
new file mode 100644
index 0000000000..0e58b6ed50
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useCurrentWeekStart.ts
@@ -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,
+ };
+}
diff --git a/apps/web/modules/bookings/hooks/usePaymentStatus.ts b/apps/web/modules/bookings/hooks/usePaymentStatus.ts
new file mode 100644
index 0000000000..fbec1346ff
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/usePaymentStatus.ts
@@ -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;
+}
diff --git a/apps/web/modules/bookings/hooks/useSelectedBookingUid.ts b/apps/web/modules/bookings/hooks/useSelectedBookingUid.ts
new file mode 100644
index 0000000000..b43f2a37f8
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useSelectedBookingUid.ts
@@ -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,
+ });
+}
diff --git a/apps/web/modules/bookings/lib/validStatuses.ts b/apps/web/modules/bookings/lib/validStatuses.ts
index 6315f84d10..ad54d26462 100644
--- a/apps/web/modules/bookings/lib/validStatuses.ts
+++ b/apps/web/modules/bookings/lib/validStatuses.ts
@@ -1 +1,3 @@
export const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
+
+export type BookingListingStatus = (typeof validStatuses)[number];
diff --git a/apps/web/modules/bookings/lib/weekUtils.ts b/apps/web/modules/bookings/lib/weekUtils.ts
new file mode 100644
index 0000000000..c1601efd6f
--- /dev/null
+++ b/apps/web/modules/bookings/lib/weekUtils.ts
@@ -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");
+};
diff --git a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx
index 531c83ae1a..3997159ec3 100644
--- a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx
+++ b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx
@@ -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 {
return createStore((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 (
diff --git a/apps/web/modules/bookings/types.ts b/apps/web/modules/bookings/types.ts
index 45ecb0e93c..7e5be0e0ba 100644
--- a/apps/web/modules/bookings/types.ts
+++ b/apps/web/modules/bookings/types.ts
@@ -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;
export type BookingListingStatus = (typeof validStatuses)[number];
diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx
index 563c2ae56f..5734dc7f03 100644
--- a/apps/web/modules/bookings/views/bookings-single-view.tsx
+++ b/apps/web/modules/bookings/views/bookings-single-view.tsx
@@ -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) {
{props.paymentStatus &&
(bookingInfo.status === BookingStatus.CANCELLED ||
- bookingInfo.status === BookingStatus.REJECTED) && (
-
- {!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")}
-
- )}
+ bookingInfo.status === BookingStatus.REJECTED) &&
{paymentStatusMessage}
}
{(isCancelled || reschedule) && cancellationReason && (
@@ -711,7 +688,7 @@ export default function Success(props: PageProps) {
<>
{t("additional_notes")}
-
{bookingInfo.description}
+
{bookingInfo.description}
>
)}
@@ -738,7 +715,7 @@ export default function Success(props: PageProps) {
{Object.entries(utmParams).filter(([_, value]) => Boolean(value)).length >
0 ? (
-
+
{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) {
))}
-
+
{t("submitted_feedback")}
{rateValue < 4 ? t("how_can_we_improve") : t("most_liked")}
diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx
index f77e0a3264..9fc1a693bb 100644
--- a/apps/web/modules/bookings/views/bookings-view.tsx
+++ b/apps/web/modules/bookings/views/bookings-view.tsx
@@ -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 ? (
-
- ) : undefined;
return (
-
-
-
({
- ...tab,
- name: t(tab.name),
- }))}
+
+ {view === "list" && (
+
-
-
-
- {status === "upcoming" && !isEmpty && (
-
- )}
- {view === "list" && (
-
- )}
- {bookingsV3Enabled && view === "calendar" && (
-
- )}
-
-
+ )}
+ {bookingsV3Enabled && view === "calendar" && (
+
+ )}
);
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 58686c76cb..4729a95c79 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/playwright/booking-filters.e2e.ts b/apps/web/playwright/booking-filters.e2e.ts
index 42ca3e7246..7e00803267 100644
--- a/apps/web/playwright/booking-filters.e2e.ts
+++ b/apps/web/playwright/booking-filters.e2e.ts
@@ -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,
diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts
index 213d47db96..b178329d2c 100644
--- a/apps/web/playwright/booking-pages.e2e.ts
+++ b/apps/web/playwright/booking-pages.e2e.ts
@@ -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();
diff --git a/apps/web/playwright/dynamic-booking-pages.e2e.ts b/apps/web/playwright/dynamic-booking-pages.e2e.ts
index 545a3daf9b..a8bfea0d69 100644
--- a/apps/web/playwright/dynamic-booking-pages.e2e.ts
+++ b/apps/web/playwright/dynamic-booking-pages.e2e.ts
@@ -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();
});
});
diff --git a/apps/web/playwright/filter-helpers.ts b/apps/web/playwright/filter-helpers.ts
index 5ac092db51..1c899692ca 100644
--- a/apps/web/playwright/filter-helpers.ts
+++ b/apps/web/playwright/filter-helpers.ts
@@ -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());
}
/**
diff --git a/apps/web/playwright/i18n-routing.e2e.ts b/apps/web/playwright/i18n-routing.e2e.ts
index 7944d5f8be..eeb287cf23 100644
--- a/apps/web/playwright/i18n-routing.e2e.ts
+++ b/apps/web/playwright/i18n-routing.e2e.ts
@@ -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 }) => {
diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts
index 57c04a2d54..238f568d1e 100644
--- a/apps/web/playwright/lib/testUtils.ts
+++ b/apps/web/playwright/lib/testUtils.ts
@@ -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,
+ };
+}
diff --git a/apps/web/playwright/locale.e2e.ts b/apps/web/playwright/locale.e2e.ts
index 96429b18b9..89175eec31 100644
--- a/apps/web/playwright/locale.e2e.ts
+++ b/apps/web/playwright/locale.e2e.ts
@@ -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);
}
diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts
index 179f678aa9..33fbfd2d55 100644
--- a/apps/web/playwright/reschedule.e2e.ts
+++ b/apps/web/playwright/reschedule.e2e.ts
@@ -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);
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 2198bf1ccc..9d3bb489a1 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -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": {
diff --git a/packages/features/bookings/lib/getAllUserBookings.ts b/packages/features/bookings/lib/getAllUserBookings.ts
index 39934544da..74cdbe24b0 100644
--- a/packages/features/bookings/lib/getAllUserBookings.ts
+++ b/packages/features/bookings/lib/getAllUserBookings.ts
@@ -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,
diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts
index 07010c43a0..e3587554ea 100644
--- a/packages/features/bookings/repositories/BookingRepository.ts
+++ b/packages/features/bookings/repositories/BookingRepository.ts
@@ -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,
diff --git a/packages/features/bookings/services/BookingAccessService.ts b/packages/features/bookings/services/BookingAccessService.ts
index ad352c486a..eac7821262 100644
--- a/packages/features/bookings/services/BookingAccessService.ts
+++ b/packages/features/bookings/services/BookingAccessService.ts
@@ -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
{
+ 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;
+ }
}
diff --git a/packages/features/bookings/services/BookingDetailsService.ts b/packages/features/bookings/services/BookingDetailsService.ts
new file mode 100644
index 0000000000..b4b55bfbb4
--- /dev/null
+++ b/packages/features/bookings/services/BookingDetailsService.ts
@@ -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,
+ };
+ }
+}
diff --git a/packages/features/calendars/weeklyview/components/DateValues/index.tsx b/packages/features/calendars/weeklyview/components/DateValues/index.tsx
index 4a9d742cb8..2000fa186d 100644
--- a/packages/features/calendars/weeklyview/components/DateValues/index.tsx
+++ b/packages/features/calendars/weeklyview/components/DateValues/index.tsx
@@ -52,7 +52,7 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
@@ -101,7 +101,7 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
{day.format("DD")}
diff --git a/packages/features/calendars/weeklyview/components/event/Event.tsx b/packages/features/calendars/weeklyview/components/event/Event.tsx
index 1ed03ed941..6fc0700017 100644
--- a/packages/features/calendars/weeklyview/components/event/Event.tsx
+++ b/packages/features/calendars/weeklyview/components/event/Event.tsx
@@ -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
= {
+ 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 = (
- {options?.color && (
-
+ {(options?.color || colorClass) && (
+
)}
{event.title}
@@ -103,10 +125,10 @@ export function Event({
style={{
transition: "all 100ms ease-out",
}}>
- {options?.color && (
+ {(options?.color || colorClass) && (
+ className={classNames("-ml-1.5 mr-1.5 h-full w-[3px] shrink-0", colorClass)}
+ style={options?.color ? { backgroundColor: options.color } : undefined}>
)}
{displayType === "single-line" && (
diff --git a/packages/features/calendars/weeklyview/types/events.ts b/packages/features/calendars/weeklyview/types/events.ts
index d49cfc13ac..51a6fa9e62 100644
--- a/packages/features/calendars/weeklyview/types/events.ts
+++ b/packages/features/calendars/weeklyview/types/events.ts
@@ -13,7 +13,7 @@ export interface CalendarEvent {
allDay?: boolean;
color?: string;
className?: string;
- bookingId?: number;
+ bookingUid?: string;
"data-test-id"?: string;
borderOnly?: boolean;
};
diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx
index a1197a09a7..bf5bda0619 100644
--- a/packages/features/data-table/components/DataTable.tsx
+++ b/packages/features/data-table/components/DataTable.tsx
@@ -362,7 +362,7 @@ function DataTableBody
({
return (
{rowsToRender.map(({ row, virtualItem }) => {
@@ -382,7 +382,7 @@ function DataTableBody({
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">
);
diff --git a/packages/features/data-table/components/filters/AddFilterButton.tsx b/packages/features/data-table/components/filters/AddFilterButton.tsx
index cd0807aacb..71395c8a46 100644
--- a/packages/features/data-table/components/filters/AddFilterButton.tsx
+++ b/packages/features/data-table/components/filters/AddFilterButton.tsx
@@ -15,7 +15,7 @@ import { useDataTable, useFilterableColumns } from "../../hooks";
export interface AddFilterButtonProps {
table: Table;
- variant?: "base" | "sm";
+ variant?: "base" | "minimal";
}
function AddFilterButtonComponent(
@@ -31,7 +31,7 @@ function AddFilterButtonComponent(
(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(
ref={ref}
color="secondary"
data-testid="add-filter-button"
- StartIcon="sliders-horizontal"
+ StartIcon="list-filter"
className="h-full">
{t("filter")}
)}
- {variant === "sm" && (
+ {variant === "minimal" && (
diff --git a/packages/features/data-table/hooks/index.ts b/packages/features/data-table/hooks/index.ts
index 70dc32414d..94d70a57de 100644
--- a/packages/features/data-table/hooks/index.ts
+++ b/packages/features/data-table/hooks/index.ts
@@ -5,3 +5,4 @@ export * from "./useFilterValue";
export * from "./useColumnFilters";
export * from "./useDataTable";
export * from "./useFilterableColumns";
+export * from "./useDisplayedFilterCount";
diff --git a/packages/features/data-table/hooks/useDisplayedFilterCount.ts b/packages/features/data-table/hooks/useDisplayedFilterCount.ts
new file mode 100644
index 0000000000..6af047e360
--- /dev/null
+++ b/packages/features/data-table/hooks/useDisplayedFilterCount.ts
@@ -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]);
+};
diff --git a/packages/features/shell/navigation/Navigation.tsx b/packages/features/shell/navigation/Navigation.tsx
index d7f11a0aa7..6e8db68dbf 100644
--- a/packages/features/shell/navigation/Navigation.tsx
+++ b/packages/features/shell/navigation/Navigation.tsx
@@ -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: ,
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",
diff --git a/packages/features/shell/navigation/NavigationItem.tsx b/packages/features/shell/navigation/NavigationItem.tsx
index 883fd04a1b..2d492af00e 100644
--- a/packages/features/shell/navigation/NavigationItem.tsx
+++ b/packages/features/shell/navigation/NavigationItem.tsx
@@ -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(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 ? (
-
+
{t(item.name)}
@@ -139,7 +114,7 @@ export const NavigationItem: React.FC<{
return (
{
setIsTooltipOpen(false);
@@ -211,7 +186,7 @@ export const NavigationItem: React.FC<{
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<{
{item.badge && {item.badge}
}
{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">
{item.icon && (
-
+
)}
{isLocaleReady ? t(item.name) : }
@@ -335,7 +306,7 @@ export const MobileNavigationMoreItem: React.FC<{
{item.child.map((childItem) => (
{isLocaleReady ? t(childItem.name) : }
diff --git a/packages/lib/hooks/useMediaQuery.ts b/packages/lib/hooks/useMediaQuery.ts
index 9d06c75005..f10b0ea4e8 100644
--- a/packages/lib/hooks/useMediaQuery.ts
+++ b/packages/lib/hooks/useMediaQuery.ts
@@ -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;
diff --git a/packages/prisma/migrations/20251127102536_add_from_reschdule_index_to_booking/migration.sql b/packages/prisma/migrations/20251127102536_add_from_reschdule_index_to_booking/migration.sql
new file mode 100644
index 0000000000..9f7154b793
--- /dev/null
+++ b/packages/prisma/migrations/20251127102536_add_from_reschdule_index_to_booking/migration.sql
@@ -0,0 +1,2 @@
+-- CreateIndex
+CREATE INDEX "Booking_fromReschedule_idx" ON "public"."Booking"("fromReschedule");
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index fb1e9b16fe..8d815369a8 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -894,6 +894,7 @@ model Booking {
@@index([uid])
@@index([status])
@@index([startTime, endTime, status])
+ @@index([fromReschedule])
}
model Tracking {
diff --git a/packages/prisma/selects/booking.ts b/packages/prisma/selects/booking.ts
index cc1e80ebd0..a105ddc813 100644
--- a/packages/prisma/selects/booking.ts
+++ b/packages/prisma/selects/booking.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx
index a2f8f0a128..5d6ff61733 100644
--- a/packages/trpc/server/routers/viewer/bookings/_router.tsx
+++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx
@@ -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");
diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts
index 1310193752..1948a7aae4 100644
--- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts
+++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts
@@ -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(
@@ -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
+): Promise<
+ Array<
+ Omit & {
+ attendees: Array;
+ }
+ >
+> {
+ // 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(
+ 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[]) {
diff --git a/packages/trpc/server/routers/viewer/bookings/get.schema.ts b/packages/trpc/server/routers/viewer/bookings/get.schema.ts
index 97cc568398..3d2116c85d 100644
--- a/packages/trpc/server/routers/viewer/bookings/get.schema.ts
+++ b/packages/trpc/server/routers/viewer/bookings/get.schema.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/bookings/getBookingDetails.handler.ts b/packages/trpc/server/routers/viewer/bookings/getBookingDetails.handler.ts
new file mode 100644
index 0000000000..12066a18a3
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/bookings/getBookingDetails.handler.ts
@@ -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;
+ };
+ 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,
+ });
+};
diff --git a/packages/trpc/server/routers/viewer/bookings/getBookingDetails.schema.ts b/packages/trpc/server/routers/viewer/bookings/getBookingDetails.schema.ts
new file mode 100644
index 0000000000..ee2aff44f3
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/bookings/getBookingDetails.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZGetBookingDetailsInputSchema = z.object({
+ uid: z.string(),
+});
+
+export type TGetBookingDetailsInputSchema = z.infer;
diff --git a/packages/ui/components/dropdown/Dropdown.tsx b/packages/ui/components/dropdown/Dropdown.tsx
index 8c6f474d79..3a8e168f53 100644
--- a/packages/ui/components/dropdown/Dropdown.tsx
+++ b/packages/ui/components/dropdown/Dropdown.tsx
@@ -170,7 +170,7 @@ export const DropdownItem = (props: DropdownItemProps) => {
props.className
)}>
<>
- {CustomStartIcon || (StartIcon && )}
+ {CustomStartIcon || (StartIcon && )}
{children}
diff --git a/packages/ui/components/form/datepicker/DatePicker.tsx b/packages/ui/components/form/datepicker/DatePicker.tsx
index ae13bd42e9..ac08ff2969 100644
--- a/packages/ui/components/form/datepicker/DatePicker.tsx
+++ b/packages/ui/components/form/datepicker/DatePicker.tsx
@@ -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")}> : Pick a date}
+ {label ?? (date ? <>{format(date, "LLL dd, y")}> : Pick a date)}
diff --git a/packages/ui/components/form/toggleGroup/ToggleGroup.tsx b/packages/ui/components/form/toggleGroup/ToggleGroup.tsx
index 710bb74e31..d314295eca 100644
--- a/packages/ui/components/form/toggleGroup/ToggleGroup.tsx
+++ b/packages/ui/components/form/toggleGroup/ToggleGroup.tsx
@@ -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"
)}>
,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, forwardedRef) => {
- return (
-
-
-
-
-
+ React.ComponentPropsWithoutRef & {
+ hideOverlay?: boolean;
+ }
+>(({ className, hideOverlay = false, ...props }, forwardedRef) => {
+ const content = (
+
);
+
+ return {hideOverlay ? content : {content}};
});
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 (
{children}
- {rightContent !== undefined
- ? rightContent
- : showCloseButton && (
-
-
-
- )}
+ {showCloseButton && (
+
+
+
+ )}
);
});
diff --git a/packages/ui/components/table/TableActions.tsx b/packages/ui/components/table/TableActions.tsx
index 52cd566201..121911937c 100644
--- a/packages/ui/components/table/TableActions.tsx
+++ b/packages/ui/components/table/TableActions.tsx
@@ -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) => 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}
@@ -88,7 +88,7 @@ export const TableActions: FC = ({ 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}
diff --git a/packages/ui/components/table/TableNew.tsx b/packages/ui/components/table/TableNew.tsx
index d4443ef683..f738a7054d 100644
--- a/packages/ui/components/table/TableNew.tsx
+++ b/packages/ui/components/table/TableNew.tsx
@@ -6,7 +6,7 @@ const Table = React.forwardRef (
)
@@ -39,7 +39,7 @@ const TableRow = React.forwardRef[],
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;
+ }[] = [
+ // 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({
diff --git a/yarn.lock b/yarn.lock
index 3c90c4b501..ccb8994b7b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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