Skip to content

Commit 55e6682

Browse files
asadath1395zomarsretrogtxcoderabbitai[bot]kart1ka
authored
feat: Custom Fields OR Add-Ons with a fee associated (#19997)
* feat: Addons to bookings * Add tests * Add tests * Fix price field not shown immediately after enabling payment for an event * Add support for all currencies in addons and fix type errors * Address review comments * Add tests to cover all input types * Address cubic-dev-ai comments * Remove openapi.json from git tracking * Revert changes * Revert unrelated linter/formatter changes * Address cubic-dev-ai comments * Addres cubic-dev-ai comment * Update to use test id instead of hardcoded label Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Address coderabbits review comments * Fix failing unit test * some fixes... * Fix tests * Fix import * Move handlePayment test file to appropriate location * Fix test --------- Co-authored-by: Omar López <zomars@me.com> Co-authored-by: amrit <iamamrit27@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com> Co-authored-by: Benny Joo <sldisek783@gmail.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
1 parent 65448c0 commit 55e6682

File tree

14 files changed

+822
-40
lines changed

14 files changed

+822
-40
lines changed

apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
141141
<div className="col-span-2 mb-6 font-semibold">
142142
<Price
143143
currency={paymentAppData.currency}
144-
price={paymentAppData.price}
144+
price={props.payment?.amount ?? paymentAppData.price}
145145
displayAlternateSymbol={false}
146146
/>
147147
</div>

packages/app-store/_utils/payments/currencyConversions.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,27 @@ export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, cur
3232
}
3333
return amount / 100;
3434
};
35+
36+
export const getCurrencySymbol = (currencyCode: string): string => {
37+
try {
38+
const formatter = Intl.NumberFormat("en", { style: "currency", currency: currencyCode });
39+
// formatToParts(1) breaks down the formatted number (1) into its constituent parts
40+
// like currency symbol, decimal separator, etc. We use 1 as it's a simple number
41+
// that will show the currency symbol clearly.
42+
// For example, since we are formatting the number 1 with USD currency, formatToParts(1) would return an array like:
43+
// [
44+
// { type: "currency", value: "$" },
45+
// { type: "integer", value: "1" },
46+
// { type: "decimal", value: "." },
47+
// { type: "fraction", value: "00" }
48+
// ]
49+
const parts = formatter.formatToParts(1);
50+
const currencyPart = parts.find((part) => part.type === "currency");
51+
return currencyPart?.value || "$";
52+
} catch {
53+
// Ideally we would not reach here, but if for some reason we reach here, we return
54+
// $ as default currency
55+
console.warn(`Failed to get currency symbol for ${currencyCode}, falling back to $`);
56+
return "$";
57+
}
58+
};

packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export const BookEventForm = ({
8989
return eventType?.price > 0 && !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
9090
}, [eventType]);
9191

