diff --git a/apps/web/components/booking/BookingDescriptionPayment.tsx b/apps/web/components/booking/BookingDescriptionPayment.tsx index 92fa5e755b..9904c2b6c3 100644 --- a/apps/web/components/booking/BookingDescriptionPayment.tsx +++ b/apps/web/components/booking/BookingDescriptionPayment.tsx @@ -1,22 +1,35 @@ +import type { TFunction } from "next-i18next"; import { FormattedNumber, IntlProvider } from "react-intl"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { FiCreditCard } from "@calcom/ui/components/icon"; -const BookingDescriptionPayment = (props: { eventType: Parameters[0] }) => { +const BookingDescriptionPayment = (props: { + eventType: Parameters[0]; + t: TFunction; +}) => { const paymentAppData = getPaymentAppData(props.eventType); if (!paymentAppData || paymentAppData.price <= 0) return null; return (

- - - + {paymentAppData.paymentOption === "HOLD" ? ( + <> + {props.t("no_show_fee_amount", { + amount: paymentAppData.price / 100.0, + formatParams: { amount: { currency: paymentAppData.currency } }, + })} + + ) : ( + + + + )}

); }; diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 6577367b43..3d98abb1fe 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -28,10 +28,20 @@ import { TableActions, TextAreaField, } from "@calcom/ui"; -import { FiCheck, FiClock, FiMapPin, FiRefreshCcw, FiSend, FiSlash, FiX } from "@calcom/ui/components/icon"; +import { + FiCheck, + FiClock, + FiMapPin, + FiRefreshCcw, + FiSend, + FiSlash, + FiX, + FiCreditCard, +} from "@calcom/ui/components/icon"; import useMeQuery from "@lib/hooks/useMeQuery"; +import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; @@ -56,7 +66,9 @@ function BookingListItem(booking: BookingItemProps) { const router = useRouter(); const [rejectionReason, setRejectionReason] = useState(""); const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); + const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false); const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState(false); + const cardCharged = booking?.payment[0]?.success; const mutation = trpc.viewer.bookings.confirm.useMutation({ onSuccess: (data) => { if (data?.status === BookingStatus.REJECTED) { @@ -116,16 +128,20 @@ function BookingListItem(booking: BookingItemProps) { icon: FiSlash, disabled: mutation.isLoading, }, - { - id: "confirm", - label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"), - onClick: () => { - bookingConfirm(true); - }, - icon: FiCheck, - disabled: mutation.isLoading, - color: "primary", - }, + // For bookings with payment, only confirm if the booking is paid for + ...((isPending && !booking?.eventType?.price) || (!!booking?.eventType?.price && booking.paid) + ? [ + { + id: "confirm", + label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"), + onClick: () => { + bookingConfirm(true); + }, + icon: FiCheck, + disabled: mutation.isLoading, + }, + ] + : []), ]; const showRecordingActions: ActionType[] = [ @@ -184,6 +200,18 @@ function BookingListItem(booking: BookingItemProps) { }, ]; + const chargeCardActions: ActionType[] = [ + { + id: "charge_card", + label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"), + disabled: cardCharged, + onClick: () => { + setChargeCardDialogIsOpen(true); + }, + icon: FiCreditCard, + }, + ]; + if (isTabRecurring && isRecurring) { bookedActions = bookedActions.filter((action) => action.id !== "edit_booking"); } @@ -254,6 +282,15 @@ function BookingListItem(booking: BookingItemProps) { isOpenDialog={isOpenSetLocationDialog} setShowLocationModal={setIsOpenLocationDialog} /> + {booking.paid && ( + + )} {showRecordingsButtons && ( - {t("paid")} + {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} )} {recurringDates !== undefined && ( @@ -423,6 +460,11 @@ function BookingListItem(booking: BookingItemProps) { )} + {booking.status === "ACCEPTED" && booking.paid && booking?.payment[0]?.paymentOption === "HOLD" && ( +
+ +
+ )} diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 0effbe9997..f0f5170a39 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -124,6 +124,16 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => { [timeZone] ); const paymentAppData = getPaymentAppData(eventType); + const paymentAmount = () => { + return; + + + ; + }; const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {}; const rawSlug = profile.slug ? profile.slug.split("/") : []; if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug. @@ -237,13 +247,22 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => { {paymentAppData.price > 0 && (

- - - + {paymentAppData.paymentOption === "HOLD" ? ( + <> + {t("no_show_fee_amount", { + amount: paymentAppData.price / 100.0, + formatParams: { amount: { currency: paymentAppData.currency } }, + })} + + ) : ( + + + + )}

)} {timezoneDropdown} diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index af3cd4e67c..dc93af6ab1 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -535,7 +535,7 @@ const BookingPage = ({ {showEventTypeDetails && (
- + {!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
diff --git a/apps/web/components/dialog/ChargeCardDialog.tsx b/apps/web/components/dialog/ChargeCardDialog.tsx new file mode 100644 index 0000000000..331b887ab8 --- /dev/null +++ b/apps/web/components/dialog/ChargeCardDialog.tsx @@ -0,0 +1,100 @@ +import { Trans } from "next-i18next"; +import { useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { IntlProvider, FormattedNumber } from "react-intl"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + showToast, +} from "@calcom/ui"; +import { FiCreditCard, FiAlertTriangle } from "@calcom/ui/components/icon"; + +interface IRescheduleDialog { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + bookingId: number; + paymentAmount: number; + paymentCurrency: string; +} + +export const ChargeCardDialog = (props: IRescheduleDialog) => { + const { t } = useLocale(); + const utils = trpc.useContext(); + const { isOpenDialog, setIsOpenDialog, bookingId } = props; + const [chargeError, setChargeError] = useState(false); + const chargeCardMutation = trpc.viewer.payments.chargeCard.useMutation({ + onSuccess: () => { + utils.viewer.bookings.invalidate(); + setIsOpenDialog(false); + showToast("Charge successful", "success"); + }, + onError: () => { + setChargeError(true); + }, + }); + + return ( + + +
+
+ +
+
+ + +

+ You are about to charge the attendee{" "} + + + + . Are you sure you want to continue? +

+
+ + {chargeError && ( +
+
+ )} + + + + + +
+
+
+
+ ); +}; diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index f9ec5cafe8..d94780d658 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -16,7 +16,7 @@ import { APP_NAME, CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { Prisma } from "@calcom/prisma/client"; import { trpc } from "@calcom/trpc/react"; -import { Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip } from "@calcom/ui"; +import { Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip, Alert } from "@calcom/ui"; import { FiEdit, FiCopy } from "@calcom/ui/components/icon"; import RequiresConfirmationController from "./RequiresConfirmationController"; @@ -58,6 +58,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick { !hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id)); @@ -262,54 +263,58 @@ export const EventAdvancedTab = ({ eventType, team }: Pick ( - { - // Enabling seats will disable guests and requiring confirmation until fully supported - if (e) { - toggleGuests(false); - formMethods.setValue("requiresConfirmation", false); - setRequiresConfirmation(false); - formMethods.setValue("seatsPerTimeSlot", 2); - } else { - formMethods.setValue("seatsPerTimeSlot", null); - toggleGuests(true); - } - onChange(e); - }}> - ( -
- {t("seats")}} - onChange={(e) => { - onChange(Math.abs(Number(e.target.value))); - }} - /> -
- formMethods.setValue("seatsShowAttendees", e.target.checked)} - defaultChecked={!!eventType.seatsShowAttendees} + <> + { + // Enabling seats will disable guests and requiring confirmation until fully supported + if (e) { + toggleGuests(false); + formMethods.setValue("requiresConfirmation", false); + setRequiresConfirmation(false); + formMethods.setValue("seatsPerTimeSlot", 2); + } else { + formMethods.setValue("seatsPerTimeSlot", null); + toggleGuests(true); + } + onChange(e); + }}> + ( +
+ {t("seats")}} + onChange={(e) => { + onChange(Math.abs(Number(e.target.value))); + }} /> +
+ formMethods.setValue("seatsShowAttendees", e.target.checked)} + defaultChecked={!!eventType.seatsShowAttendees} + /> +
-
- )} - /> - + )} + /> + + {noShowFeeEnabled && } + )} /> diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 0067865610..d01c2a3c44 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -126,41 +126,6 @@ const EventTypePage = (props: EventTypeSetupProps) => { const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props; const [animationParentRef] = useAutoAnimate(); - const updateMutation = trpc.viewer.eventTypes.update.useMutation({ - onSuccess: async () => { - showToast( - t("event_type_updated_successfully", { - eventTypeTitle: eventType.title, - }), - "success" - ); - }, - async onSettled() { - await utils.viewer.eventTypes.get.invalidate(); - }, - onError: (err) => { - let message = ""; - if (err instanceof HttpError) { - const message = `${err.statusCode}: ${err.message}`; - showToast(message, "error"); - } - - if (err.data?.code === "UNAUTHORIZED") { - message = `${err.data.code}: You are not able to update this event`; - } - - if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") { - message = `${err.data.code}: ${err.message}`; - } - - if (message) { - showToast(message, "error"); - } else { - showToast(err.message, "error"); - } - }, - }); - const [periodDates] = useState<{ startDate: Date; endDate: Date }>({ startDate: new Date(eventType.periodStartDate || Date.now()), endDate: new Date(eventType.periodEndDate || Date.now()), @@ -235,6 +200,48 @@ const EventTypePage = (props: EventTypeSetupProps) => { ), }); + const updateMutation = trpc.viewer.eventTypes.update.useMutation({ + onSuccess: async () => { + showToast( + t("event_type_updated_successfully", { + eventTypeTitle: eventType.title, + }), + "success" + ); + }, + async onSettled() { + await utils.viewer.eventTypes.get.invalidate(); + }, + onError: (err) => { + let message = ""; + if (err instanceof HttpError) { + const message = `${err.statusCode}: ${err.message}`; + showToast(message, "error"); + } + + if (err.data?.code === "UNAUTHORIZED") { + message = `${err.data.code}: You are not able to update this event`; + } + + if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") { + message = `${err.data.code}: ${err.message}`; + } + + if (message) { + showToast(message, "error"); + } else { + showToast(err.message, "error"); + } + + console.log(formMethods.formState.isSubmitting); + + // formMethods.setFormState((prevState) => ({ + // ...prevState, + // isSubmitting: false, + // })); + }, + }); + useEffect(() => { if (!formMethods.formState.isDirty) { //TODO: What's the best way to sync the form with backend @@ -337,6 +344,10 @@ const EventTypePage = (props: EventTypeSetupProps) => { } } + if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) { + throw new Error(t("seats_and_no_show_fee_error")); + } + updateMutation.mutate({ ...input, locations, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 001264cda2..7598e263a5 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1693,7 +1693,29 @@ "redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.", "event_trends": "Event Trends", "clear_filters": "Clear Filters", + "hold": "Hold", + "on_booking_option": "Collect payment on booking", + "hold_option": "Charge no-show fee", + "card_held": "Card held", + "charge_card": "Charge card", + "card_charged": "Card charged", + "no_show_fee_amount": "{{amount, currency}} no-show fee", + "no_show_fee": "No-show Fee", + "submit_card": "Submit card", + "submit_payment_information": "Submit payment information", + "meeting_awaiting_payment_method": "Your meeting is awaiting a payment method", + "no_show_fee_charged_email_subject": "No-show fee of {{amount, currency}} charged for {{title}} at {{date}}", + "no_show_fee_charged_text_body": "No-show fee was charged", + "no_show_fee_charged_subtitle": "No-show fee of {{amount, currency}} was charged for the following event", + "error_charging_card": "Something went wrong charging the no-show fee. Please try again later.", + "collect_no_show_fee": "Collect no-show fee", + "no_show_fee_charged": "No-show fee charged", "insights": "Insights", "insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.", + "acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.", + "card_details": "Card details", + "seats_and_no_show_fee_error": "Currently cannot enable seats and charge a no-show fee", + "complete_your_booking": "Complete your booking", + "complete_your_booking_subject": "Complete your booking: {{title}} on {{date}}", "email_invite_team": "{{email}} has been invited" } diff --git a/packages/app-store/_utils/getEventTypeAppData.ts b/packages/app-store/_utils/getEventTypeAppData.ts index 0b1d757b9a..e279539186 100644 --- a/packages/app-store/_utils/getEventTypeAppData.ts +++ b/packages/app-store/_utils/getEventTypeAppData.ts @@ -28,6 +28,7 @@ export const getEventTypeAppData = ( price: eventType.price, // Currency default is "usd" in DB.So, it would also be available always currency: eventType.currency, + paymentOption: "ON_BOOKING", }, rainbow: { enabled: !!(eventType.metadata?.smartContractAddress && eventType.metadata?.blockchainId), diff --git a/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx b/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx index 58ec0142bc..494f3ce196 100644 --- a/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx @@ -7,18 +7,24 @@ import AppCard from "@calcom/app-store/_components/AppCard"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Alert, TextField } from "@calcom/ui"; +import { Alert, TextField, Select } from "@calcom/ui"; +import { paymentOptions } from "../lib/constants"; import type { appDataSchema } from "../zod"; +type Option = { value: string; label: string }; + const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { asPath } = useRouter(); const [getAppData, setAppData] = useAppContextWithSchema(); const price = getAppData("price"); const currency = getAppData("currency"); + const paymentOption = getAppData("paymentOption"); + const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value); const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); const { t } = useLocale(); const recurringEventDefined = eventType.recurringEvent?.count !== undefined; + const seatsEnabled = !!eventType.seatsPerTimeSlot; const getCurrencySymbol = (locale: string, currency: string) => (0) .toLocaleString(locale, { @@ -54,22 +60,43 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ ) : ( requirePayment && ( -
- {currency ? getCurrencySymbol("en", currency) : ""}} - step="0.01" - min="0.5" - type="number" - required - className="border-default block w-full rounded-sm pl-2 pr-12 text-sm" - placeholder="Price" - onChange={(e) => { - setAppData("price", Number(e.target.value) * 100); - }} - value={price > 0 ? price / 100 : undefined} - /> -
+ <> +
+ {currency ? getCurrencySymbol("en", currency) : ""}} + addOnClassname="h-[38px]" + step="0.01" + min="0.5" + type="number" + required + placeholder="Price" + onChange={(e) => { + setAppData("price", Number(e.target.value) * 100); + }} + value={price > 0 ? price / 100 : undefined} + /> + + defaultValue={ + paymentOptionSelectValue + ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } + : { ...paymentOptions[0], label: t(paymentOptions[0].label) } + } + options={paymentOptions.map((option) => { + return { ...option, label: t(option.label) || option.label }; + })} + onChange={(input) => { + if (input) setAppData("paymentOption", input.value); + }} + className="mb-1 h-[38px] w-full" + isDisabled={seatsEnabled} + /> +
+ {seatsEnabled && paymentOption === "HOLD" && ( + + )} + ) )} diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index 71ff45f332..a5098ad4b9 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -1,4 +1,4 @@ -import type { Booking, Payment, Prisma } from "@prisma/client"; +import type { Booking, Payment, Prisma, PaymentOption } from "@prisma/client"; import Stripe from "stripe"; import { v4 as uuidv4 } from "uuid"; import z from "zod"; @@ -9,8 +9,10 @@ import { getErrorFromUnknown } from "@calcom/lib/errors"; import prisma from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import { paymentOptionEnum } from "../zod"; import { createPaymentLink } from "./client"; -import type { StripePaymentData } from "./server"; +import { retrieveOrCreateStripeCustomerByEmail } from "./customer"; +import type { StripeSetupIntentData, StripePaymentData } from "./server"; const stripeCredentialKeysSchema = z.object({ stripe_user_id: z.string(), @@ -36,11 +38,19 @@ export class PaymentService implements IAbstractPaymentService { }); } + /* This method is for creating charges at the time of booking */ async create( payment: Pick, - bookingId: Booking["id"] + bookingId: Booking["id"], + bookerEmail: string, + paymentOption: PaymentOption ) { try { + // Ensure that the payment service can support the passed payment option + if (paymentOptionEnum.parse(paymentOption) !== "ON_BOOKING") { + throw new Error("Payment option is not compatible with create method"); + } + // Load stripe keys const stripeAppKeys = await prisma?.app.findFirst({ select: { @@ -57,10 +67,17 @@ export class PaymentService implements IAbstractPaymentService { ); const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed); + const customer = await retrieveOrCreateStripeCustomerByEmail( + bookerEmail, + this.credentials.stripe_user_id + ); + const params: Stripe.PaymentIntentCreateParams = { amount: payment.amount, currency: this.credentials.default_currency, payment_method_types: ["card"], + application_fee_amount: paymentFee, + customer: customer.id, }; const paymentIntent = await this.stripe.paymentIntents.create(params, { @@ -91,6 +108,7 @@ export class PaymentService implements IAbstractPaymentService { fee: paymentFee, refunded: false, success: false, + paymentOption: paymentOption || "ON_BOOKING", }, }); if (!paymentData) { @@ -103,6 +121,161 @@ export class PaymentService implements IAbstractPaymentService { } } + async collectCard( + payment: Pick, + bookingId: Booking["id"], + bookerEmail: string, + paymentOption: PaymentOption + ): Promise { + // Ensure that the payment service can support the passed payment option + if (paymentOptionEnum.parse(paymentOption) !== "HOLD") { + throw new Error("Payment option is not compatible with create method"); + } + + // Load stripe keys + const stripeAppKeys = await prisma?.app.findFirst({ + select: { + keys: true, + }, + where: { + slug: "stripe", + }, + }); + + // Parse keys with zod + const { client_id, payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse( + stripeAppKeys?.keys + ); + const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed); + + const customer = await retrieveOrCreateStripeCustomerByEmail( + bookerEmail, + this.credentials.stripe_user_id + ); + + const params = { + customer: customer.id, + payment_method_types: ["card"], + metadata: { + bookingId, + }, + }; + + const setupIntent = await this.stripe.setupIntents.create(params, { + stripeAccount: this.credentials.stripe_user_id, + }); + + const paymentData = await prisma?.payment.create({ + data: { + uid: uuidv4(), + app: { + connect: { + slug: "stripe", + }, + }, + booking: { + connect: { + id: bookingId, + }, + }, + amount: payment.amount, + currency: payment.currency, + externalId: setupIntent.id, + + data: Object.assign( + {}, + { + setupIntent, + stripe_publishable_key: this.credentials.stripe_publishable_key, + stripeAccount: this.credentials.stripe_user_id, + } + ) as unknown as Prisma.InputJsonValue, + fee: paymentFee, + refunded: false, + success: false, + paymentOption: paymentOption || "ON_BOOKING", + }, + }); + + return paymentData; + } + + async chargeCard(payment: Payment, bookingId: Booking["id"]): Promise { + try { + const stripeAppKeys = await prisma?.app.findFirst({ + select: { + keys: true, + }, + where: { + slug: "stripe", + }, + }); + + const paymentObject = payment.data as unknown as StripeSetupIntentData; + + const setupIntent = paymentObject.setupIntent; + + // Parse keys with zod + const { client_id, payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse( + stripeAppKeys?.keys + ); + + const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed); + + // Ensure that the stripe customer & payment method still exists + const customer = await this.stripe.customers.retrieve(setupIntent.customer as string, { + stripeAccount: this.credentials.stripe_user_id, + }); + const paymentMethod = await this.stripe.paymentMethods.retrieve(setupIntent.payment_method as string, { + stripeAccount: this.credentials.stripe_user_id, + }); + + if (!customer) { + throw new Error(`Stripe customer does not exist for setupIntent ${setupIntent.id}`); + } + + if (!paymentMethod) { + throw new Error(`Stripe paymentMethod does not exist for setupIntent ${setupIntent.id}`); + } + + const params = { + amount: payment.amount, + currency: payment.currency, + application_fee_amount: paymentFee, + customer: setupIntent.customer as string, + payment_method: setupIntent.payment_method as string, + off_session: true, + confirm: true, + }; + + const paymentIntent = await this.stripe.paymentIntents.create(params, { + stripeAccount: this.credentials.stripe_user_id, + }); + + const paymentData = await prisma.payment.update({ + where: { + id: payment.id, + }, + data: { + success: true, + data: { + ...paymentObject, + paymentIntent, + } as unknown as Prisma.InputJsonValue, + }, + }); + + if (!paymentData) { + throw new Error(); + } + + return paymentData; + } catch (error) { + console.error(error); + throw new Error("Payment could not be created"); + } + } + async update(): Promise { throw new Error("Method not implemented."); } @@ -165,6 +338,9 @@ export class PaymentService implements IAbstractPaymentService { email: booking.user?.email, date: booking.startTime.toISOString(), }), + paymentOption: paymentData.paymentOption || "ON_BOOKING", + amount: paymentData.amount, + currency: paymentData.currency, }, }); } diff --git a/packages/app-store/stripepayment/lib/constants.ts b/packages/app-store/stripepayment/lib/constants.ts index e36dd8c730..5499427359 100644 --- a/packages/app-store/stripepayment/lib/constants.ts +++ b/packages/app-store/stripepayment/lib/constants.ts @@ -1,3 +1,14 @@ export const PREMIUM_MONTHLY_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY || ""; export const PREMIUM_PLAN_PRODUCT_ID = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID || ""; export const STRIPE_TEAM_MONTHLY_PRICE_ID = process.env.NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID || ""; + +export const paymentOptions = [ + { + label: "on_booking_option", + value: "ON_BOOKING", + }, + { + label: "hold_option", + value: "HOLD", + }, +]; diff --git a/packages/app-store/stripepayment/lib/customer.ts b/packages/app-store/stripepayment/lib/customer.ts index 2231a5c7cd..dafb2fbaa7 100644 --- a/packages/app-store/stripepayment/lib/customer.ts +++ b/packages/app-store/stripepayment/lib/customer.ts @@ -82,3 +82,27 @@ export async function deleteStripeCustomer(user: UserType): Promise & { stripeAccount: string; }; +export type StripeSetupIntentData = { + setupIntent: Stripe.Response; + paymentIntent?: StripePaymentData; +}; + export const stripeOAuthTokenSchema = z.object({ access_token: z.string().optional(), scope: z.string().optional(), diff --git a/packages/app-store/stripepayment/zod.ts b/packages/app-store/stripepayment/zod.ts index 1fe4cfeb78..6906450275 100644 --- a/packages/app-store/stripepayment/zod.ts +++ b/packages/app-store/stripepayment/zod.ts @@ -1,11 +1,22 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; +import { paymentOptions } from "./lib/constants"; + +// Extract the payment options enum from paymentOptions +// https://stackoverflow.com/a/73825370 +type PaymentOption = (typeof paymentOptions)[number]["value"]; +const VALUES: [PaymentOption, ...PaymentOption[]] = [ + paymentOptions[0].value, + ...paymentOptions.slice(1).map((option) => option.value), +]; +export const paymentOptionEnum = z.enum(VALUES); export const appDataSchema = eventTypeAppCardZod.merge( z.object({ price: z.number(), currency: z.string(), + paymentOption: paymentOptionEnum.optional(), }) ); @@ -14,6 +25,6 @@ export const appKeysSchema = z.object({ client_secret: z.string().startsWith("sk_").min(1), public_key: z.string().startsWith("pk_").min(1), webhook_secret: z.string().startsWith("whsec_").min(1), - payment_fee_fixed: z.number().int().min(0), + payment_fee_fixed: z.number().min(0), payment_fee_percentage: z.number().min(0), }); diff --git a/packages/app-store/types.d.ts b/packages/app-store/types.d.ts index 2a590a5433..624f3ca35b 100644 --- a/packages/app-store/types.d.ts +++ b/packages/app-store/types.d.ts @@ -30,7 +30,7 @@ export type EventTypeAppCardComponentProps = { // Limit what data should be accessible to apps eventType: Pick< z.infer, - "id" | "title" | "description" | "teamId" | "length" | "recurringEvent" + "id" | "title" | "description" | "teamId" | "length" | "recurringEvent" | "seatsPerTimeSlot" > & { URL: string; }; diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index b4e57e9dff..2e6563243f 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -21,6 +21,7 @@ import type { Feedback } from "./templates/feedback-email"; import FeedbackEmail from "./templates/feedback-email"; import type { PasswordReset } from "./templates/forgot-password-email"; import ForgotPasswordEmail from "./templates/forgot-password-email"; +import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email"; import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email"; import OrganizerCancelledEmail from "./templates/organizer-cancelled-email"; import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email"; @@ -285,3 +286,7 @@ export const sendDisabledAppEmail = async ({ }) => { await sendEmail(() => new DisabledAppEmail(email, appName, appType, t, title, eventTypeId)); }; + +export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarEvent) => { + await sendEmail(() => new NoShowFeeChargedEmail(evt, attendee)); +}; diff --git a/packages/emails/src/components/BaseEmailHtml.tsx b/packages/emails/src/components/BaseEmailHtml.tsx index a5c79dc2fb..2f14915782 100644 --- a/packages/emails/src/components/BaseEmailHtml.tsx +++ b/packages/emails/src/components/BaseEmailHtml.tsx @@ -4,7 +4,8 @@ import EmailBodyLogo from "./EmailBodyLogo"; import EmailHead from "./EmailHead"; import EmailScheduledBodyHeaderContent from "./EmailScheduledBodyHeaderContent"; import EmailSchedulingBodyDivider from "./EmailSchedulingBodyDivider"; -import EmailSchedulingBodyHeader, { BodyHeadType } from "./EmailSchedulingBodyHeader"; +import type { BodyHeadType } from "./EmailSchedulingBodyHeader"; +import EmailSchedulingBodyHeader from "./EmailSchedulingBodyHeader"; import RawHtml from "./RawHtml"; import Row from "./Row"; @@ -20,7 +21,7 @@ export const BaseEmailHtml = (props: { callToAction?: React.ReactNode; subject: string; title?: string; - subtitle?: React.ReactNode; + subtitle?: React.ReactNode | string; headerType?: BodyHeadType; }) => { return ( diff --git a/packages/emails/src/templates/AttendeeAwaitingPaymentEmail.tsx b/packages/emails/src/templates/AttendeeAwaitingPaymentEmail.tsx index a02408cd22..b9d506ef8c 100644 --- a/packages/emails/src/templates/AttendeeAwaitingPaymentEmail.tsx +++ b/packages/emails/src/templates/AttendeeAwaitingPaymentEmail.tsx @@ -13,12 +13,22 @@ function ManageLink(props: React.ComponentProps) ); } -export const AttendeeAwaitingPaymentEmail = (props: React.ComponentProps) => ( - } - {...props} - /> -); +export const AttendeeAwaitingPaymentEmail = (props: React.ComponentProps) => { + return props.calEvent.paymentInfo?.paymentOption === "HOLD" ? ( + } + {...props} + /> + ) : ( + } + {...props} + /> + ); +}; diff --git a/packages/emails/src/templates/BaseScheduledEmail.tsx b/packages/emails/src/templates/BaseScheduledEmail.tsx index 654c0378a5..94c55560d9 100644 --- a/packages/emails/src/templates/BaseScheduledEmail.tsx +++ b/packages/emails/src/templates/BaseScheduledEmail.tsx @@ -80,6 +80,16 @@ export const BaseScheduledEmail = ( {props.includeAppsStatus && } + {props.calEvent.paymentInfo?.amount && ( + + )} ); }; diff --git a/packages/emails/src/templates/NoShowFeeChargedEmail.tsx b/packages/emails/src/templates/NoShowFeeChargedEmail.tsx new file mode 100644 index 0000000000..9bc5d99323 --- /dev/null +++ b/packages/emails/src/templates/NoShowFeeChargedEmail.tsx @@ -0,0 +1,33 @@ +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import { BaseScheduledEmail } from "./BaseScheduledEmail"; + +export const NoShowFeeChargedEmail = ( + props: { + calEvent: CalendarEvent; + attendee: Person; + } & Partial> +) => { + const { calEvent } = props; + const t = props.attendee.language.translate; + + if (!calEvent.paymentInfo?.amount) throw new Error("No payment info"); + + return ( + + {t("no_show_fee_charged_subtitle", { + amount: calEvent.paymentInfo.amount / 100, + formatParams: { amount: { currency: calEvent.paymentInfo?.currency } }, + })} + + } + timeZone={props.attendee.timeZone} + {...props} + t={t} + /> + ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index d1d7d7bcb5..59dac4cc05 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -21,4 +21,5 @@ export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail"; export { TeamInviteEmail } from "./TeamInviteEmail"; export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail"; export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail"; +export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail"; export * from "@calcom/app-store/routing-forms/emails/components"; diff --git a/packages/emails/templates/attendee-awaiting-payment-email.ts b/packages/emails/templates/attendee-awaiting-payment-email.ts index df135ad52b..772b55c10b 100644 --- a/packages/emails/templates/attendee-awaiting-payment-email.ts +++ b/packages/emails/templates/attendee-awaiting-payment-email.ts @@ -7,7 +7,7 @@ export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail to: `${this.attendee.name} <${this.attendee.email}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, replyTo: this.calEvent.organizer.email, - subject: `${this.attendee.language.translate("awaiting_payment_subject", { + subject: `${this.attendee.language.translate("complete_your_booking_subject", { title: this.calEvent.title, date: this.getFormattedDate(), })}`, diff --git a/packages/emails/templates/no-show-fee-charged-email.ts b/packages/emails/templates/no-show-fee-charged-email.ts new file mode 100644 index 0000000000..500e4393eb --- /dev/null +++ b/packages/emails/templates/no-show-fee-charged-email.ts @@ -0,0 +1,24 @@ +import { renderEmail } from "../"; +import AttendeeScheduledEmail from "./attendee-scheduled-email"; + +export default class NoShowFeeChargedEmail extends AttendeeScheduledEmail { + protected getNodeMailerPayload(): Record { + if (!this.calEvent.paymentInfo?.amount) throw new Error("No payment into"); + return { + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `${this.attendee.language.translate("no_show_fee_charged_email_subject", { + title: this.calEvent.title, + date: this.getFormattedDate(), + amount: this.calEvent.paymentInfo.amount / 100, + formatParams: { amount: { currency: this.calEvent.paymentInfo?.currency } }, + })}`, + html: renderEmail("NoShowFeeChargedEmail", { + calEvent: this.calEvent, + attendee: this.attendee, + }), + text: this.getTextBody("no_show_fee_charged_text_body"), + }; + } +} diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 9fe941e75a..d529abeccc 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1412,7 +1412,8 @@ async function handler( evt, eventType, eventTypePaymentAppCredential as IEventTypePaymentCredentialType, - booking + booking, + bookerEmail ); return { @@ -1846,17 +1847,18 @@ async function handler( } } - if (!isConfirmedByDefault && noEmail !== true) { + const bookingRequiresPayment = + !Number.isNaN(paymentAppData.price) && + paymentAppData.price > 0 && + !originalRescheduledBooking?.paid && + !!booking; + + if (!isConfirmedByDefault && noEmail !== true && !bookingRequiresPayment) { await sendOrganizerRequestEmail({ ...evt, additionalNotes }); await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]); } - if ( - !Number.isNaN(paymentAppData.price) && - paymentAppData.price > 0 && - !originalRescheduledBooking?.paid && - !!booking - ) { + if (bookingRequiresPayment) { // Load credentials.app.categories const credentialPaymentAppCategories = await prisma.credential.findMany({ where: { @@ -1892,7 +1894,8 @@ async function handler( evt, eventType, eventTypePaymentAppCredential as IEventTypePaymentCredentialType, - booking + booking, + bookerEmail ); req.statusCode = 201; diff --git a/packages/features/calendars/weeklyview/components/Calendar.tsx b/packages/features/calendars/weeklyview/components/Calendar.tsx index aa8511d578..fa05568469 100644 --- a/packages/features/calendars/weeklyview/components/Calendar.tsx +++ b/packages/features/calendars/weeklyview/components/Calendar.tsx @@ -56,7 +56,7 @@ export function Calendar(props: CalendarComponentProps) { containerRef={container} /> */}
-
+
>; + let eventTypeRaw: EventTypeRaw | null = null; + if (booking.eventTypeId) { + eventTypeRaw = await getEventType(booking.eventTypeId); + } + + const { user } = booking; + + if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" }); + + const t = await getTranslation(user.locale ?? "en", "common"); + const attendeesListPromises = booking.attendees.map(async (attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + + const evt: CalendarEvent = { + type: booking.title, + title: booking.title, + description: booking.description || undefined, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + customInputs: isPrismaObjOrUndefined(booking.customInputs), + organizer: { + email: user.email, + name: user.name!, + timeZone: user.timeZone, + language: { translate: t, locale: user.locale ?? "en" }, + id: user.id, + }, + attendees: attendeesList, + uid: booking.uid, + destinationCalendar: booking.destinationCalendar || user.destinationCalendar, + recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent), + }; + + return { + booking, + user, + evt, + eventTypeRaw, + }; +} + async function handlePaymentSuccess(event: Stripe.Event) { const paymentIntent = event.data.object as Stripe.PaymentIntent; const payment = await prisma.payment.findFirst({ @@ -180,10 +266,61 @@ async function handlePaymentSuccess(event: Stripe.Event) { }); } +const handleSetupSuccess = async (event: Stripe.Event) => { + const setupIntent = event.data.object as Stripe.SetupIntent; + const payment = await prisma.payment.findFirst({ + where: { + externalId: setupIntent.id, + }, + }); + + if (!payment?.data || !payment?.id) throw new HttpCode({ statusCode: 204, message: "Payment not found" }); + + const { booking, user, evt, eventTypeRaw } = await getBooking(payment.bookingId); + + const bookingData: Prisma.BookingUpdateInput = { + paid: true, + }; + + if (!eventTypeRaw?.requiresConfirmation) { + const eventManager = new EventManager(user); + const scheduleResult = await eventManager.create(evt); + bookingData.references = { create: scheduleResult.referencesToCreate }; + bookingData.status = BookingStatus.ACCEPTED; + } + + await prisma.payment.update({ + where: { + id: payment.id, + }, + data: { + data: { + ...(payment.data as Prisma.JsonObject), + setupIntent: setupIntent as unknown as Prisma.JsonObject, + }, + booking: { + update: { + ...bookingData, + }, + }, + }, + }); + + // If the card information was already captured in the same customer. Delete the previous payment method + + if (!eventTypeRaw?.requiresConfirmation) { + await sendScheduledEmails({ ...evt }); + } else { + await sendOrganizerRequestEmail({ ...evt }); + await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]); + } +}; + type WebhookHandler = (event: Stripe.Event) => Promise; const webhookHandlers: Record = { "payment_intent.succeeded": handlePaymentSuccess, + "setup_intent.succeeded": handleSetupSuccess, }; /** diff --git a/packages/features/ee/payments/components/Payment.tsx b/packages/features/ee/payments/components/Payment.tsx index e525ee1117..c2cfb0248b 100644 --- a/packages/features/ee/payments/components/Payment.tsx +++ b/packages/features/ee/payments/components/Payment.tsx @@ -1,3 +1,4 @@ +import type { Payment } from "@prisma/client"; import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js"; import type { StripeCardElementChangeEvent, StripeElementLocale } from "@stripe/stripe-js"; import type stripejs from "@stripe/stripe-js"; @@ -5,10 +6,10 @@ import { useRouter } from "next/router"; import type { SyntheticEvent } from "react"; import { useEffect, useState } from "react"; -import type { StripePaymentData } from "@calcom/app-store/stripepayment/lib/server"; +import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server"; import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Button } from "@calcom/ui"; +import { Button, Checkbox } from "@calcom/ui"; import type { EventType } from ".prisma/client"; @@ -32,8 +33,8 @@ const CARD_OPTIONS: stripejs.StripeCardElementOptions = { } as const; type Props = { - payment: { - data: StripePaymentData; + payment: Omit & { + data: StripePaymentData | StripeSetupIntentData; }; eventType: { id: number; successRedirectUrl: EventType["successRedirectUrl"] }; user: { username: string | null }; @@ -54,6 +55,8 @@ export default function PaymentComponent(props: Props) { const [state, setState] = useState({ status: "idle" }); const stripe = useStripe(); const elements = useElements(); + const paymentOption = props.payment.paymentOption; + const [holdAcknowledged, setHoldAcknowledged] = useState(paymentOption === "HOLD" ? false : true); useEffect(() => { elements?.update({ locale: i18n.language as StripeElementLocale }); @@ -74,22 +77,33 @@ export default function PaymentComponent(props: Props) { const card = elements.getElement(CardElement); if (!card) return; setState({ status: "processing" }); - const payload = await stripe.confirmCardPayment(props.payment.data.client_secret!, { - payment_method: { - card, - }, - }); - if (payload.error) { + + let payload; + const params: { [k: string]: any } = { + uid: props.bookingUid, + email: router.query.email, + }; + if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) { + const setupIntentData = props.payment.data as unknown as StripeSetupIntentData; + payload = await stripe.confirmCardSetup(setupIntentData.setupIntent.client_secret!, { + payment_method: { + card, + }, + }); + } else if (paymentOption === "ON_BOOKING") { + const paymentData = props.payment.data as unknown as StripePaymentData; + payload = await stripe.confirmCardPayment(paymentData.client_secret!, { + payment_method: { + card, + }, + }); + } + if (payload?.error) { setState({ status: "error", error: new Error(`Payment failed: ${payload.error.message}`), }); } else { - const params: { [k: string]: any } = { - uid: props.bookingUid, - email: router.query.email, - }; - if (props.location) { if (props.location.includes("integration")) { params.location = t("web_conferencing_details_to_follow"); @@ -107,22 +121,53 @@ export default function PaymentComponent(props: Props) { } }; return ( -
- -
+ +

{t("card_details")}

+ + {paymentOption === "HOLD" && ( +
+ setHoldAcknowledged(e.target.checked)} + descriptionClassName="text-blue-900 font-semibold" + /> +
+ )} +
+
{state.status === "error" && ( -
+
{state.error.message}
)} diff --git a/packages/features/ee/payments/components/PaymentPage.tsx b/packages/features/ee/payments/components/PaymentPage.tsx index 12eca7bd19..10a249a238 100644 --- a/packages/features/ee/payments/components/PaymentPage.tsx +++ b/packages/features/ee/payments/components/PaymentPage.tsx @@ -73,7 +73,7 @@ const PaymentPage: FC = (props) => {
= (props) => {
-
{t("what")}
@@ -108,8 +106,10 @@ const PaymentPage: FC = (props) => {
)} -
{t("price")}
-
+
+ {props.payment.paymentOption === "HOLD" ? t("no_show_fee") : t("price")} +
+
refunded: true, bookingId: true, appId: true, + amount: true, + currency: true, + paymentOption: true, booking: { select: { id: true, @@ -87,7 +90,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const { data, booking: _booking, ...restPayment } = rawPayment; const payment = { ...restPayment, - data: data as unknown as StripePaymentData, + data: data as unknown as StripePaymentData | StripeSetupIntentData, }; if (!_booking) return { notFound: true }; diff --git a/packages/features/ee/sso/page/teams-sso-view.tsx b/packages/features/ee/sso/page/teams-sso-view.tsx index 0b8bb1017d..fb74262dde 100644 --- a/packages/features/ee/sso/page/teams-sso-view.tsx +++ b/packages/features/ee/sso/page/teams-sso-view.tsx @@ -41,7 +41,7 @@ const SAMLSSO = () => { } return ( -
+
diff --git a/packages/features/ee/sso/page/user-sso-view.tsx b/packages/features/ee/sso/page/user-sso-view.tsx index 441ce9efef..2efe499355 100644 --- a/packages/features/ee/sso/page/user-sso-view.tsx +++ b/packages/features/ee/sso/page/user-sso-view.tsx @@ -19,7 +19,7 @@ const SAMLSSO = () => { }, []); return ( -
+
diff --git a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx index 7fb190f52b..c137bb3a60 100644 --- a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx +++ b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx @@ -98,7 +98,7 @@ const WorkflowListItem = (props: ItemProps) => { }); return ( -
+
{getActionIcon( workflow.steps, diff --git a/packages/features/schedules/components/DateOverrideList.tsx b/packages/features/schedules/components/DateOverrideList.tsx index f033b822be..02d25a7454 100644 --- a/packages/features/schedules/components/DateOverrideList.tsx +++ b/packages/features/schedules/components/DateOverrideList.tsx @@ -53,7 +53,7 @@ const DateOverrideList = ({ }; return ( -
    +
      {items.sort(sortByDate).map((item, index) => (
    • diff --git a/packages/features/schedules/components/Schedule.tsx b/packages/features/schedules/components/Schedule.tsx index d4d1ce9e6a..3a550f4339 100644 --- a/packages/features/schedules/components/Schedule.tsx +++ b/packages/features/schedules/components/Schedule.tsx @@ -52,7 +52,7 @@ const ScheduleDay = ({ const watchDayRange = watch(name); return ( -
      +
      {/* Label & switch container */}
      @@ -179,7 +179,7 @@ export const DayRanges = ({
      {fields.map((field, index: number) => ( -
      +
      } /> {index === 0 && (
      )}
      -
      +
      {React.createElement( descriptionAsLabel ? "label" : "div", { @@ -45,7 +46,7 @@ const CheckboxField = forwardRef( ), }, <> -
      +
      ( )} />
      - {description} + {description} )} {/* {informationIconText && } */}