diff --git a/apps/web/components/booking/BookingDescription.tsx b/apps/web/components/booking/BookingDescription.tsx index 6e9b2bcc6e..01598b5caa 100644 --- a/apps/web/components/booking/BookingDescription.tsx +++ b/apps/web/components/booking/BookingDescription.tsx @@ -1,6 +1,7 @@ import { SchedulingType } from "@prisma/client"; import { FC, ReactNode, useEffect } from "react"; +import dayjs from "@calcom/dayjs"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Icon, Badge } from "@calcom/ui"; @@ -41,6 +42,7 @@ interface Props { const BookingDescription: FC = (props) => { const { profile, eventType, isBookingPage = false, children } = props; + const { date: bookingDate } = useRouterQuery("date"); const { t } = useLocale(); const { duration, setQuery: setDuration } = useRouterQuery("duration"); useEffect(() => { @@ -54,6 +56,21 @@ const BookingDescription: FC = (props) => { } } }, []); + let requiresConfirmation = eventType?.requiresConfirmation; + let requiresConfirmationText = t("requires_confirmation"); + const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold; + if (rcThreshold) { + if (isBookingPage) { + if (dayjs(bookingDate).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) { + requiresConfirmation = false; + } + } else { + requiresConfirmationText = t("requires_confirmation_threshold", { + ...rcThreshold, + unit: rcThreshold.unit.slice(0, -1), + }); + } + } return ( <> = (props) => { )} - {eventType?.requiresConfirmation && ( + {requiresConfirmation && (
- +
- {t("requires_confirmation")} + {requiresConfirmationText}
)} - +

{timeZone} diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 98ac176216..1ad322503b 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -27,6 +27,8 @@ import { import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm"; +import RequiresConfirmationController from "./RequiresConfirmationController"; + const generateHashedLink = (id: number) => { const translator = short(); const seed = `${id}:${new Date().getTime()}`; @@ -47,6 +49,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick(undefined); const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false); + const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation); const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`; const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled"); @@ -159,18 +162,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick


- ( - onChange(e)} - disabled={seatsEnabled} - /> - )} +
(undefined); const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration); - const multipleDurationOptions = [ - { value: 5, label: t("multiple_duration_mins", { count: 5 }) }, - { value: 10, label: t("multiple_duration_mins", { count: 10 }) }, - { value: 15, label: t("multiple_duration_mins", { count: 15 }) }, - { value: 20, label: t("multiple_duration_mins", { count: 20 }) }, - { value: 25, label: t("multiple_duration_mins", { count: 25 }) }, - { value: 30, label: t("multiple_duration_mins", { count: 30 }) }, - { value: 45, label: t("multiple_duration_mins", { count: 45 }) }, - { value: 50, label: t("multiple_duration_mins", { count: 50 }) }, - { value: 60, label: t("multiple_duration_mins", { count: 60 }) }, - { value: 75, label: t("multiple_duration_mins", { count: 75 }) }, - { value: 80, label: t("multiple_duration_mins", { count: 80 }) }, - { value: 90, label: t("multiple_duration_mins", { count: 90 }) }, - { value: 120, label: t("multiple_duration_mins", { count: 120 }) }, - { value: 180, label: t("multiple_duration_mins", { count: 180 }) }, - ]; + const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({ + value: mins, + label: t("multiple_duration_mins", { count: mins }), + })); const [selectedMultipleDuration, setSelectedMultipleDuration] = useState< MultiValue<{ diff --git a/apps/web/components/eventtype/RequiresConfirmationController.tsx b/apps/web/components/eventtype/RequiresConfirmationController.tsx new file mode 100644 index 0000000000..2be9a06891 --- /dev/null +++ b/apps/web/components/eventtype/RequiresConfirmationController.tsx @@ -0,0 +1,156 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { UnitTypeLongPlural } from "dayjs"; +import { Trans } from "next-i18next"; +import type { FormValues } from "pages/event-types/[type]"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import z from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { Alert, Input, Label, SettingsToggle } from "@calcom/ui"; + +type RequiresConfirmationControllerProps = { + metadata: z.infer; + requiresConfirmation: boolean; + onRequiresConfirmation: Dispatch>; + seatsEnabled: boolean; +}; + +export default function RequiresConfirmationController({ + metadata, + requiresConfirmation, + onRequiresConfirmation, + seatsEnabled, +}: RequiresConfirmationControllerProps) { + const { t } = useLocale(); + const [requiresConfirmationSetup, setRequiresConfirmationSetup] = useState( + metadata?.requiresConfirmationThreshold + ); + const defaultRequiresConfirmationSetup = { time: 30, unit: "minutes" as UnitTypeLongPlural }; + const formMethods = useFormContext(); + + useEffect(() => { + if (!requiresConfirmation) { + formMethods.setValue("metadata.requiresConfirmationThreshold", undefined); + } + }, [requiresConfirmation]); + + return ( +
+
+ {seatsEnabled ? ( + + ) : ( + ( + { + formMethods.setValue("requiresConfirmation", val); + onRequiresConfirmation(val); + }}> + { + if (val === "always") { + formMethods.setValue("requiresConfirmation", true); + onRequiresConfirmation(true); + formMethods.setValue("metadata.requiresConfirmationThreshold", undefined); + setRequiresConfirmationSetup(undefined); + } else if (val === "notice") { + formMethods.setValue("requiresConfirmation", true); + onRequiresConfirmation(true); + formMethods.setValue( + "metadata.requiresConfirmationThreshold", + requiresConfirmationSetup || defaultRequiresConfirmationSetup + ); + } + }}> +
+
+ + + + +
+
+ + + +
+ ), + }} + /> + +
+
+ + + )} + /> + )} +
+ + ); +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 0c22af01cb..f7309ff29d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -704,6 +704,7 @@ "hide_event_type": "Hide event type", "edit_location": "Edit location", "into_the_future": "into the future", + "when_booked_with_less_than_notice": "When booked with less than notice", "within_date_range": "Within a date range", "indefinitely_into_future": "Indefinitely into the future", "add_new_custom_input_field": "Add new custom input field", @@ -1028,6 +1029,9 @@ "error_removing_app": "Error removing app", "web_conference": "Web conference", "requires_confirmation": "Requires confirmation", + "always_requires_confirmation": "Always", + "requires_confirmation_threshold": "Requires confirmation if booked with < {{time}} $t({{unit}}_timeUnit) notice", + "may_require_confirmation": "May require confirmation", "nr_event_type_one": "{{count}} event type", "nr_event_type_other": "{{count}} event types", "add_action": "Add action", diff --git a/packages/features/bookings/UnconfirmedBookingBadge.tsx b/packages/features/bookings/UnconfirmedBookingBadge.tsx index 6c8e94925b..e776b5d6b9 100644 --- a/packages/features/bookings/UnconfirmedBookingBadge.tsx +++ b/packages/features/bookings/UnconfirmedBookingBadge.tsx @@ -11,11 +11,15 @@ export default function UnconfirmedBookingBadge() { else return ( - - + <> + {unconfirmedBookingCount} - + ); } diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index b2b7ee77eb..9bb60477e0 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -480,6 +480,14 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) { const additionalNotes = reqBody.notes; + let requiresConfirmation = eventType?.requiresConfirmation; + const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold; + if (rcThreshold) { + if (dayjs(dayjs(reqBody.start).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) { + requiresConfirmation = false; + } + } + let evt: CalendarEvent = { type: eventType.title, title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately @@ -499,7 +507,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) { /** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */ destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar, hideCalendarNotes: eventType.hideCalendarNotes, - requiresConfirmation: eventType.requiresConfirmation ?? false, + requiresConfirmation: requiresConfirmation ?? false, eventTypeId: eventType.id, seatsShowAttendees: !!eventType.seatsShowAttendees, }; @@ -651,8 +659,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) { // Otherwise, an owner rescheduling should be always accepted. // Before comparing make sure that userId is set, otherwise undefined === undefined const userReschedulingIsOwner = userId && originalRescheduledBooking?.user?.id === userId; - const isConfirmedByDefault = - (!eventType.requiresConfirmation && !stripeAppData.price) || userReschedulingIsOwner; + const isConfirmedByDefault = (!requiresConfirmation && !stripeAppData.price) || userReschedulingIsOwner; async function createBooking() { if (originalRescheduledBooking) { @@ -875,7 +882,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) { } // If it's not a reschedule, doesn't require confirmation and there's no price, // Create a booking - } else if (!eventType.requiresConfirmation && !stripeAppData.price) { + } else if (!requiresConfirmation && !stripeAppData.price) { // Use EventManager to conditionally use all needed integrations. const createManager = await eventManager.create(evt); @@ -981,7 +988,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) { const eventTypeInfo: EventTypeInfo = { eventTitle: eventType.title, eventDescription: eventType.description, - requiresConfirmation: eventType.requiresConfirmation || null, + requiresConfirmation: requiresConfirmation || null, price: stripeAppData.price, currency: eventType.currency, length: eventType.length, diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index d1470c4690..e6254b3b61 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -1,4 +1,5 @@ import { EventTypeCustomInputType } from "@prisma/client"; +import { UnitTypeLongPlural } from "dayjs"; import z, { ZodNullable, ZodObject, ZodOptional } from "zod"; /* eslint-disable no-underscore-dangle */ @@ -26,6 +27,8 @@ export enum Frequency { SECONDLY = 6, } +export const RequiresConfirmationThresholdUnits: z.ZodType = z.enum(["hours", "minutes"]); + export const EventTypeMetaDataSchema = z .object({ smartContractAddress: z.string().optional(), @@ -34,6 +37,12 @@ export const EventTypeMetaDataSchema = z giphyThankYouPage: z.string().optional(), apps: z.object(appDataSchemas).partial().optional(), additionalNotesRequired: z.boolean().optional(), + requiresConfirmationThreshold: z + .object({ + time: z.number(), + unit: RequiresConfirmationThresholdUnits, + }) + .optional(), config: z .object({ useHostSchedulesForTeamEvent: z.boolean().optional(), diff --git a/packages/ui/v2/core/Switch.tsx b/packages/ui/v2/core/Switch.tsx index ba76e4af15..5582adf195 100644 --- a/packages/ui/v2/core/Switch.tsx +++ b/packages/ui/v2/core/Switch.tsx @@ -14,11 +14,11 @@ const Switch = ( fitToHeight?: boolean; } ) => { - const { label, ...primitiveProps } = props; + const { label, fitToHeight, ...primitiveProps } = props; const id = useId(); return ( -
+
- {t("requires_confirmation")} + {eventType.metadata?.requiresConfirmationThreshold + ? t("may_require_confirmation") + : t("requires_confirmation")} )}