diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx
index b89c8be118..cf9b94e2c0 100644
--- a/apps/web/components/booking/BookingListItem.tsx
+++ b/apps/web/components/booking/BookingListItem.tsx
@@ -1,4 +1,3 @@
-import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
@@ -46,6 +45,7 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
+import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
import { BookingActionsDropdown } from "./BookingActionsDropdown";
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
import {
@@ -94,10 +94,51 @@ const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooki
return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
};
+const ConditionalLink = ({
+ children,
+ onClick,
+ bookingLink,
+ className,
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ bookingLink: string;
+ className?: string;
+}) => {
+ const { t } = useLocale();
+
+ if (onClick) {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onClick();
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+};
+
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
+ const { onClick } = booking;
const {
t,
i18n: { language },
@@ -258,15 +299,11 @@ function BookingListItem(booking: BookingItemProps) {
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
- const buildBookingLink = () => {
- const urlSearchParams = new URLSearchParams({
- allRemainingBookings: isTabRecurring.toString(),
- });
- if (booking.attendees?.[0]?.email) urlSearchParams.set("email", booking.attendees[0].email);
- return `/booking/${booking.uid}?${urlSearchParams.toString()}`;
- };
-
- const bookingLink = buildBookingLink();
+ const bookingLink = buildBookingLink({
+ bookingUid: booking.uid,
+ allRemainingBookings: isTabRecurring,
+ email: booking.attendees?.[0]?.email,
+ });
const title = booking.title;
@@ -321,7 +358,7 @@ function BookingListItem(booking: BookingItemProps) {
{eventTypeColor && (
)}
-
+
{startTime}
@@ -350,7 +387,8 @@ function BookingListItem(booking: BookingItemProps) {
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
{provider?.iconUrl && (
-
)}
-
+
-
+
{/* Time and Badges for mobile */}
@@ -454,7 +492,7 @@ function BookingListItem(booking: BookingItemProps) {
)}
-
+
{shouldShowPendingActions(actionContext) &&
}
diff --git a/apps/web/components/booking/types.ts b/apps/web/components/booking/types.ts
index 87a22251e0..ccac79bf43 100644
--- a/apps/web/components/booking/types.ts
+++ b/apps/web/components/booking/types.ts
@@ -14,4 +14,5 @@ export type BookingItemProps = BookingItem & {
userEmail: string | undefined;
};
isToday: boolean;
+ onClick?: () => void;
};
diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx
new file mode 100644
index 0000000000..72bdeb9db8
--- /dev/null
+++ b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx
@@ -0,0 +1,454 @@
+"use client";
+
+import { useMemo } from "react";
+import { z } from "zod";
+
+import dayjs from "@calcom/dayjs";
+import { useBookingLocation } from "@calcom/features/bookings/hooks";
+import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
+import { formatPrice } from "@calcom/lib/currencyConversions";
+import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
+import { bookingMetadataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
+import type { RecurringEvent } from "@calcom/types/Calendar";
+import classNames from "@calcom/ui/classNames";
+import { Avatar } from "@calcom/ui/components/avatar";
+import { Badge } from "@calcom/ui/components/badge";
+import { Button } from "@calcom/ui/components/button";
+import { Icon } from "@calcom/ui/components/icon";
+import {
+ Sheet,
+ SheetContent,
+ SheetBody,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+} from "@calcom/ui/components/sheet";
+
+import { buildBookingLink } from "../lib/buildBookingLink";
+import type { BookingOutput } from "../types";
+
+type BookingMetaData = z.infer
;
+
+interface BookingDetailsSheetProps {
+ booking: BookingOutput | null;
+ isOpen: boolean;
+ onClose: () => void;
+ userTimeZone?: string;
+ userTimeFormat?: number;
+ onPrevious?: () => void;
+ hasPrevious?: boolean;
+ onNext?: () => void;
+ hasNext?: boolean;
+}
+
+export function BookingDetailsSheet({
+ booking,
+ isOpen,
+ onClose,
+ userTimeZone,
+ onPrevious,
+ hasPrevious = false,
+ onNext,
+ hasNext = false,
+}: BookingDetailsSheetProps) {
+ const { t } = useLocale();
+
+ if (!booking) return null;
+
+ const startTime = dayjs(booking.startTime).tz(userTimeZone);
+ const endTime = dayjs(booking.endTime).tz(userTimeZone);
+
+ // Build booking confirmation link
+ const isRecurring = booking.recurringEventId !== null;
+ const bookingLink = buildBookingLink({
+ bookingUid: booking.uid,
+ allRemainingBookings: isRecurring,
+ email: booking.attendees?.[0]?.email,
+ });
+
+ const getStatusBadge = () => {
+ switch (booking.status) {
+ case "ACCEPTED":
+ return { variant: "green" as const, label: t("confirmed") };
+ case "PENDING":
+ return { variant: "orange" as const, label: t("pending") };
+ case "CANCELLED":
+ return { variant: "red" as const, label: t("cancelled") };
+ case "REJECTED":
+ return { variant: "red" as const, label: t("rejected") };
+ default:
+ return { variant: "gray" as const, label: booking.status };
+ }
+ };
+
+ const statusBadge = getStatusBadge();
+
+ const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
+ const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
+
+ // Get conference link info for Join button
+ const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
+ location: booking.location,
+ videoCallUrl: bookingMetadata?.videoCallUrl,
+ t,
+ bookingStatus: booking.status,
+ });
+
+ const recurringInfo =
+ booking.recurringEventId && booking.eventType?.recurringEvent
+ ? {
+ count: booking.eventType.recurringEvent.count,
+ recurringEvent: booking.eventType.recurringEvent,
+ }
+ : null;
+
+ const customResponses = booking.responses
+ ? Object.entries(booking.responses as Record)
+ .filter(([fieldName]) => shouldShowFieldInCustomResponses(fieldName))
+ .map(([question, answer]) => [question, answer] as [string, unknown])
+ : [];
+
+ return (
+
+
+
+
+ }>
+
+
+ {statusBadge.label}
+
+ {booking.eventType.team && {booking.eventType.team.name}}
+ {recurringInfo && (
+
+
+ {recurringInfo.count}
+
+ )}
+
+
+
+
+
+
+
{booking.title}
+
+ {startTime.format("dddd, MMMM D, YYYY h:mma")} - {endTime.format("h:mma")} (
+ {userTimeZone || startTime.format("Z")})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLocationURL && locationToDisplay && (
+ <>
+
+ {provider?.iconUrl && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+ {provider?.label
+ ? t("join_event_location", { eventLocationType: provider.label })
+ : t("join_meeting")}
+
+
+ >
+ )}
+
+ {t("view")}
+
+
+
+
+
+ );
+}
+
+function WhoSection({ booking }: { booking: BookingOutput }) {
+ const { t } = useLocale();
+ return (
+
+
+ {booking.user && (
+
+
+
+
+
{booking.user.name}
+
+ {t("host")}
+
+
+
{booking.user.email}
+
+
+ )}
+
+ {booking.attendees.map((attendee, idx) => (
+
+
+
+
{attendee.name}
+
{attendee.email}
+
+
+ ))}
+
+
+ );
+}
+
+function WhereSection({ booking, meta }: { booking: BookingOutput; meta: BookingMetaData | null }) {
+ const { t } = useLocale();
+
+ const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
+ location: booking.location,
+ videoCallUrl: meta?.videoCallUrl,
+ t,
+ bookingStatus: booking.status,
+ });
+
+ if (!locationToDisplay) {
+ return null;
+ }
+
+ if (!isLocationURL) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {provider?.iconUrl && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+
+
+ );
+}
+
+function RecurringInfoSection({
+ recurringInfo,
+}: {
+ recurringInfo: { count: number; recurringEvent: RecurringEvent } | null;
+}) {
+ const { t } = useLocale();
+
+ if (!recurringInfo || recurringInfo.count <= 0) {
+ return null;
+ }
+
+ return (
+
+
+ {getEveryFreqFor({
+ t,
+ recurringEvent: recurringInfo.recurringEvent,
+ recurringCount: recurringInfo.count,
+ })}
+
+
+ );
+}
+
+function PaymentSection({ booking }: { booking: BookingOutput }) {
+ const { t } = useLocale();
+
+ if (!booking.paid || !Array.isArray(booking.payment) || booking.payment.length === 0) {
+ return null;
+ }
+
+ const payment = booking.payment[0];
+ const formattedPrice = formatPrice(payment.amount, payment.currency);
+
+ return (
+
+ );
+}
+
+function SlotsSection({ booking }: { booking: BookingOutput }) {
+ const { t } = useLocale();
+
+ if (
+ !booking.eventType?.seatsShowAvailabilityCount ||
+ !booking.eventType?.seatsPerTimeSlot ||
+ booking.eventType.seatsPerTimeSlot <= 0
+ ) {
+ return null;
+ }
+
+ const totalSeats = booking.eventType.seatsPerTimeSlot;
+ const takenSeats = booking.attendees.length;
+
+ return (
+
+ {t("slots_taken", { takenSeats, totalSeats })}
+
+ );
+}
+
+function CustomQuestionsSection({
+ customResponses,
+ bookingFields: rawBookingFields,
+}: {
+ customResponses: [string, unknown][];
+ bookingFields?: unknown;
+}) {
+ const { t } = useLocale();
+
+ // Parse and memoize booking fields
+ const bookingFields = useMemo(() => {
+ if (!rawBookingFields) return undefined;
+ const parsed = eventTypeBookingFields.safeParse(rawBookingFields);
+ return parsed.success ? parsed.data : undefined;
+ }, [rawBookingFields]);
+
+ // Filter out responses with falsy answers or empty arrays
+ const validResponses = useMemo(
+ (): [string, unknown][] =>
+ customResponses.filter(([, answer]) => {
+ if (!answer) return false;
+ if (Array.isArray(answer) && answer.length === 0) return false;
+ return true;
+ }),
+ [customResponses]
+ );
+
+ // Memoize field label lookups
+ const fieldLabels = useMemo(() => {
+ if (!bookingFields) return new Map();
+
+ return new Map(
+ bookingFields.map(
+ (field) => [field.name, field.label || t(field.defaultLabel || field.name)] as [string, string]
+ )
+ );
+ }, [bookingFields, t]);
+
+ if (validResponses.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ {validResponses.map(([fieldName, answer], idx) => {
+ const fieldNameStr = String(fieldName);
+ const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
+ return (
+
+ );
+ })}
+ >
+ );
+}
+
+function DescriptionSection({ booking }: { booking: BookingOutput }) {
+ const { t } = useLocale();
+
+ if (!booking.description) {
+ return null;
+ }
+
+ return (
+
+ {booking.description}
+
+ );
+}
+
+function Section({
+ title,
+ className,
+ children,
+}: {
+ title: string;
+ className?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
diff --git a/apps/web/modules/bookings/hooks/useBookingCursor.ts b/apps/web/modules/bookings/hooks/useBookingCursor.ts
new file mode 100644
index 0000000000..a271898c8b
--- /dev/null
+++ b/apps/web/modules/bookings/hooks/useBookingCursor.ts
@@ -0,0 +1,43 @@
+import { useMemo, useCallback } from "react";
+
+import type { RowData } from "../types";
+
+function isDataRow(row: RowData): row is Extract {
+ return row.type === "data";
+}
+
+export function useBookingCursor({
+ bookings,
+ selectedBookingId,
+ setSelectedBookingId,
+}: {
+ bookings: RowData[];
+ 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]
+ );
+
+ const onPrevious = useCallback(() => {
+ if (currentIndex >= 1) {
+ setSelectedBookingId(bookingRows[currentIndex - 1].booking.id);
+ }
+ }, [bookingRows, currentIndex, setSelectedBookingId]);
+
+ const onNext = useCallback(() => {
+ if (currentIndex >= 0 && currentIndex < bookingRows.length - 1) {
+ setSelectedBookingId(bookingRows[currentIndex + 1].booking.id);
+ }
+ }, [bookingRows, currentIndex, setSelectedBookingId]);
+
+ return {
+ onPrevious,
+ onNext,
+ hasPrevious: currentIndex > 0,
+ hasNext: currentIndex < bookingRows.length - 1 && currentIndex >= 0,
+ };
+}
diff --git a/apps/web/modules/bookings/lib/buildBookingLink.ts b/apps/web/modules/bookings/lib/buildBookingLink.ts
new file mode 100644
index 0000000000..5007a04341
--- /dev/null
+++ b/apps/web/modules/bookings/lib/buildBookingLink.ts
@@ -0,0 +1,24 @@
+/**
+ * Builds a booking link with query parameters
+ * @param bookingUid - The unique identifier for the booking
+ * @param allRemainingBookings - Whether to include all remaining bookings
+ * @param email - Optional email of the attendee
+ * @returns The formatted booking link with query parameters
+ */
+export function buildBookingLink({
+ bookingUid,
+ allRemainingBookings,
+ email,
+}: {
+ bookingUid: string;
+ allRemainingBookings: boolean;
+ email?: string | null;
+}): string {
+ const urlSearchParams = new URLSearchParams({
+ allRemainingBookings: allRemainingBookings.toString(),
+ });
+ if (email) {
+ urlSearchParams.set("email", email);
+ }
+ return `/booking/${bookingUid}?${urlSearchParams.toString()}`;
+}
diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx
index d663f2a198..6c3c35e8de 100644
--- a/apps/web/modules/bookings/views/bookings-single-view.tsx
+++ b/apps/web/modules/bookings/views/bookings-single-view.tsx
@@ -26,7 +26,7 @@ import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/li
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
-import { SMS_REMINDER_NUMBER_FIELD, SystemField, TITLE_FIELD } from "@calcom/lib/bookings/SystemField";
+import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
import { APP_NAME } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
@@ -776,15 +776,9 @@ export default function Success(props: PageProps) {
// We show notes in additional notes section
// We show rescheduleReason at the top
- const isSystemField = SystemField.safeParse(field.name);
- // SMS_REMINDER_NUMBER_FIELD is a system field but doesn't have a dedicated place in the UI. So, it would be shown through the following responses list
- // TITLE is also an identifier for booking question "What is this meeting about?"
- if (
- isSystemField.success &&
- field.name !== SMS_REMINDER_NUMBER_FIELD &&
- field.name !== TITLE_FIELD
- )
+ if (!shouldShowFieldInCustomResponses(field.name)) {
return null;
+ }
const label = field.label || t(field.defaultLabel);
diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx
index ee707ab576..dcc1c8f171 100644
--- a/apps/web/modules/bookings/views/bookings-view.tsx
+++ b/apps/web/modules/bookings/views/bookings-view.tsx
@@ -32,8 +32,10 @@ import BookingListItem from "@components/booking/BookingListItem";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import type { validStatuses } from "~/bookings/lib/validStatuses";
+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";
type BookingsProps = {
@@ -99,6 +101,12 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
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() || "";
@@ -277,6 +285,8 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
}}
listingStatus={status}
recurringInfo={recurringInfo}
+ // uncomment this line to enable BookingDetailsSheet
+ // onClick={() => setSelectedBookingId(booking.id)}
{...booking}
/>
);
@@ -369,6 +379,21 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
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({
@@ -442,6 +467,17 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
)}
+ setSelectedBookingId(null)}
+ userTimeZone={user?.timeZone}
+ userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
+ onPrevious={bookingNavigation.onPrevious}
+ hasPrevious={bookingNavigation.hasPrevious}
+ onNext={bookingNavigation.onNext}
+ hasNext={bookingNavigation.hasNext}
+ />
);
}
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 05ff62c062..9b13c3ebf2 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -3957,7 +3957,10 @@
"team_brand_subtitle": "Customize your team's branding with a logo and color",
"team_details": "Team Details",
"team_invite": "Team Invite",
+ "slots": "Slots",
+ "slots_taken": "{{takenSeats}}/{{totalSeats}} slots taken",
"team_invite_subtitle": "Invite team members to collaborate and schedule together",
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
+ "view_booking_details": "View Booking Details",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
diff --git a/packages/features/bookings/hooks/index.ts b/packages/features/bookings/hooks/index.ts
new file mode 100644
index 0000000000..9c6df62032
--- /dev/null
+++ b/packages/features/bookings/hooks/index.ts
@@ -0,0 +1,2 @@
+export { useBookerUrl, useEmbedBookerUrl } from "./useBookerUrl";
+export { useBookingLocation } from "./useBookingLocation";
diff --git a/packages/features/bookings/hooks/useBookingLocation.ts b/packages/features/bookings/hooks/useBookingLocation.ts
new file mode 100644
index 0000000000..e34206527e
--- /dev/null
+++ b/packages/features/bookings/hooks/useBookingLocation.ts
@@ -0,0 +1,77 @@
+import type { TFunction } from "i18next";
+import { useMemo } from "react";
+
+import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/app-store/locations";
+import type { BookingStatus } from "@calcom/prisma/enums";
+
+interface UseBookingLocationParams {
+ /**
+ * The booking location value from the booking object
+ */
+ location: string | null | undefined;
+ /**
+ * The video call URL from booking metadata (if available)
+ */
+ videoCallUrl?: string | null;
+ /**
+ * Translation function
+ */
+ t: TFunction;
+ /**
+ * Booking status (optional)
+ */
+ bookingStatus?: BookingStatus;
+}
+
+interface UseBookingLocationReturn {
+ /**
+ * The location message to display (URL or text)
+ */
+ locationToDisplay: string | null;
+ /**
+ * The provider information (label, iconUrl, etc.)
+ */
+ provider: ReturnType;
+ /**
+ * Whether the location is a URL that can be clicked
+ */
+ isLocationURL: boolean;
+}
+
+/**
+ * Custom hook to process booking location information
+ *
+ * This hook handles the common pattern of:
+ * 1. Determining the actual location (video call URL vs. raw location)
+ * 2. Getting the display message for the location
+ * 3. Determining the provider type
+ * 4. Checking if the location is a clickable URL
+ */
+export function useBookingLocation({
+ location,
+ videoCallUrl,
+ t,
+ bookingStatus,
+}: UseBookingLocationParams): UseBookingLocationReturn {
+ const locationToDisplay = useMemo(() => {
+ if (!location) return null;
+
+ const effectiveLocation = videoCallUrl ?? location;
+ return getSuccessPageLocationMessage(effectiveLocation, t, bookingStatus);
+ }, [location, videoCallUrl, t, bookingStatus]);
+
+ const provider = useMemo(() => {
+ if (!location) return null;
+ return guessEventLocationType(location);
+ }, [location]);
+
+ const isLocationURL = useMemo(() => {
+ return typeof locationToDisplay === "string" && locationToDisplay.startsWith("http");
+ }, [locationToDisplay]);
+
+ return {
+ locationToDisplay,
+ provider,
+ isLocationURL,
+ };
+}
diff --git a/packages/lib/bookings/SystemField.ts b/packages/lib/bookings/SystemField.ts
index 4e1af78b98..9b8b4fe7f5 100644
--- a/packages/lib/bookings/SystemField.ts
+++ b/packages/lib/bookings/SystemField.ts
@@ -16,3 +16,20 @@ export const SystemField = z.enum([
export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber";
export const CAL_AI_AGENT_PHONE_NUMBER_FIELD = "aiAgentCallPhoneNumber";
export const TITLE_FIELD = "title";
+
+/**
+ * Check if a field should be displayed in custom responses section.
+ * System fields are filtered out except for SMS_REMINDER_NUMBER_FIELD and TITLE_FIELD
+ * which don't have dedicated sections in the UI.
+ */
+export function shouldShowFieldInCustomResponses(fieldName: string): boolean {
+ const isSystemField = SystemField.safeParse(fieldName);
+
+ // Filter out system fields except SMS_REMINDER_NUMBER_FIELD and TITLE_FIELD
+ // These don't have dedicated sections in the UI
+ if (isSystemField.success && fieldName !== SMS_REMINDER_NUMBER_FIELD && fieldName !== TITLE_FIELD) {
+ return false;
+ }
+
+ return true;
+}
diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts
index caf7e0ab73..5a81a91a0f 100644
--- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts
+++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts
@@ -498,6 +498,7 @@ export async function getBookings({
"EventType.currency",
"EventType.metadata",
"EventType.disableGuests",
+ "EventType.bookingFields",
"EventType.seatsPerTimeSlot",
"EventType.seatsShowAttendees",
"EventType.seatsShowAvailabilityCount",
diff --git a/packages/ui/components/sheet/Sheet.tsx b/packages/ui/components/sheet/Sheet.tsx
index ee499d2424..e0cdeb6bf7 100644
--- a/packages/ui/components/sheet/Sheet.tsx
+++ b/packages/ui/components/sheet/Sheet.tsx
@@ -91,16 +91,18 @@ SheetContent.displayName = "SheetContent";
const SheetHeader = React.forwardRef<
HTMLDivElement,
- React.ComponentPropsWithoutRef<"div"> & { showCloseButton?: boolean }
->(({ children, className, showCloseButton = true, ...props }, ref) => {
+ React.ComponentPropsWithoutRef<"div"> & { showCloseButton?: boolean; rightContent?: React.ReactNode }
+>(({ children, className, showCloseButton = true, rightContent, ...props }, ref) => {
return (
{children}
- {showCloseButton && (
-
-
-
- )}
+ {rightContent !== undefined
+ ? rightContent
+ : showCloseButton && (
+
+
+
+ )}
);
});
diff --git a/scripts/seed-insights.ts b/scripts/seed-insights.ts
index ee3757a037..8440b802fc 100644
--- a/scripts/seed-insights.ts
+++ b/scripts/seed-insights.ts
@@ -83,6 +83,35 @@ const shuffle = (
booking.ratingFeedback = getRandomRatingFeedback(); // Random feedback from a predefined list
booking.noShowHost = Math.random() < 0.5;
+ // Add random video call links to 40% of bookings
+ const random = Math.random();
+ if (random < 0.2) {
+ // 20% Google Meet
+ const randomMeetId = Math.random().toString(36).substring(2, 15);
+ booking.metadata = {
+ videoCallUrl: `https://meet.google.com/${randomMeetId}`,
+ };
+ booking.location = "integrations:google:meet";
+ } else if (random < 0.35) {
+ // 15% Zoom
+ const randomZoomId = Math.floor(Math.random() * 1000000000);
+ booking.metadata = {
+ videoCallUrl: `https://zoom.us/j/${randomZoomId}`,
+ };
+ booking.location = "integrations:zoom";
+ } else if (random < 0.4) {
+ // 5% Cal Video
+ booking.metadata = {
+ videoCallUrl: `https://cal.com/video/${uuidv4()}`,
+ };
+ booking.location = "integrations:daily";
+ }
+
+ // Add recurring event ID to 10% of bookings from recurring events
+ if (randomEvent.recurringEvent && Math.random() < 0.1) {
+ booking.recurringEventId = uuidv4();
+ }
+
return booking;
};
@@ -103,6 +132,173 @@ async function createAttendees(bookings: any[]) {
}
}
+async function createPayments(bookings: any[]) {
+ // Filter for paid bookings
+ const paidBookings = bookings.filter((booking) => booking.paid);
+
+ if (paidBookings.length === 0) {
+ console.log("No paid bookings found, skipping payment creation");
+ return;
+ }
+
+ console.log(`Creating payments for ${paidBookings.length} paid bookings...`);
+
+ for (const booking of paidBookings) {
+ // Use event type's price and currency, with fallback to defaults
+ const amount = booking.eventType?.price || 5000; // Default to $50.00 if not set
+ const currency = booking.eventType?.currency || "usd";
+ const externalIdPrefix = "ch_"; // Stripe charge ID
+
+ await prisma.payment.create({
+ data: {
+ uid: uuidv4(),
+ appId: "stripe",
+ bookingId: booking.id,
+ amount,
+ fee: Math.floor(amount * 0.029) + 30, // Typical Stripe fee: 2.9% + $0.30
+ currency,
+ success: true,
+ refunded: false,
+ data: {},
+ externalId: `${externalIdPrefix}${uuidv4().substring(0, 24)}`,
+ },
+ });
+ }
+
+ console.log(`Successfully created payments for ${paidBookings.length} bookings`);
+}
+
+async function createPaidEventTypeAndBookings(organizationId: number) {
+ console.log("Creating paid event type and bookings...");
+
+ // Find the insights team
+ const insightsTeam = await prisma.team.findFirst({
+ where: {
+ slug: "insights-team",
+ parentId: organizationId,
+ },
+ });
+
+ if (!insightsTeam) {
+ console.log("Insights team not found, skipping paid event type creation");
+ return;
+ }
+
+ // Get team members
+ const insightsTeamMembers = await prisma.membership.findMany({
+ where: {
+ teamId: insightsTeam.id,
+ },
+ include: {
+ user: true,
+ },
+ });
+
+ if (insightsTeamMembers.length === 0) {
+ console.log("No team members found, skipping paid 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 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,
+ teamId: insightsTeam.id,
+ schedulingType: "ROUND_ROBIN",
+ assignAllTeamMembers: true,
+ price: 5000, // $50.00
+ currency: "usd",
+ metadata: {
+ apps: {
+ stripe: {
+ price: 5000,
+ currency: "usd",
+ enabled: 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,
+ });
+
+ console.log(`Created paid event type: ${paidEventType.title}`);
+ }
+
+ // 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,
+ };
+
+ console.log(`Creating 100 paid bookings for event type: ${paidEventType.title}`);
+ 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;
+ }),
+ ],
+ });
+
+ // Get the paid bookings we just created
+ const paidBookings = await prisma.booking.findMany({
+ where: {
+ eventTypeId: paidEventType.id,
+ paid: true,
+ },
+ include: {
+ eventType: {
+ select: {
+ price: true,
+ currency: true,
+ },
+ },
+ },
+ });
+
+ // Create attendees for paid bookings
+ await createAttendees(paidBookings);
+
+ // Create payments for paid bookings
+ await createPayments(paidBookings);
+
+ console.log(`Successfully created ${paidBookings.length} paid bookings with payments`);
+}
+
async function seedBookingAssignments() {
const assignmentReasons = [
AssignmentReasonEnum.ROUTING_FORM_ROUTING,
@@ -218,6 +414,7 @@ async function main() {
teamId: true,
userId: true,
assignAllTeamMembers: true,
+ recurringEvent: true,
},
});
@@ -251,6 +448,20 @@ async function main() {
schedulingType: "ROUND_ROBIN",
assignAllTeamMembers: true,
},
+ {
+ title: "Weekly Standup",
+ slug: "weekly-standup",
+ description: "Weekly team standup meeting",
+ length: 30,
+ teamId: insightsTeam.id,
+ schedulingType: "ROUND_ROBIN",
+ assignAllTeamMembers: true,
+ recurringEvent: {
+ freq: 2, // Weekly (RRULE freq: WEEKLY = 2)
+ count: 10,
+ interval: 1,
+ },
+ },
],
});
@@ -267,6 +478,7 @@ async function main() {
teamId: true,
userId: true,
assignAllTeamMembers: true,
+ recurringEvent: true,
},
});
@@ -343,7 +555,8 @@ async function main() {
],
});
- await createAttendees(await prisma.booking.findMany());
+ const allBookings = await prisma.booking.findMany();
+ await createAttendees(allBookings);
// Find owner of the organization
const owner = orgMembers.find((m) => m.role === "OWNER" || m.role === "ADMIN");
@@ -364,29 +577,31 @@ async function main() {
}
await seedBookingAssignments();
+
+ // Create paid event type and bookings separately
+ await createPaidEventTypeAndBookings(organization.id);
}
async function runMain() {
- await main()
- .catch(async (e) => {
- console.error(e);
- await prisma.user.deleteMany({
- where: {
- email: {
- in: ["insights@example", "insightsuser@example.com"],
- },
+ await main().catch(async (e) => {
+ console.error(e);
+ await prisma.user.deleteMany({
+ where: {
+ email: {
+ in: ["insights@example", "insightsuser@example.com"],
},
- });
-
- await prisma.team.deleteMany({
- where: {
- slug: "insights",
- },
- });
-
- await prisma.$disconnect();
- process.exit(1);
+ },
});
+
+ await prisma.team.deleteMany({
+ where: {
+ slug: "insights",
+ },
+ });
+
+ await prisma.$disconnect();
+ process.exit(1);
+ });
}
/**
@@ -525,19 +740,18 @@ async function createPerformanceData() {
}
async function runPerformanceData() {
- await createPerformanceData()
- .catch(async (e) => {
- console.error(e);
- await prisma.user.deleteMany({
- where: {
- username: {
- contains: "insights-user-",
- },
+ await createPerformanceData().catch(async (e) => {
+ console.error(e);
+ await prisma.user.deleteMany({
+ where: {
+ username: {
+ contains: "insights-user-",
},
- });
- await prisma.$disconnect();
- process.exit(1);
+ },
});
+ await prisma.$disconnect();
+ process.exit(1);
+ });
}
async function runEverything() {