92+
const paymentCurrency = useMemo(() => {
93+
if (!eventType) return "USD";
94+
return getPaymentAppData(eventType)?.currency || "USD";
95+
}, [eventType]);
96+
9297
if (eventQuery.isError) return <Alert severity="warning" message={t("error_booking_event")} />;
9398
if (eventQuery.isPending || !eventQuery.data) return <FormSkeleton />;
9499
if (!timeslot)
@@ -129,6 +134,8 @@ export const BookEventForm = ({
129134
locations={eventType.locations}
130135
rescheduleUid={rescheduleUid || undefined}
131136
bookingData={bookingData}
137+
isPaidEvent={isPaidEvent}
138+
paymentCurrency={paymentCurrency}
132139
/>
133140
{errors.hasFormErrors || errors.hasDataErrors ? (
134141
<div data-testid="booking-fail">

packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,81 @@ import { useBookerStore } from "@calcom/features/bookings/Booker/store";
66
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
77
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
88
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
9+
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
10+
import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml";
911
import { useLocale } from "@calcom/lib/hooks/useLocale";
12+
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
1013
import type { RouterOutputs } from "@calcom/trpc/react";
1114

1215
import { SystemField } from "../../../lib/SystemField";
1316

17+
type Fields = NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
1418
export const BookingFields = ({
1519
fields,
1620
locations,
1721
rescheduleUid,
1822
isDynamicGroupBooking,
1923
bookingData,
24+
isPaidEvent,
25+
paymentCurrency = "USD",
2026
}: {
21-
fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
27+
fields: Fields;
2228
locations: LocationObject[];
2329
rescheduleUid?: string;
2430
bookingData?: GetBookingType | null;
2531
isDynamicGroupBooking: boolean;
32+
isPaidEvent?: boolean;
33+
paymentCurrency?: string;
2634
}) => {
27-
const { t } = useLocale();
35+
const { t, i18n } = useLocale();
2836
const { watch, setValue } = useFormContext();
2937
const locationResponse = watch("responses.location");
3038
const currentView = rescheduleUid ? "reschedule" : "";
3139
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
3240

41+
const getPriceFormattedLabel = (label: string, price: number) =>
42+
`${label} (${Intl.NumberFormat(i18n.language, {
43+
style: "currency",
44+
currency: paymentCurrency,
45+
}).format(price)})`;
46+
47+
const getFieldWithDirectPricing = (field: Fields[number]) => {
48+
if (!fieldTypesConfigMap[field.type]?.supportsPricing || !field.label || !field.price) {
49+
return field;
50+
}
51+
52+
const price = typeof field.price === "string" ? parseFloat(field.price) : field.price;
53+
const label = getPriceFormattedLabel(field.label, price);
54+
55+
return {
56+
...field,
57+
label,
58+
...(fieldsThatSupportLabelAsSafeHtml.includes(field.type) && field.labelAsSafeHtml
59+
? { labelAsSafeHtml: markdownToSafeHTML(label) }
60+
: { labelAsSafeHtml: undefined }),
61+
};
62+
};
63+
64+
const getFieldWithOptionLevelPrices = (field: Fields[number]) => {
65+
if (!fieldTypesConfigMap[field.type]?.optionsSupportPricing || !field.options) return field;
66+
67+
return {
68+
...field,
69+
options: field.options.map((opt) => {
70+
const option = opt as { value: string; label: string; price?: number };
71+
const optionPrice = option.price;
72+
73+
// Only add price to label if there's a price
74+
if (!optionPrice) return option;
75+
76+
return {
77+
...option,
78+
label: getPriceFormattedLabel(option.label, optionPrice),
79+
};
80+
}),
81+
};
82+
};
83+
3384
return (
3485
// 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
3586
// 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 = ({
134185
});
135186
}
136187

188+
// Add price display for custom inputs with prices
189+
let fieldWithPrice = field;
190+
191+
if (isPaidEvent) {
192+
// Handle number fields and boolean (single checkbox) fields
193+
if (fieldTypesConfigMap[field.type]?.supportsPricing) {
194+
fieldWithPrice = getFieldWithDirectPricing(field);
195+
}
196+
197+
// Handle fields with option-level prices (select, multiselect, and checkbox group)
198+
if (fieldTypesConfigMap[field.type]?.optionsSupportPricing) {
199+
fieldWithPrice = getFieldWithOptionLevelPrices(fieldWithPrice);
200+
}
201+
}
202+
137203
return (
138-
<FormBuilderField className="mb-4" field={{ ...field, hidden }} readOnly={readOnly} key={index} />
204+
<FormBuilderField
205+
className="mb-4"
206+
field={{ ...fieldWithPrice, hidden }}
207+
readOnly={readOnly}
208+
key={index}
209+
/>
139210
);
140211
})}
141212
</div>

packages/features/bookings/lib/getBookingFields.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
EventTypeMetaDataSchema,
1919
} from "@calcom/prisma/zod-utils";
2020

21-
type Fields = z.infer<typeof eventTypeBookingFields>;
21+
export type Fields = z.infer<typeof eventTypeBookingFields>;
2222

2323
if (typeof window !== "undefined" && !process.env.INTEGRATION_TEST_MODE) {
2424
// This file imports some costly dependencies, so we want to make sure it's not imported on the client side.

packages/features/bookings/lib/handleNewBooking.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2102,6 +2102,8 @@ async function handler(
21022102
bookerEmail,
21032103
bookerPhoneNumber,
21042104
isDryRun,
2105+
bookingFields: eventType.bookingFields,
2106+
locale: language,
21052107
});
21062108
const subscriberOptionsPaymentInitiated: GetSubscriberOptions = {
21072109
userId: triggerForUser ? organizerUser.id : null,

0 commit comments

Comments
 (0)