diff --git a/apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx b/apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx index f2000cd208..fa00d6efc7 100644 --- a/apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx +++ b/apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx @@ -141,7 +141,7 @@ const PaymentPage: FC = (props) => {
diff --git a/packages/app-store/_utils/payments/currencyConversions.ts b/packages/app-store/_utils/payments/currencyConversions.ts index 408bc50629..da5c5e539a 100644 --- a/packages/app-store/_utils/payments/currencyConversions.ts +++ b/packages/app-store/_utils/payments/currencyConversions.ts @@ -32,3 +32,27 @@ export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, cur } return amount / 100; }; + +export const getCurrencySymbol = (currencyCode: string): string => { + try { + const formatter = Intl.NumberFormat("en", { style: "currency", currency: currencyCode }); + // formatToParts(1) breaks down the formatted number (1) into its constituent parts + // like currency symbol, decimal separator, etc. We use 1 as it's a simple number + // that will show the currency symbol clearly. + // For example, since we are formatting the number 1 with USD currency, formatToParts(1) would return an array like: + // [ + // { type: "currency", value: "$" }, + // { type: "integer", value: "1" }, + // { type: "decimal", value: "." }, + // { type: "fraction", value: "00" } + // ] + const parts = formatter.formatToParts(1); + const currencyPart = parts.find((part) => part.type === "currency"); + return currencyPart?.value || "$"; + } catch { + // Ideally we would not reach here, but if for some reason we reach here, we return + // $ as default currency + console.warn(`Failed to get currency symbol for ${currencyCode}, falling back to $`); + return "$"; + } +}; diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index c0c9699c8a..137c4abaa1 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -89,6 +89,11 @@ export const BookEventForm = ({ return eventType?.price > 0 && !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0; }, [eventType]); + const paymentCurrency = useMemo(() => { + if (!eventType) return "USD"; + return getPaymentAppData(eventType)?.currency || "USD"; + }, [eventType]); + if (eventQuery.isError) return ; if (eventQuery.isPending || !eventQuery.data) return ; if (!timeslot) @@ -129,6 +134,8 @@ export const BookEventForm = ({ locations={eventType.locations} rescheduleUid={rescheduleUid || undefined} bookingData={bookingData} + isPaidEvent={isPaidEvent} + paymentCurrency={paymentCurrency} /> {errors.hasFormErrors || errors.hasDataErrors ? (
diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx index 313bee1287..6197e948af 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx @@ -6,30 +6,81 @@ import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect"; import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField"; +import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes"; +import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import type { RouterOutputs } from "@calcom/trpc/react"; import { SystemField } from "../../../lib/SystemField"; +type Fields = NonNullable["bookingFields"]; export const BookingFields = ({ fields, locations, rescheduleUid, isDynamicGroupBooking, bookingData, + isPaidEvent, + paymentCurrency = "USD", }: { - fields: NonNullable["bookingFields"]; + fields: Fields; locations: LocationObject[]; rescheduleUid?: string; bookingData?: GetBookingType | null; isDynamicGroupBooking: boolean; + isPaidEvent?: boolean; + paymentCurrency?: string; }) => { - const { t } = useLocale(); + const { t, i18n } = useLocale(); const { watch, setValue } = useFormContext(); const locationResponse = watch("responses.location"); const currentView = rescheduleUid ? "reschedule" : ""; const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); + const getPriceFormattedLabel = (label: string, price: number) => + `${label} (${Intl.NumberFormat(i18n.language, { + style: "currency", + currency: paymentCurrency, + }).format(price)})`; + + const getFieldWithDirectPricing = (field: Fields[number]) => { + if (!fieldTypesConfigMap[field.type]?.supportsPricing || !field.label || !field.price) { + return field; + } + + const price = typeof field.price === "string" ? parseFloat(field.price) : field.price; + const label = getPriceFormattedLabel(field.label, price); + + return { + ...field, + label, + ...(fieldsThatSupportLabelAsSafeHtml.includes(field.type) && field.labelAsSafeHtml + ? { labelAsSafeHtml: markdownToSafeHTML(label) } + : { labelAsSafeHtml: undefined }), + }; + }; + + const getFieldWithOptionLevelPrices = (field: Fields[number]) => { + if (!fieldTypesConfigMap[field.type]?.optionsSupportPricing || !field.options) return field; + + return { + ...field, + options: field.options.map((opt) => { + const option = opt as { value: string; label: string; price?: number }; + const optionPrice = option.price; + + // Only add price to label if there's a price + if (!optionPrice) return option; + + return { + ...option, + label: getPriceFormattedLabel(option.label, optionPrice), + }; + }), + }; + }; + return ( // TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view // The logic here intends to make modifications to booking fields based on the way we want to specifically show Booking Form @@ -134,8 +185,28 @@ export const BookingFields = ({ }); } + // Add price display for custom inputs with prices + let fieldWithPrice = field; + + if (isPaidEvent) { + // Handle number fields and boolean (single checkbox) fields + if (fieldTypesConfigMap[field.type]?.supportsPricing) { + fieldWithPrice = getFieldWithDirectPricing(field); + } + + // Handle fields with option-level prices (select, multiselect, and checkbox group) + if (fieldTypesConfigMap[field.type]?.optionsSupportPricing) { + fieldWithPrice = getFieldWithOptionLevelPrices(fieldWithPrice); + } + } + return ( -
diff --git a/packages/features/bookings/lib/getBookingFields.ts b/packages/features/bookings/lib/getBookingFields.ts index d516ddb4a1..150d5cd4d2 100644 --- a/packages/features/bookings/lib/getBookingFields.ts +++ b/packages/features/bookings/lib/getBookingFields.ts @@ -18,7 +18,7 @@ import { EventTypeMetaDataSchema, } from "@calcom/prisma/zod-utils"; -type Fields = z.infer; +export type Fields = z.infer; if (typeof window !== "undefined" && !process.env.INTEGRATION_TEST_MODE) { // This file imports some costly dependencies, so we want to make sure it's not imported on the client side. diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index f8aadd9f46..dab2adbc6d 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -2102,6 +2102,8 @@ async function handler( bookerEmail, bookerPhoneNumber, isDryRun, + bookingFields: eventType.bookingFields, + locale: language, }); const subscriberOptionsPaymentInitiated: GetSubscriberOptions = { userId: triggerForUser ? organizerUser.id : null, diff --git a/packages/features/bookings/lib/handlePayment.test.ts b/packages/features/bookings/lib/handlePayment.test.ts new file mode 100644 index 0000000000..359cf8c87c --- /dev/null +++ b/packages/features/bookings/lib/handlePayment.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { handlePayment } from "./handlePayment"; + +vi.mock("@calcom/app-store/zod-utils", () => ({ + eventTypeMetaDataSchemaWithTypedApps: { + parse: vi.fn((data) => data), + }, +})); + +vi.mock("@calcom/app-store/payment.services.generated", () => ({ + PaymentServiceMap: { + stripepayment: Promise.resolve({ + PaymentService: class MockPaymentService { + async create(paymentData: { amount: number; currency: string }) { + return { + ...paymentData, + id: "mock-payment-id", + externalId: "mock-external-id", + success: true, + }; + } + + async collectCard(paymentData: { amount: number; currency: string }) { + return { + ...paymentData, + id: "mock-payment-id", + externalId: "mock-external-id", + success: true, + }; + } + + async afterPayment() { + return true; + } + }, + }), + }, +})); + +describe("handlePayment", () => { + const mockBooking = { + id: 123, + userId: 456, + startTime: { toISOString: () => "2025-03-12T10:00:00Z" }, + uid: "test-uid", + user: { + email: "user@example.com", + name: "Test User", + timeZone: "UTC", + username: "testuser", + }, + }; + + const mockEventType = { + title: "Test Event", + metadata: { + apps: { + stripe: { + enabled: true, + price: 1000, + currency: "USD", + paymentOption: "ON_BOOKING", + }, + }, + }, + }; + + const mockPaymentCredentials = { + key: {}, + appId: "stripe" as const, + app: { + dirName: "stripepayment", + categories: ["payment"], + }, + }; + + const mockEvent = { + type: "testEvent", + title: "Test Event", + startTime: "2025-03-12T10:00:00Z", + endTime: "2025-03-12T11:00:00Z", + organizer: { + name: "Test Organizer", + email: "organizer@example.com", + timeZone: "UTC", + language: { locale: "en" }, + }, + attendees: [], + responses: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should handle payment on dry run", async () => { + const result = await handlePayment({ + evt: mockEvent, + selectedEventType: mockEventType, + paymentAppCredentials: mockPaymentCredentials, + booking: mockBooking, + bookerName: "John Doe", + bookerEmail: "john@example.com", + bookingFields: [], + isDryRun: true, + }); + + expect(result).toBeNull(); + }); + + it("should calculate total amount with no addons", async () => { + const result = await handlePayment({ + evt: mockEvent, + selectedEventType: mockEventType, + paymentAppCredentials: mockPaymentCredentials, + booking: mockBooking, + bookerName: "John Doe", + bookerEmail: "john@example.com", + bookingFields: [], + }); + + expect(result?.amount).toBe(1000); + expect(result?.currency).toBe("USD"); + }); + + it("should calculate total amount with number field addon", async () => { + const mockEventWithResponse = { + ...mockEvent, + responses: { + quantity: { value: "2" }, + }, + }; + + const bookingFields = [ + { + name: "quantity", + type: "number" as const, + price: 5, + required: false, + }, + ]; + + const result = await handlePayment({ + evt: mockEventWithResponse, + selectedEventType: mockEventType, + paymentAppCredentials: mockPaymentCredentials, + booking: mockBooking, + bookerName: "John Doe", + bookerEmail: "john@example.com", + bookingFields, + }); + + expect(result?.amount).toBe(2000); // Base 1000 cents + (2 * $5 * 100 cents) + }); + + it("should calculate total amount with boolean field addon", async () => { + const mockEventWithResponse = { + ...mockEvent, + responses: { + extraService: { value: true }, + }, + }; + + const bookingFields = [ + { + name: "extraService", + type: "boolean" as const, + price: 15, + required: false, + }, + ]; + + const result = await handlePayment({ + evt: mockEventWithResponse, + selectedEventType: mockEventType, + paymentAppCredentials: mockPaymentCredentials, + booking: mockBooking, + bookerName: "John Doe", + bookerEmail: "john@example.com", + bookingFields, + }); + + expect(result?.amount).toBe(2500); // Base 1000 cents + ($15 * 100 cents) + }); + + it("should calculate total amount with select field addon", async () => { + const mockEventWithResponse = { + ...mockEvent, + responses: { + package: { value: "premium" }, + }, + }; + + const bookingFields = [ + { + name: "package", + type: "select" as const, + required: false, + options: [ + { value: "basic", label: "Basic Package", price: 10 }, + { value: "premium", label: "Premium Package", price: 20 }, + ], + }, + ]; + + const result = await handlePayment({ + evt: mockEventWithResponse, + selectedEventType: mockEventType, + paymentAppCredentials: mockPaymentCredentials, + booking: mockBooking, + bookerName: "John Doe", + bookerEmail: "john@example.com", + bookingFields, + }); + + expect(result?.amount).toBe(3000); // Base 1000 cents + ($20 * 100 cents) + }); + + it("should calculate total amount with multiple addons", async () => { + const mockEventWithResponse = { + ...mockEvent, + responses: { + quantity: { value: "2" }, + extraService: { value: true }, + package: { value: "premium" }, + }, + }; + + const bookingFields = [ + { + name: "quantity", + type: "number" as const, + price: 5, + required: false, + }, + { + name: "extraService", + type: "boolean" as const, + price: 15, + required: false, + }, + { + name: "package", + type: "select" as const, + required: false, + options: [ + { value: "basic", label: "Basic Package", price: 10 }, + { value: "premium", label: "Premium Package", price: 20 }, + ], + }, + ]; + + const result = await handlePayment({ + evt: mockEventWithResponse, + selectedEventType: mockEventType, + paymentAppCredentials: mockPaymentCredentials, + booking: mockBooking, + bookerName: "John Doe", + bookerEmail: "john@example.com", + bookingFields, + }); + + expect(result?.amount).toBe(5500); // Base 1000 cents + (2 * $5 * 100 cents) + ($15 * 100 cents) + ($20 * 100 cents) + }); + + it("should calculate total amount with radio field addon using locale", async () => { + const mockEventWithResponse = { + ...mockEvent, + responses: { + mealChoice: { value: "premium ($20.00)" }, // Formatted with price + }, + }; + + const bookingFields = [ + { + name: "mealChoice", + type: "radio" as const, + required: false, + options: [ + { value: "basic", label: "Basic Meal", price: 10 }, + { value: "premium", label: "Premium Meal", price: 20 }, + ], + }, + ]; + + const result = await handlePayment({ + evt: mockEventWithResponse, + selectedEventType: mockEventType, + paymentAppCredentials: mockPaymentCredentials, + booking: mockBooking, + bookerName: "John Doe", + bookerEmail: "john@example.com", + bookingFields, + locale: "en", + }); + + expect(result?.amount).toBe(3000); // Base 1000 cents + ($20 * 100 cents) + }); +}); diff --git a/packages/features/bookings/lib/handlePayment.ts b/packages/features/bookings/lib/handlePayment.ts index 26a397bcd7..373e490853 100644 --- a/packages/features/bookings/lib/handlePayment.ts +++ b/packages/features/bookings/lib/handlePayment.ts @@ -2,6 +2,9 @@ import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated" import type { EventTypeAppsList } from "@calcom/app-store/utils"; import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; import type { AppCategories, Prisma, EventType } from "@calcom/prisma/client"; +import { convertToSmallestCurrencyUnit } from "@calcom/app-store/_utils/payments/currencyConversions"; +import type { Fields } from "@calcom/features/bookings/lib/getBookingFields"; +import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; @@ -17,6 +20,8 @@ const handlePayment = async ({ bookerEmail, bookerPhoneNumber, isDryRun = false, + bookingFields, + locale = "en", }: { evt: CalendarEvent; selectedEventType: Pick; @@ -39,6 +44,8 @@ const handlePayment = async ({ bookerEmail: string; bookerPhoneNumber?: string | null; isDryRun?: boolean; + bookingFields?: Fields; + locale?: string; }) => { if (isDryRun) return null; const key = paymentAppCredentials?.app?.dirName; @@ -60,14 +67,104 @@ const handlePayment = async ({ const apps = eventTypeMetaDataSchemaWithTypedApps.parse(selectedEventType?.metadata)?.apps; const paymentOption = apps?.[paymentAppCredentials.appId].paymentOption || "ON_BOOKING"; + const paymentCurrency = apps?.[paymentAppCredentials.appId].currency; + // Ensure we have a valid currency - fallback to USD if undefined + const currency = paymentCurrency || "USD"; + + let totalAmount = apps?.[paymentAppCredentials.appId].price || 0; + + if ((bookingFields || [])?.length > 0) { + let addonsPrice = 0; + + // Process each booking field + (bookingFields || []).forEach((field) => { + // Skip fields that don't support pricing + if ( + !fieldTypesConfigMap[field.type]?.supportsPricing && + !fieldTypesConfigMap[field.type]?.optionsSupportPricing + ) { + return; + } + + // Type assertion for price-related fields + const typedInput = field as { price?: number; options?: { value: string; price?: number }[] }; + const response = evt.responses?.[field.name]; + + // Skip if no response for this field + if (!response) return; + + switch (field.type) { + case "number": { + // Multiply the numeric value by the field's price + const parsedValue = Number((response.value ?? "").toString().trim()); + const safeValue = Number.isFinite(parsedValue) ? parsedValue : 0; + addonsPrice += safeValue * (typedInput.price || 0); + break; + } + + case "boolean": { + // Add price if boolean field is true + if (response.value) { + addonsPrice += typedInput.price || 0; + } + break; + } + + case "select": + case "radio": { + // For select and radio, find the selected option and add its price + const selectedValue = response.value; + let selectedOption; + + if (field.type === "radio") { + // For radio, the value coming is the label itself (formatted with price) + selectedOption = typedInput.options?.find((opt) => { + const formattedValue = `${opt.value} (${Intl.NumberFormat(locale, { + style: "currency", + currency: currency, + }).format(opt.price || 0)})`; + return formattedValue === selectedValue; + }); + } else { + // For select, match by direct value + selectedOption = typedInput.options?.find((opt) => opt.value === selectedValue); + } + + addonsPrice += selectedOption?.price || 0; + break; + } + + case "checkbox": + case "multiselect": { + // For checkbox and multiselect, add prices of all selected options + const responseValue = response.value; + const selectedValues = Array.isArray(responseValue) + ? responseValue + : responseValue + ? [responseValue] + : []; + + selectedValues.forEach((value) => { + const option = typedInput.options?.find((opt) => opt.value === value); + addonsPrice += option?.price || 0; + }); + break; + } + } + }); + + totalAmount += convertToSmallestCurrencyUnit(addonsPrice, currency); + } + + const paymentPriceAndCurrency = { + amount: totalAmount, + currency: currency, + }; let paymentData; if (paymentOption === "HOLD") { paymentData = await paymentInstance.collectCard( - { - amount: apps?.[paymentAppCredentials.appId].price, - currency: apps?.[paymentAppCredentials.appId].currency, - }, + paymentPriceAndCurrency, booking.id, paymentOption, bookerEmail, @@ -75,10 +172,7 @@ const handlePayment = async ({ ); } else { paymentData = await paymentInstance.create( - { - amount: apps?.[paymentAppCredentials.appId].price, - currency: apps?.[paymentAppCredentials.appId].currency, - }, + paymentPriceAndCurrency, booking.id, booking.userId, booking.user?.username ?? null, diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index 3412f77c8c..5c60372edc 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -42,6 +42,7 @@ import { } from "@calcom/lib/constants"; import { generateHashedLink } from "@calcom/lib/generateHashedLink"; import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; +import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData"; import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { Prisma } from "@calcom/prisma/client"; @@ -569,6 +570,21 @@ export const EventAdvancedTab = ({ userEmail = removePlatformClientIdFromEmail(userEmail, platformContext.clientId); } + const metadata = formMethods.watch("metadata"); + const paymentAppData = useMemo(() => { + const _eventType = { + price: 0, + currency: "", + metadata, + }; + return getPaymentAppData(_eventType); + }, [metadata]); + + const isPaidEvent = useMemo( + () => !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0, + [paymentAppData] + ); + const TimezoneSelect = useMemo(() => { return isPlatform ? PlatformTimzoneSelect : WebTimezoneSelect; }, [isPlatform]); @@ -628,6 +644,8 @@ export const EventAdvancedTab = ({ // Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user return field.name === "location" ? true : field.required; }} + showPriceField={isPaidEvent} + paymentCurrency={paymentAppData?.currency || "usd"} /> diff --git a/packages/features/form-builder/FormBuilder.test.tsx b/packages/features/form-builder/FormBuilder.test.tsx index 7c1375a8fa..c47efa97a5 100644 --- a/packages/features/form-builder/FormBuilder.test.tsx +++ b/packages/features/form-builder/FormBuilder.test.tsx @@ -167,4 +167,108 @@ describe("FormBuilder", () => { expect(pageObject.dialog.isFieldShowingAsRequired({ dialog })).toBe(true); }); }); + + describe("Addon Fields Tests", () => { + beforeEach(() => { + renderComponent({ + formBuilderProps: { + ...mockProps, + showPriceField: true, + }, + formDefaultValues: {}, + }); + }); + + it("Should add number field with price", async () => { + const identifier = "quantity-addon"; + const price = 10; + await verifier.verifyFieldAddition({ + fieldType: "number", + identifier, + label: "Quantity", + price, + }); + + pageObject.openEditFieldDialog({ identifier }); + await expectScenario.toHavePriceField({ identifier, price }); + }); + + it("Should add boolean field with price", async () => { + const identifier = "extra-service-addon"; + const price = 15; + await verifier.verifyFieldAddition({ + fieldType: "boolean", + identifier, + label: "Extra Service", + price, + }); + + pageObject.openEditFieldDialog({ identifier }); + await expectScenario.toHavePriceField({ identifier, price }); + }); + + it("Should add select field with price and options", async () => { + const identifier = "meal-selection"; + await verifier.verifyFieldAddition({ + fieldType: "select", + identifier, + label: "Meal Selection", + options: [ + { label: "Vegetarian", value: "veg", price: 20 }, + { label: "Non-Vegetarian", value: "non-veg", price: 25 }, + ], + }); + + + verifier.verifyOptionPrices({ identifier, prices: [20, 25] }); + }); + + it("Should add multiselect field with price and options", async () => { + const identifier = "add-ons"; + await verifier.verifyFieldAddition({ + fieldType: "multiselect", + identifier, + label: "Add-ons", + options: [ + { label: "WiFi", value: "wifi", price: 10 }, + { label: "Breakfast", value: "breakfast", price: 15 }, + { label: "Parking", value: "parking", price: 20 }, + ], + }); + + verifier.verifyOptionPrices({ identifier, prices: [10, 15, 20] }); + }); + + it("Should add checkbox group field with price and options", async () => { + const identifier = "amenities"; + await verifier.verifyFieldAddition({ + fieldType: "checkbox", + identifier, + label: "Amenities", + options: [ + { label: "Pool Access", value: "pool", price: 15 }, + { label: "Gym Access", value: "gym", price: 20 }, + { label: "Spa Access", value: "spa", price: 25 }, + ], + }); + + verifier.verifyOptionPrices({ identifier, prices: [15, 20, 25] }); + }); + + it("Should add radio group field with price and options", async () => { + const identifier = "room-type"; + await verifier.verifyFieldAddition({ + fieldType: "radio", + identifier, + label: "Room Type", + options: [ + { label: "Standard", value: "standard", price: 50 }, + { label: "Deluxe", value: "deluxe", price: 75 }, + { label: "Suite", value: "suite", price: 100 }, + ], + }); + + verifier.verifyOptionPrices({ identifier, prices: [50, 75, 100] }); + }); + }); }); diff --git a/packages/features/form-builder/FormBuilder.tsx b/packages/features/form-builder/FormBuilder.tsx index 3677f3d97a..26409aca03 100644 --- a/packages/features/form-builder/FormBuilder.tsx +++ b/packages/features/form-builder/FormBuilder.tsx @@ -5,6 +5,7 @@ import { Controller, useFieldArray, useForm, useFormContext } from "react-hook-f import type { z } from "zod"; import { ZodError } from "zod"; +import { getCurrencySymbol } from "@calcom/app-store/_utils/payments/currencyConversions"; import { Dialog } from "@calcom/features/components/controlled-dialog"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; @@ -79,6 +80,8 @@ export const FormBuilder = function FormBuilder({ LockedIcon, dataStore, shouldConsiderRequired, + showPriceField, + paymentCurrency = "USD", showPhoneAndEmailToggle = false, }: { formProp: string; @@ -97,6 +100,8 @@ export const FormBuilder = function FormBuilder({ * e.g. Location field has a default value at backend so API can send no location but formBuilder in UI doesn't allow it. */ shouldConsiderRequired?: (field: RhfFormField) => boolean | undefined; + showPriceField?: boolean; + paymentCurrency?: string; }) { // I would have liked to give Form Builder it's own Form but nested Forms aren't something that browsers support. // So, this would reuse the same Form as the parent form. @@ -402,6 +407,8 @@ export const FormBuilder = function FormBuilder({ }); }} shouldConsiderRequired={shouldConsiderRequired} + showPriceField={showPriceField} + paymentCurrency={paymentCurrency} /> )} @@ -415,13 +422,19 @@ function Options({ onChange = () => {}, className = "", readOnly = false, + showPrice = false, + paymentCurrency, }: { label?: string; - value: { label: string; value: string }[]; - onChange?: (value: { label: string; value: string }[]) => void; + value: { label: string; value: string; price?: number }[]; + onChange?: (value: { label: string; value: string; price?: number }[]) => void; className?: string; readOnly?: boolean; + showPrice?: boolean; + paymentCurrency: string; }) { + const { t } = useLocale(); + const [animationRef] = useAutoAnimate(); if (!value) { onChange([ @@ -438,26 +451,52 @@ function Options({ return (
-
+
    {value?.map((option, index) => (
  • -
    - { - // Right now we use label of the option as the value of the option. It allows us to not separately lookup the optionId to know the optionValue - // It has the same drawback that if the label is changed, the value of the option will change. It is not a big deal for now. - value.splice(index, 1, { - label: e.target.value, - value: e.target.value.trim(), - }); - onChange(value); - }} - readOnly={readOnly} - placeholder={`Enter Option ${index + 1}`} - /> +
    +
    + { + // Right now we use label of the option as the value of the option. It allows use to not separately lookup the optionId to know the optionValue + // It has the same drawback that if the label is changed, the value of the option will change. It is not a big deal for now. + const newOptions = [...(value || [])]; + newOptions.splice(index, 1, { + label: e.target.value, + value: e.target.value.trim(), + }); + onChange(newOptions); + }} + readOnly={readOnly} + placeholder={t("enter_option", { index: index + 1 })} + /> +
    + {showPrice && ( +
    + { + const val = e.target.value; + const numValue = val === "" ? undefined : Number(val); + const updatedOptions = [...(value || [])]; + updatedOptions[index] = { + ...option, + price: numValue, + }; + onChange(updatedOptions); + }} + readOnly={readOnly} + placeholder="0" + addOnLeading={getCurrencySymbol(paymentCurrency)} + /> +
    + )} {value.length > 2 && !readOnly && (