From dd7f108f08536221a4fc12c900455b192e40ed16 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 19 Nov 2025 16:27:13 +0100 Subject: [PATCH] fix: put booking details and calendar behind feature flag (#25175) * Revert "fix: revert bookings redesign (#25172)" This reverts commit 1f102bf3b49b82cfc3b6c3dfd5a39775658539fd. * add bookings-v3 feature flag * put things behind a feature flag * remove no longer needed test * revert e2e tests * put back description * revert AvatarGroup * apply feedback * remove "view" booking action * remove Alert (When the bookings query errors, this branch now renders only the alert and skips the data-table filter/segment controls. Those controls moved into BookingsList, so in error states users can no longer clear or tweak filters to recover from the failure, effectively trapping them behind the alert.) * address feedback * revert useMediaQuery --- .../bookings/[status]/ViewToggleButton.tsx | 64 +++ .../(main-nav)/bookings/[status]/page.tsx | 17 +- .../components/booking/BookingListItem.tsx | 2 +- .../web/components/booking/SkeletonLoader.tsx | 56 ++- .../actions/BookingActionsDropdown.tsx | 273 ++++++++---- .../booking/actions/bookingActions.test.ts | 16 +- .../booking/actions/bookingActions.ts | 16 +- .../components/dialog/RescheduleDialog.tsx | 2 +- .../modules/bookings/columns/filterColumns.ts | 133 ++++++ .../components/BookingDetailsSheet.tsx | 202 +++++---- .../bookings/components/BookingsCalendar.tsx | 88 ++-- .../components/BookingsCalendarContainer.tsx | 162 +++++++ .../components/BookingsCalendarView.tsx | 15 +- .../bookings/components/BookingsList.tsx | 13 +- .../components/BookingsListContainer.tsx | 346 +++++++++++++++ .../bookings/components/JoinMeetingButton.tsx | 66 +++ .../components/useJoinableLocation.tsx | 46 ++ .../bookings/hooks/useBookingCursor.ts | 26 +- .../bookings/hooks/useSelectedBookingId.ts | 14 + apps/web/modules/bookings/lib/viewParser.ts | 11 + .../store/bookingDetailsSheetStore.tsx | 144 +++++++ apps/web/modules/bookings/types.ts | 2 + .../modules/bookings/views/bookings-view.tsx | 401 +++--------------- apps/web/playwright/booking-filters.e2e.ts | 68 +-- apps/web/public/static/locales/en/common.json | 14 +- .../calendars/weeklyview/types/events.ts | 1 + .../data-table/components/DataTable.tsx | 28 +- .../components/DataTableWrapper.tsx | 17 +- packages/features/flags/config.ts | 1 + packages/features/flags/hooks/index.ts | 1 + .../migration.sql | 9 + packages/ui/components/sheet/Sheet.tsx | 2 +- 32 files changed, 1557 insertions(+), 699 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx create mode 100644 apps/web/modules/bookings/columns/filterColumns.ts create mode 100644 apps/web/modules/bookings/components/BookingsCalendarContainer.tsx create mode 100644 apps/web/modules/bookings/components/BookingsListContainer.tsx create mode 100644 apps/web/modules/bookings/components/JoinMeetingButton.tsx create mode 100644 apps/web/modules/bookings/components/useJoinableLocation.tsx create mode 100644 apps/web/modules/bookings/hooks/useSelectedBookingId.ts create mode 100644 apps/web/modules/bookings/lib/viewParser.ts create mode 100644 apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx create mode 100644 packages/prisma/migrations/20251114173403_add_bookings_v3_feature_flag/migration.sql diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx new file mode 100644 index 0000000000..92b1237933 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx @@ -0,0 +1,64 @@ +"use client"; + +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"; + +export function ViewToggleButton() { + const { t } = useLocale(); + const [view, setView] = useQueryState( + "view", + viewParser.withDefault("list").withOptions({ clearOnDefault: true }) + ); + const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser); + const isMobile = !useMediaQuery("(min-width: 640px)"); + + 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]); + + return ( +
+ { + 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); + }} + options={[ + { + value: "list", + label: "", + tooltip: t("list_view"), + iconLeft: , + }, + { + value: "calendar", + label: "", + tooltip: t("calendar_view"), + iconLeft: , + }, + ]} + /> +
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index f749bfe150..1b6fab7cd2 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -16,6 +16,8 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { validStatuses } from "~/bookings/lib/validStatuses"; import BookingsList from "~/bookings/views/bookings-view"; +import { ViewToggleButton } from "./ViewToggleButton"; + const querySchema = z.object({ status: z.enum(validStatuses), }); @@ -54,18 +56,23 @@ const Page = async ({ params }: PageProps) => { canReadOthersBookings = teamIdsWithPermission.length > 0; } + const userProfile = session?.user?.profile; + const orgId = userProfile?.organizationId ?? session?.user.org?.id; const featuresRepository = new FeaturesRepository(prisma); - const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( - "booking-calendar-view" - ); + const bookingsV3Enabled = orgId + ? await featuresRepository.checkIfTeamHasFeature(orgId, "bookings-v3") + : false; return ( - + : null}> ); diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index c869c1d3ba..be269b44fd 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -499,7 +499,7 @@ function BookingListItem(booking: BookingItemProps) {
{shouldShowPendingActions(actionContext) && } - + {shouldShowRecurringCancelAction(actionContext) && } {shouldShowIndividualReportButton(actionContext) && (
diff --git a/apps/web/components/booking/SkeletonLoader.tsx b/apps/web/components/booking/SkeletonLoader.tsx index 419a0ecf03..c61a3ab460 100644 --- a/apps/web/components/booking/SkeletonLoader.tsx +++ b/apps/web/components/booking/SkeletonLoader.tsx @@ -5,9 +5,20 @@ import { SkeletonText } from "@calcom/ui/components/skeleton"; function SkeletonLoader() { return (
    + {/* TODAY header */} +
  • + +
  • + + {/* NEXT header */} +
  • + +
  • + +
); } @@ -16,20 +27,41 @@ export default SkeletonLoader; function SkeletonItem() { return ( -
  • -
    -
    -
    - - -
    +
  • + {/* Left side - Date and time info */} +
    + + +
    + + +
    + {/* Badges */} +
    +
    -
    -
    - - -
    + + {/* Right side - Event details */} +
    + {/* Event title */} + + {/* Event description */} + + {/* Attendees */} + +
    + + {/* Action buttons - only visible on larger screens */} +
    + + + +
    + + {/* Mobile menu button */} +
    +
  • ); diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index 13c04b8915..45c30a8fbe 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -15,9 +15,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuPortal, } from "@calcom/ui/components/dropdown"; import { TextAreaField } from "@calcom/ui/components/form"; import type { ActionType } from "@calcom/ui/components/table"; @@ -39,16 +39,36 @@ import { getAfterEventActions, getReportAction, shouldShowEditActions, + shouldShowPendingActions, + getPendingActions, type BookingActionContext, } from "./bookingActions"; interface BookingActionsDropdownProps { booking: BookingItemProps; - context: "booking-list-item" | "booking-details-sheet"; size?: "xs" | "sm" | "base" | "lg"; + className?: string; + /** + * Whether to use a portal for the dropdown menu. + * Set to false when rendering inside a Sheet/Dialog to keep the dropdown within the modal's stacking context. + * @default true + */ + usePortal?: boolean; + /** + * Context where the dropdown is being used. + * - "list": Used in booking list view (hides Confirm and Reject actions) + * - "details": Used in booking details view (shows all actions) + */ + context: "list" | "details"; } -export function BookingActionsDropdown({ booking, context, size = "base" }: BookingActionsDropdownProps) { +export function BookingActionsDropdown({ + booking, + size = "base", + className, + usePortal = true, + context, +}: BookingActionsDropdownProps) { const { t } = useLocale(); const utils = trpc.useUtils(); @@ -211,6 +231,10 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book } }; + // Calculate showPendingPayment based on payment logic + const hasPayment = booking.payment.length > 0; + const showPendingPayment = hasPayment; + const actionContext: BookingActionContext = { booking, isUpcoming, @@ -228,7 +252,7 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book isDisabledCancelling, isDisabledRescheduling, isCalVideoLocation, - showPendingPayment: false, // This will be calculated below + showPendingPayment, isAttendee, cardCharged, attendeeList, @@ -238,6 +262,21 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book const cancelEventAction = getCancelEventAction(actionContext); + // Get pending actions (accept/reject) - only for details context + const shouldShowPending = shouldShowPendingActions(actionContext); + const basePendingActions = + shouldShowPending && context === "details" ? getPendingActions(actionContext) : []; + const pendingActions: ActionType[] = basePendingActions.map((action) => ({ + ...action, + disabled: mutation.isPending, + onClick: + action.id === "confirm" + ? () => bookingConfirm(true) + : action.id === "reject" + ? () => setRejectionDialogIsOpen(true) + : undefined, + })) as ActionType[]; + const shouldShowEdit = shouldShowEditActions(actionContext); const baseEditEventActions = getEditEventActions(actionContext); const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({ @@ -500,22 +539,26 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book // Check if there are any available actions across all action groups const hasAnyAvailableActions = () => { + // Check if any pending action is available + const hasAvailablePendingAction = pendingActions.some((action) => !action.disabled); + // Check if any edit action is available const hasAvailableEditAction = editEventActions.some((action) => !action.disabled); // Check if any after event action is available const hasAvailableAfterAction = afterEventActions.some((action) => !action.disabled); - // For booking-list-item context, only check edit and after event actions - if (context === "booking-list-item") { - return hasAvailableEditAction || hasAvailableAfterAction; - } - - // For booking-details-sheet context, also check report and cancel actions + // Check report and cancel actions const isReportAvailable = !reportActionWithHandler.disabled; const isCancelAvailable = !cancelEventAction.disabled; - return hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable; + return ( + hasAvailablePendingAction || + hasAvailableEditAction || + hasAvailableAfterAction || + isReportAvailable || + isCancelAvailable + ); }; // Don't render dropdown if no actions are available @@ -523,94 +566,138 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book return dialogs; } - const menuContent = ( - - {t("edit_event")} - {editEventActions.map((action) => ( - - - {action.label} - - - ))} - - {t("after_event")} - {afterEventActions.map((action) => ( - - - {action.label} - - - ))} - <> - - - - {reportActionWithHandler.label} - - - - - - - {cancelEventAction.label} - - - - ); + // Conditional portal wrapper to avoid portal when inside Sheet/Dialog + const ConditionalPortal = usePortal + ? DropdownMenuPortal + : ({ children }: { children: React.ReactNode }) => <>{children}; return ( <> {dialogs} - + -
    @@ -183,6 +201,8 @@ function BookingDetailsSheetInner({

    + + @@ -203,49 +223,35 @@ function BookingDetailsSheetInner({ -
    - {isLocationURL && locationToDisplay && ( - <> - -
    - - )} - - + +
    + + + +
    @@ -253,6 +259,20 @@ function BookingDetailsSheetInner({ ); } +function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) { + const { t } = useLocale(); + + if (booking.status !== "CANCELLED" || !booking.rescheduled) { + return null; + } + + return ( + + {t("reschedule_request_sent")} + + ); +} + function WhoSection({ booking }: { booking: BookingOutput }) { const { t } = useLocale(); return ( diff --git a/apps/web/modules/bookings/components/BookingsCalendar.tsx b/apps/web/modules/bookings/components/BookingsCalendar.tsx index 8c7c68bb57..e97e07379a 100644 --- a/apps/web/modules/bookings/components/BookingsCalendar.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendar.tsx @@ -1,89 +1,64 @@ "use client"; import type { Table as ReactTable } from "@tanstack/react-table"; -import { createParser, useQueryState } from "nuqs"; -import { useMemo, useCallback } from "react"; +import { useCallback } from "react"; import dayjs from "@calcom/dayjs"; import { DataTableFilters, DataTableSegment, useDataTable, - useFilterValue, - ZDateRangeFilterValue, ColumnFilterType, } from "@calcom/features/data-table"; import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange"; -import type { RowData, BookingListingStatus } from "../types"; +import type { RowData, BookingListingStatus, BookingOutput } from "../types"; import { BookingsCalendarView } from "./BookingsCalendarView"; -type BookingsCalendarViewProps = { +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"]; -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"), -}); - -export function BookingsCalendar({ table }: BookingsCalendarViewProps) { - const { rows } = table.getRowModel(); +export function BookingsCalendar({ + table, + isPending = false, + currentWeekStart, + setCurrentWeekStart, + bookings, + ErrorView, + hasError, +}: BookingsCalendarProps) { const { updateFilter } = useDataTable(); - const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data; - - const [currentWeekStart, setCurrentWeekStart] = useQueryState( - "weekStart", - weekStartParser.withDefault(dayjs().startOf("week")) - ); - - const bookings = useMemo(() => { - return rows - .filter((row) => row.original.type === "data") - .map((row) => (row.original.type === "data" ? row.original.booking : null)) - .filter((booking): booking is NonNullable => booking !== null); - }, [rows]); 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(); - if (!dateRange) { - return; - } - - const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null; - const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null; - - const needsStartUpdate = !rangeStart || startDate < rangeStart; - const needsEndUpdate = !rangeEnd || endDate > rangeEnd; - - if (!needsStartUpdate && !needsEndUpdate) { - return; - } - - const newStartDate = needsStartUpdate ? startDate : rangeStart; - const newEndDate = needsEndUpdate ? endDate : rangeEnd; - updateFilter("dateRange", { type: ColumnFilterType.DATE_RANGE, data: { - startDate: newStartDate.toISOString(), - endDate: newEndDate.toISOString(), + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), preset: CUSTOM_PRESET.value, }, }); }, - [dateRange, updateFilter, setCurrentWeekStart] + [updateFilter, setCurrentWeekStart] ); return ( @@ -99,11 +74,16 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
    - + {hasError && ErrorView ? ( + ErrorView + ) : ( + + )} ); } diff --git a/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx b/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx new file mode 100644 index 0000000000..9bc19b095b --- /dev/null +++ b/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx @@ -0,0 +1,162 @@ +"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/BookingsCalendarView.tsx b/apps/web/modules/bookings/components/BookingsCalendarView.tsx index 6a24a62f72..0a91023521 100644 --- a/apps/web/modules/bookings/components/BookingsCalendarView.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendarView.tsx @@ -12,19 +12,23 @@ import { Button } from "@calcom/ui/components/button"; import { ButtonGroup } from "@calcom/ui/components/buttonGroup"; import { Icon } from "@calcom/ui/components/icon"; +import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore"; import type { BookingOutput } from "../types"; type BookingsCalendarViewProps = { bookings: BookingOutput[]; currentWeekStart: dayjs.Dayjs; onWeekStartChange: (weekStart: dayjs.Dayjs) => void; + isPending?: boolean; }; export function BookingsCalendarView({ bookings, currentWeekStart, onWeekStartChange, + isPending = false, }: BookingsCalendarViewProps) { + const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId); const { t } = useLocale(); const { timezone } = useTimePreferences(); const { resolvedTheme, forcedTheme } = useGetTheme(); @@ -44,8 +48,10 @@ export function BookingsCalendarView({ const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]); const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]); + // Intentionally only runs on mount to trigger the initial currentWeekStart useEffect(() => { onWeekStartChange(currentWeekStart); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const events = useMemo(() => { @@ -78,6 +84,7 @@ export function BookingsCalendarView({ options: { status: booking.status, ...(eventTypeColor && { color: eventTypeColor }), + bookingId: booking.id, }, }; }); @@ -109,6 +116,7 @@ export function BookingsCalendarView({

    {weekRange}

    + {isPending && }
    diff --git a/apps/web/modules/bookings/components/BookingsList.tsx b/apps/web/modules/bookings/components/BookingsList.tsx index ae4ad90067..d2dc796d6a 100644 --- a/apps/web/modules/bookings/components/BookingsList.tsx +++ b/apps/web/modules/bookings/components/BookingsList.tsx @@ -23,9 +23,18 @@ type BookingsListViewProps = { table: ReactTable; isPending: boolean; totalRowCount?: number; + ErrorView?: React.ReactNode; + hasError?: boolean; }; -export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) { +export function BookingsList({ + status, + table, + isPending, + totalRowCount, + ErrorView, + hasError, +}: BookingsListViewProps) { const { t } = useLocale(); return ( @@ -64,6 +73,8 @@ export function BookingsList({ status, table, isPending, totalRowCount }: Bookin />
    } + ErrorView={ErrorView} + hasError={hasError} /> ); } diff --git a/apps/web/modules/bookings/components/BookingsListContainer.tsx b/apps/web/modules/bookings/components/BookingsListContainer.tsx new file mode 100644 index 0000000000..d9568570b4 --- /dev/null +++ b/apps/web/modules/bookings/components/BookingsListContainer.tsx @@ -0,0 +1,346 @@ +"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 new file mode 100644 index 0000000000..8ff4d4ed29 --- /dev/null +++ b/apps/web/modules/bookings/components/JoinMeetingButton.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { BookingStatus } from "@calcom/prisma/enums"; +import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; + +import { useJoinableLocation } from "./useJoinableLocation"; + +interface JoinMeetingButtonProps { + location: string | null; + metadata?: unknown; + bookingStatus: BookingStatus; + size?: "sm" | "base" | "lg"; + color?: "primary" | "secondary" | "minimal" | "destructive"; + className?: string; + onClick?: (e: React.MouseEvent) => void; +} + +export function JoinMeetingButton({ + location, + metadata, + bookingStatus, + size = "base", + color = "secondary", + className, + onClick, +}: JoinMeetingButtonProps) { + const { t } = useLocale(); + const { isJoinable, locationToDisplay, provider } = useJoinableLocation({ + location, + metadata, + bookingStatus, + t, + }); + + if (!isJoinable || !locationToDisplay) { + return null; + } + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e); + }; + + return ( + + ); +} diff --git a/apps/web/modules/bookings/components/useJoinableLocation.tsx b/apps/web/modules/bookings/components/useJoinableLocation.tsx new file mode 100644 index 0000000000..82993c2bec --- /dev/null +++ b/apps/web/modules/bookings/components/useJoinableLocation.tsx @@ -0,0 +1,46 @@ +import type { TFunction } from "i18next"; +import { useMemo } from "react"; + +import { useBookingLocation } from "@calcom/features/bookings/hooks"; +import type { BookingStatus } from "@calcom/prisma/enums"; +import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; + +interface UseJoinableLocationParams { + location: string | null; + metadata?: unknown; + bookingStatus: BookingStatus; + t: TFunction; +} + +/** + * Custom hook to determine if a booking location is joinable (i.e., has a clickable URL). + * This hook is used to show/hide the join meeting button and related UI elements. + * + * @returns An object containing: + * - `isJoinable`: Whether the location is joinable (has a valid URL) + * - `locationToDisplay`: The location URL or text to display + * - `provider`: Provider information (label, iconUrl, etc.) + * - `isLocationURL`: Whether the location is a URL + */ +export function useJoinableLocation({ location, metadata, bookingStatus, t }: UseJoinableLocationParams) { + const bookingMetadata = useMemo(() => { + const parsedMetadata = bookingMetadataSchema.safeParse(metadata ?? null); + return parsedMetadata.success ? parsedMetadata.data : null; + }, [metadata]); + + const { locationToDisplay, provider, isLocationURL } = useBookingLocation({ + location, + videoCallUrl: bookingMetadata?.videoCallUrl, + t, + bookingStatus, + }); + + const isJoinable = isLocationURL && !!locationToDisplay; + + return { + isJoinable, + locationToDisplay, + provider, + isLocationURL, + }; +} diff --git a/apps/web/modules/bookings/hooks/useBookingCursor.ts b/apps/web/modules/bookings/hooks/useBookingCursor.ts index a271898c8b..96dbfa80bd 100644 --- a/apps/web/modules/bookings/hooks/useBookingCursor.ts +++ b/apps/web/modules/bookings/hooks/useBookingCursor.ts @@ -1,43 +1,37 @@ import { useMemo, useCallback } from "react"; -import type { RowData } from "../types"; - -function isDataRow(row: RowData): row is Extract { - return row.type === "data"; -} +import type { BookingOutput } from "../types"; export function useBookingCursor({ bookings, selectedBookingId, setSelectedBookingId, }: { - bookings: RowData[]; + bookings: BookingOutput[]; selectedBookingId: number | null; setSelectedBookingId: (bookingId: number | null) => void; }) { - const bookingRows = useMemo(() => bookings.filter(isDataRow), [bookings]); - const currentIndex = useMemo( - () => bookingRows.findIndex((row) => selectedBookingId && row.booking.id === selectedBookingId), - [bookingRows, selectedBookingId] + () => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId), + [bookings, selectedBookingId] ); const onPrevious = useCallback(() => { if (currentIndex >= 1) { - setSelectedBookingId(bookingRows[currentIndex - 1].booking.id); + setSelectedBookingId(bookings[currentIndex - 1].id); } - }, [bookingRows, currentIndex, setSelectedBookingId]); + }, [bookings, currentIndex, setSelectedBookingId]); const onNext = useCallback(() => { - if (currentIndex >= 0 && currentIndex < bookingRows.length - 1) { - setSelectedBookingId(bookingRows[currentIndex + 1].booking.id); + if (currentIndex >= 0 && currentIndex < bookings.length - 1) { + setSelectedBookingId(bookings[currentIndex + 1].id); } - }, [bookingRows, currentIndex, setSelectedBookingId]); + }, [bookings, currentIndex, setSelectedBookingId]); return { onPrevious, onNext, hasPrevious: currentIndex > 0, - hasNext: currentIndex < bookingRows.length - 1 && currentIndex >= 0, + hasNext: currentIndex < bookings.length - 1 && currentIndex >= 0, }; } diff --git a/apps/web/modules/bookings/hooks/useSelectedBookingId.ts b/apps/web/modules/bookings/hooks/useSelectedBookingId.ts new file mode 100644 index 0000000000..26f9481bfe --- /dev/null +++ b/apps/web/modules/bookings/hooks/useSelectedBookingId.ts @@ -0,0 +1,14 @@ +import { useQueryState } from "nuqs"; + +export function useSelectedBookingId() { + return useQueryState("selectedId", { + defaultValue: null, + parse: (value) => { + if (!value) return null; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? null : parsed; + }, + serialize: (value) => (value ? String(value) : ""), + clearOnDefault: true, + }); +} diff --git a/apps/web/modules/bookings/lib/viewParser.ts b/apps/web/modules/bookings/lib/viewParser.ts new file mode 100644 index 0000000000..b6b2569f0e --- /dev/null +++ b/apps/web/modules/bookings/lib/viewParser.ts @@ -0,0 +1,11 @@ +import { createParser } from "nuqs"; + +export const viewParser = createParser({ + parse: (value: string) => { + if (value === "calendar") return "calendar"; + return "list"; + }, + serialize: (value: "list" | "calendar") => value, +}); + +export type BookingView = "list" | "calendar"; diff --git a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx new file mode 100644 index 0000000000..531c83ae1a --- /dev/null +++ b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx @@ -0,0 +1,144 @@ +"use client"; + +import React from "react"; +import { createStore, useStore } from "zustand"; + +import { useSelectedBookingId } from "../hooks/useSelectedBookingId"; +import type { BookingOutput } from "../types"; + +interface BookingDetailsSheetStore { + // State + selectedBookingId: number | null; + bookings: BookingOutput[]; + + // Actions + setSelectedBookingId: (id: number | null) => void; + setBookings: (bookings: BookingOutput[]) => void; + clearSelection: () => void; + + // Computed getters (used via selectors) + getSelectedBooking: () => BookingOutput | null; + getNextBookingId: () => number | null; + getPreviousBookingId: () => number | null; + hasNext: () => boolean; + hasPrevious: () => boolean; +} + +type BookingDetailsSheetStoreType = ReturnType; + +const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => { + return createStore((set, get) => ({ + // Initial state + selectedBookingId: null, + bookings: initialBookings, + + // Actions + setSelectedBookingId: (id) => set({ selectedBookingId: id }), + setBookings: (bookings) => set({ bookings }), + clearSelection: () => set({ selectedBookingId: null }), + + // Computed getters + getSelectedBooking: () => { + const state = get(); + if (!state.selectedBookingId) return null; + return state.bookings.find((booking) => booking.id === state.selectedBookingId) ?? null; + }, + + getNextBookingId: () => { + const state = get(); + if (!state.selectedBookingId) return null; + + const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId); + if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null; + + return state.bookings[currentIndex + 1].id; + }, + + getPreviousBookingId: () => { + const state = get(); + if (!state.selectedBookingId) return null; + + const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId); + if (currentIndex <= 0) return null; + + return state.bookings[currentIndex - 1].id; + }, + + hasNext: () => { + const state = get(); + if (!state.selectedBookingId) return false; + + const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId); + return currentIndex >= 0 && currentIndex < state.bookings.length - 1; + }, + + hasPrevious: () => { + const state = get(); + if (!state.selectedBookingId) return false; + + const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId); + return currentIndex > 0; + }, + })); +}; + +const BookingDetailsSheetStoreContext = React.createContext(null); + +export function BookingDetailsSheetStoreProvider({ + children, + bookings, +}: { + children: React.ReactNode; + bookings: BookingOutput[]; +}) { + const [store] = React.useState(() => createBookingDetailsSheetStore(bookings)); + const [selectedBookingIdFromUrl, setSelectedBookingIdToUrl] = useSelectedBookingId(); + + // Update bookings when they change + React.useEffect(() => { + store.getState().setBookings(bookings); + }, [bookings, store]); + + // Sync Store → URL + React.useEffect(() => { + const unsubscribe = store.subscribe((state) => { + const storeId = state.selectedBookingId; + if (storeId !== selectedBookingIdFromUrl) { + setSelectedBookingIdToUrl(storeId); + } + }); + + return unsubscribe; + }, [selectedBookingIdFromUrl, setSelectedBookingIdToUrl, store]); + + // Sync URL → Store + React.useEffect(() => { + const currentStoreId = store.getState().selectedBookingId; + if (currentStoreId !== selectedBookingIdFromUrl) { + store.getState().setSelectedBookingId(selectedBookingIdFromUrl); + } + }, [selectedBookingIdFromUrl, store]); + + return ( + + {children} + + ); +} + +export function useBookingDetailsSheetStore(selector: (state: BookingDetailsSheetStore) => T): T { + const store = React.useContext(BookingDetailsSheetStoreContext); + if (!store) { + throw new Error("useBookingDetailsSheetStore must be used within BookingDetailsSheetStoreProvider"); + } + return useStore(store, selector); +} + +// For direct store access (needed for subscribe and getState) +export function useBookingDetailsSheetStoreApi() { + const store = React.useContext(BookingDetailsSheetStoreContext); + if (!store) { + throw new Error("useBookingDetailsSheetStoreApi must be used within BookingDetailsSheetStoreProvider"); + } + return store; +} diff --git a/apps/web/modules/bookings/types.ts b/apps/web/modules/bookings/types.ts index 68bcaa935a..45ecb0e93c 100644 --- a/apps/web/modules/bookings/types.ts +++ b/apps/web/modules/bookings/types.ts @@ -2,6 +2,8 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import type { validStatuses } from "~/bookings/lib/validStatuses"; +export type BookingsGetOutput = RouterOutputs["viewer"]["bookings"]["get"]; + export type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0]; export type RecurringInfo = { diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index 48678a0f79..f77e0a3264 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -1,15 +1,13 @@ "use client"; -import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table"; +import dynamic from "next/dynamic"; import { useSearchParams, usePathname } from "next/navigation"; -import { createParser, useQueryState } from "nuqs"; +import { useQueryState } from "nuqs"; import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; import { DataTableProvider, - DataTableFilters, - DataTableSegment, type SystemFilterSegment, useDataTable, ColumnFilterType, @@ -21,22 +19,21 @@ import { import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; 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 type { HorizontalTabItemProps } from "@calcom/ui/components/navigation"; import { HorizontalTabs } from "@calcom/ui/components/navigation"; import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton"; -import BookingListItem from "@components/booking/BookingListItem"; - -import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"; import type { validStatuses } from "~/bookings/lib/validStatuses"; +import { viewParser } from "~/bookings/lib/viewParser"; -import { BookingDetailsSheet } from "../components/BookingDetailsSheet"; -import { BookingsCalendar } from "../components/BookingsCalendar"; -import { BookingsList } from "../components/BookingsList"; -import { useBookingCursor } from "../hooks/useBookingCursor"; -import type { RowData, BookingOutput } from "../types"; +import { BookingsListContainer } from "../components/BookingsListContainer"; + +const BookingsCalendarContainer = dynamic(() => + import("../components/BookingsCalendarContainer").then((mod) => ({ + default: mod.BookingsCalendarContainer, + })) +); type BookingsProps = { status: (typeof validStatuses)[number]; @@ -44,7 +41,7 @@ type BookingsProps = { permissions: { canReadOthersBookings: boolean; }; - isCalendarViewEnabled: boolean; + bookingsV3Enabled: boolean; }; function useSystemSegments(userId?: number) { @@ -86,27 +83,12 @@ export default function Bookings(props: BookingsProps) { ); } -const viewParser = createParser({ - parse: (value: string) => { - if (value === "calendar") return "calendar"; - return "list"; - }, - serialize: (value: "list" | "calendar") => value, -}); - -function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) { +function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsProps) { const [_view] = useQueryState("view", viewParser.withDefault("list")); // Force view to be "list" if calendar view is disabled - const view = isCalendarViewEnabled ? _view : "list"; + const view = bookingsV3Enabled ? _view : "list"; const { t } = useLocale(); - const user = useMeQuery().data; const searchParams = useSearchParams(); - const [selectedBookingId, setSelectedBookingId] = useQueryState("selectedId", { - defaultValue: null, - parse: (value) => (value ? parseInt(value, 10) : null), - serialize: (value) => (value ? String(value) : ""), - clearOnDefault: true, - }); const tabs: HorizontalTabItemProps[] = useMemo(() => { const queryString = searchParams?.toString() || ""; @@ -156,267 +138,43 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking const { limit, offset } = useDataTable(); - const query = trpc.viewer.bookings.get.useQuery({ - limit, - offset, - 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, - }, - }); + // 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 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 ( - setSelectedBookingId(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]); - - const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]); - - const flatData = useMemo(() => { - const shownBookings: Record = {}; - const filterBookings = (booking: BookingOutput) => { - if (status === "recurring" || status == "unconfirmed" || status === "cancelled") { - if (!booking.recurringEventId) { - return true; - } - if ( - shownBookings[booking.recurringEventId] !== undefined && - shownBookings[booking.recurringEventId].length > 0 - ) { - shownBookings[booking.recurringEventId].push(booking); - return false; - } - shownBookings[booking.recurringEventId] = [booking]; - } else if (status === "upcoming") { - return ( - dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !== - dayjs().tz(user?.timeZone).format("YYYY-MM-DD") - ); - } - return true; - }; - - return ( - query.data?.bookings.filter(filterBookings).map((booking) => ({ - type: "data", - booking, - recurringInfo: query.data?.recurringInfo.find( - (info) => info.recurringEventId === booking.recurringEventId - ), - isToday: false, - })) || [] - ); - }, [query.data, status, user?.timeZone]); - - const bookingsToday = useMemo(() => { - return ( - query.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: query.data?.recurringInfo.find( - (info) => info.recurringEventId === booking.recurringEventId - ), - isToday: true, - })) ?? [] - ); - }, [query.data, user?.timeZone]); - - const finalData = useMemo(() => { - if (status !== "upcoming") { - return flatData; - } - 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 selectedBooking = useMemo(() => { - if (!selectedBookingId) return null; - const dataRow = finalData.find( - (row): row is Extract => - row.type === "data" && row.booking.id === selectedBookingId - ); - return dataRow?.booking ?? null; - }, [selectedBookingId, finalData]); - - const bookingNavigation = useBookingCursor({ - bookings: finalData, - selectedBookingId, - setSelectedBookingId, - }); - - 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, + 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, }, }, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFacetedUniqueValues, - }); + { + 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 (
    @@ -430,56 +188,33 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
    - {query.status === "error" ? ( - <> -
    -
    -
    -
    - -
    -
    - - - -
    -
    -
    -
    - - - ) : ( - <> - {!!bookingsToday.length && status === "upcoming" && ( - - )} - {view === "list" ? ( - - ) : ( - - )} - + {status === "upcoming" && !isEmpty && ( + + )} + {view === "list" && ( + + )} + {bookingsV3Enabled && view === "calendar" && ( + )}
    - setSelectedBookingId(null)} - userTimeZone={user?.timeZone} - userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat} - userId={user?.id} - userEmail={user?.email} - onPrevious={bookingNavigation.onPrevious} - hasPrevious={bookingNavigation.hasPrevious} - onNext={bookingNavigation.onNext} - hasNext={bookingNavigation.hasNext} - /> ); } diff --git a/apps/web/playwright/booking-filters.e2e.ts b/apps/web/playwright/booking-filters.e2e.ts index 97a15511dd..42ca3e7246 100644 --- a/apps/web/playwright/booking-filters.e2e.ts +++ b/apps/web/playwright/booking-filters.e2e.ts @@ -1,8 +1,9 @@ import { expect } from "@playwright/test"; +import { MembershipRole } from "@calcom/prisma/enums"; + import { applySelectFilter } from "./filter-helpers"; import { test } from "./lib/fixtures"; -import { MembershipRole } from "@calcom/prisma/enums"; test.describe.configure({ mode: "parallel" }); @@ -107,69 +108,6 @@ test.describe("Booking Filters", () => { expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters); }); - test("Query params should be preserved when switching between bookings tabs in calendar view", 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?view=calendar`, { 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(); - expect(searchParams.get("view")).toBe("calendar"); - - 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); - expect(pastSearchParams.get("view")).toBe("calendar"); - - 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); - expect(cancelledSearchParams.get("view")).toBe("calendar"); - }); - test("Query params should NOT be preserved when navigating from a non-bookings page", async ({ page, users, @@ -229,7 +167,7 @@ test.describe("Booking Filters", () => { 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/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 75493666bc..ddefc8a102 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -221,6 +221,7 @@ "manage_this_team": "Manage this team", "team_info": "Team Info", "join_meeting": "Join Meeting", + "join_platform": "Join {{platform}}", "request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.", "you_have_been_invited": "You have been invited to join the team {{teamName}}", "user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}", @@ -1714,7 +1715,10 @@ "error_updating_password": "Error updating password", "two_factor_auth": "Two factor authentication", "recurring_event_tab_description": "Set up a repeating schedule", - "today": "today", + "today": "Today", + "this_month": "This month", + "time": "Time", + "event": "Event", "appearance": "Appearance", "my_account": "My account", "general": "General", @@ -3893,7 +3897,7 @@ "history": "History", "no_audit_history": "No audit history available", "remove_value_from_blocklist": "Remove {{value}} from blocklist?", - "remove_value_from_blocklist_description":"The action is irreversible", + "remove_value_from_blocklist_description": "The action is irreversible", "delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.", "created_by": "Created By", "blocked_by": "Blocked By", @@ -3983,7 +3987,7 @@ "team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team", "repeats_num_times": "Repeats {{count}} times", "view_booking_details": "View Booking Details", - "view": "View", + "recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences", "team_bio": "Team Bio", "team_bio_placeholder": "Tell us about your team...", "onboarding_invite_subtitle": "Connect your Google workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Team.", @@ -4012,5 +4016,9 @@ "onboarding_browser_view_ask_question_description": "Ask a question", "onboarding_browser_view_default_bio": "Add your bio here...", "book_now": "Book now", + "view": "View", + "booking_response": "Booking Response", + "list_view": "List view", + "calendar_view": "Calendar view", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/calendars/weeklyview/types/events.ts b/packages/features/calendars/weeklyview/types/events.ts index 5803d0a46d..b8d68914d1 100644 --- a/packages/features/calendars/weeklyview/types/events.ts +++ b/packages/features/calendars/weeklyview/types/events.ts @@ -13,6 +13,7 @@ export interface CalendarEvent { allDay?: boolean; color?: string; className?: string; + bookingId?: number; "data-test-id"?: string; }; } diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index c6e25ffc24..98680df8dd 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -38,7 +38,9 @@ export type DataTablePropsFromWrapper = { className?: string; containerClassName?: string; headerClassName?: string; - rowClassName?: string; + rowClassName?: string | ((row: Row) => string); + rowTestId?: string | ((row: Row) => string | undefined); + rowDataAttributes?: (row: Row) => Record | undefined; paginationMode?: "infinite" | "standard"; hasWrapperContext?: boolean; hideSeparatorsOnSort?: boolean; @@ -68,6 +70,8 @@ export function DataTable({ containerClassName, headerClassName, rowClassName, + rowTestId, + rowDataAttributes, paginationMode = "infinite", hasWrapperContext = false, hideSeparatorsOnSort = true, @@ -203,6 +207,8 @@ export function DataTable({ onRowMouseclick={onRowMouseclick} paginationMode={paginationMode} rowClassName={rowClassName} + rowTestId={rowTestId} + rowDataAttributes={rowDataAttributes} hideSeparatorsOnSort={hideSeparatorsOnSort} hideSeparatorsOnFilter={hideSeparatorsOnFilter} separatorClassName={separatorClassName} @@ -219,6 +225,8 @@ export function DataTable({ onRowMouseclick={onRowMouseclick} paginationMode={paginationMode} rowClassName={rowClassName} + rowTestId={rowTestId} + rowDataAttributes={rowDataAttributes} hideSeparatorsOnSort={hideSeparatorsOnSort} hideSeparatorsOnFilter={hideSeparatorsOnFilter} separatorClassName={separatorClassName} @@ -244,6 +252,8 @@ const MemoizedTableBody = memo( prev.onRowMouseclick === next.onRowMouseclick && prev.paginationMode === next.paginationMode && prev.rowClassName === next.rowClassName && + prev.rowTestId === next.rowTestId && + prev.rowDataAttributes === next.rowDataAttributes && prev.hideSeparatorsOnSort === next.hideSeparatorsOnSort && prev.hideSeparatorsOnFilter === next.hideSeparatorsOnFilter && prev.separatorClassName === next.separatorClassName && @@ -259,7 +269,9 @@ type DataTableBodyProps = { isPending?: boolean; onRowMouseclick?: (row: Row) => void; paginationMode?: "infinite" | "standard"; - rowClassName?: string; + rowClassName?: string | ((row: Row) => string); + rowTestId?: string | ((row: Row) => string | undefined); + rowDataAttributes?: (row: Row) => Record | undefined; hideSeparatorsOnSort?: boolean; hideSeparatorsOnFilter?: boolean; separatorClassName?: string; @@ -294,6 +306,8 @@ function DataTableBody({ onRowMouseclick, paginationMode, rowClassName, + rowTestId, + rowDataAttributes, hideSeparatorsOnSort = true, hideSeparatorsOnFilter = false, separatorClassName, @@ -374,10 +388,16 @@ function DataTableBody({ ); } + const computedRowTestId = typeof rowTestId === "function" ? rowTestId(row) : rowTestId; + const computedRowClassName = typeof rowClassName === "function" ? rowClassName(row) : rowClassName; + const computedDataAttributes = rowDataAttributes?.(row); + return ( filteredRowVirtualizer.measureElement(node) : undefined} key={row.id} + data-testid={computedRowTestId} + {...computedDataAttributes} data-index={virtualItem?.index} // needed for dynamic row height measurement data-state={row.getIsSelected() && "selected"} onClick={(e) => { @@ -396,7 +416,7 @@ function DataTableBody({ width: "100%", }), }} - className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}> + className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", computedRowClassName)}> {row.getVisibleCells().map((cell) => { const column = cell.column; return ( @@ -435,7 +455,7 @@ const TableHeadLabel = ({ header }: { header: Header }) if (!canSort && !canHide) { if (typeof header.column.columnDef.header === "string") { return ( -
    +
    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
    ); diff --git a/packages/features/data-table/components/DataTableWrapper.tsx b/packages/features/data-table/components/DataTableWrapper.tsx index 46337cd147..433d3296e0 100644 --- a/packages/features/data-table/components/DataTableWrapper.tsx +++ b/packages/features/data-table/components/DataTableWrapper.tsx @@ -20,8 +20,12 @@ type BaseDataTableWrapperProps = Omit< ToolbarRight?: React.ReactNode; EmptyView?: React.ReactNode; LoaderView?: React.ReactNode; + ErrorView?: React.ReactNode; + hasError?: boolean; tableContainerRef?: React.RefObject; onRowMouseclick?: (row: Row) => void; + rowTestId?: string | ((row: Row) => string | undefined); + rowDataAttributes?: (row: Row) => Record | undefined; }; type InfinitePaginationProps = BaseDataTableWrapperProps & { @@ -54,10 +58,14 @@ export function DataTableWrapper({ ToolbarRight, EmptyView, LoaderView, + ErrorView, + hasError, className, containerClassName, headerClassName, rowClassName, + rowTestId, + rowDataAttributes, children, tableContainerRef: externalRef, paginationMode, @@ -97,8 +105,10 @@ export function DataTableWrapper({ })); }, [table, sorting, columnFilters, columnVisibility, setSorting, setColumnVisibility]); - let view: "loader" | "empty" | "table" = "table"; - if (isPending && LoaderView) { + let view: "loader" | "empty" | "error" | "table" = "table"; + if (hasError && ErrorView) { + view = "error"; + } else if (isPending && LoaderView) { view = "loader"; } else if (table.getRowCount() === 0 && EmptyView) { view = "empty"; @@ -118,6 +128,7 @@ export function DataTableWrapper({ {children}
    )} + {view === "error" && ErrorView} {view === "loader" && LoaderView} {view === "empty" && EmptyView} {view === "table" && ( @@ -133,6 +144,8 @@ export function DataTableWrapper({ containerClassName={containerClassName} headerClassName={headerClassName} rowClassName={rowClassName} + rowTestId={rowTestId} + rowDataAttributes={rowDataAttributes} paginationMode={paginationMode} onRowMouseclick={onRowMouseclick} hasWrapperContext={true} diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 8002437a86..c3e71fe6b6 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -31,6 +31,7 @@ export type AppFlags = { "onboarding-v3": boolean; "booker-botid": boolean; "booking-calendar-view": boolean; + "bookings-v3": boolean; }; export type TeamFeatures = Record; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index cc5c20b96e..e34e1a1254 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -30,6 +30,7 @@ const initialData: AppFlags = { "onboarding-v3": false, "booker-botid": false, "booking-calendar-view": false, + "bookings-v3": false, }; if (process.env.NEXT_PUBLIC_IS_E2E) { diff --git a/packages/prisma/migrations/20251114173403_add_bookings_v3_feature_flag/migration.sql b/packages/prisma/migrations/20251114173403_add_bookings_v3_feature_flag/migration.sql new file mode 100644 index 0000000000..fe0367b07e --- /dev/null +++ b/packages/prisma/migrations/20251114173403_add_bookings_v3_feature_flag/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'bookings-v3', + false, + 'Enable bookings redesign v3 for all users', + 'EXPERIMENT' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/packages/ui/components/sheet/Sheet.tsx b/packages/ui/components/sheet/Sheet.tsx index e0cdeb6bf7..87526f3b7e 100644 --- a/packages/ui/components/sheet/Sheet.tsx +++ b/packages/ui/components/sheet/Sheet.tsx @@ -70,7 +70,7 @@ const SheetContent = React.forwardRef< ref={forwardedRef} className={classNames( // base - "fixed inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:right-2 sm:max-w-lg sm:p-6", + "fixed inset-x-0 inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:inset-x-auto sm:right-2 sm:max-w-lg sm:p-6", // "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm sm:p-6 p-4 shadow-lg rounded-xl border ", // border color "border-subtle",