From 4407a1952cb9fa07bf00e27345e49810b7bb3602 Mon Sep 17 00:00:00 2001 From: Rodrigo Ehlers Date: Tue, 11 Nov 2025 12:40:55 +0100 Subject: [PATCH] feat: add booking details sheet for /bookings (#24795) * spike: initial booking detail sheet * rename button * revert some changes * remove unnecessary test * disable booking details sheet --------- Co-authored-by: Eunjae Lee Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../components/booking/BookingListItem.tsx | 68 ++- apps/web/components/booking/types.ts | 1 + .../components/BookingDetailsSheet.tsx | 454 ++++++++++++++++++ .../bookings/hooks/useBookingCursor.ts | 43 ++ .../modules/bookings/lib/buildBookingLink.ts | 24 + .../bookings/views/bookings-single-view.tsx | 12 +- .../modules/bookings/views/bookings-view.tsx | 36 ++ apps/web/public/static/locales/en/common.json | 3 + packages/features/bookings/hooks/index.ts | 2 + .../bookings/hooks/useBookingLocation.ts | 77 +++ packages/lib/bookings/SystemField.ts | 17 + .../routers/viewer/bookings/get.handler.ts | 1 + packages/ui/components/sheet/Sheet.tsx | 16 +- scripts/seed-insights.ts | 274 +++++++++-- 14 files changed, 967 insertions(+), 61 deletions(-) create mode 100644 apps/web/modules/bookings/components/BookingDetailsSheet.tsx create mode 100644 apps/web/modules/bookings/hooks/useBookingCursor.ts create mode 100644 apps/web/modules/bookings/lib/buildBookingLink.ts create mode 100644 packages/features/bookings/hooks/index.ts create mode 100644 packages/features/bookings/hooks/useBookingLocation.ts 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 && ( + <> + +
+ + )} + +
+ + + + ); +} + +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 ( +
+

{locationToDisplay}

+
+ ); + } + + return ( +
+
+ {provider?.iconUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {`${provider?.label} + )} +
+ {provider?.label}: + + {locationToDisplay} + +
+
+
+ ); +} + +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 ( +
+

{formattedPrice}

+
+ ); +} + +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 ( +
+

{String(answer)}

+
+ ); + })} + + ); +} + +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 && ( - -
); }); 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() {