* fix * move event types components to web * update import paths * mv apps components * migrate form builder * fix * mv sso * fix * mv * update import paths * update import paths * mv * mv * mv * fix * update Booker * fix * fix * fix * fix * mv video * mv embed components to web * update import paths * mv calendar weekly view components * update import paths * fix * fixp * fix * fix * fix * fix: update FormBuilder imports to use @calcom/features/form-builder Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update broken import paths after file migrations Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: correct import paths for platform atoms and moved components Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: apply CSS type fixes and add missing atoms exports Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: resolve type errors in test files after component migrations Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: resolve remaining type errors in test files Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix * migrate * fix: resolve type errors in test and mock files - Add missing bookingForm, bookerFormErrorRef, instantConnectCooldownMs to Booker.test.tsx bookings prop - Add all required BookerEvent properties to event.mock.ts - Add vi import from vitest to all mock files - Fix date parameter types in packages/dayjs/__mocks__/index.ts - Add verificationCode and setVerificationCode to test-utils.tsx mock store - Remove children.type access in Section.tsx mock to fix type error - Fix lint issues: remove unused React imports, use import type where needed, add return types - Add biome-ignore comments for pre-existing lint warnings in test files Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * migrate * migrate * migrate * update import paths * update import paths * update import paths * fix * migrate data table * migrate data table * fix * fix * fix * migrate insights components * migrate insights components * fix * mv * update import paths * fix * fix * fix * fix * fix * fix: resolve type errors in test mocks - Booker.test.tsx: Add all required UseFormReturn methods to bookingForm mock - event.mock.ts: Fix entity, subsetOfHosts, instantMeetingParameters, fieldTranslations, image types - dayjs/__mocks__/index.ts: Use Object.assign for proper typing of mock properties - Section.tsx: Change 'class' to 'className' in JSX with biome-ignore comment Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing hasDataErrors and dataErrors to bookings.errors mock Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing loadingStates properties to bookings mock Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing slots properties (setTentativeSelectedTimeslots, tentativeSelectedTimeslots, slotReservationId) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update quickAvailabilityChecks to include utcEndIso and use valid status type Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing bookerForm properties (formName, beforeVerifyEmail, formErrors) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing UseFormReturn properties to bookerForm.bookingForm mock Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add hasFormErrors and formErrors to bookerForm.formErrors mock Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add hasFormErrors and formErrors to bookerForm.errors mock Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing isError property to mockEvent Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: use complete BookerEvent mock in Booker.test.tsx Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: use branded bookingFields type in event mock Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing schedule mock properties (isError, isSuccess, isLoading, dataUpdatedAt) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * revert * fix * fix * fix * fix build * fix * fix * fix * fix: correct AddMembersWithSwitch test wrapper to use initial assignAllTeamMembers value - Fixed test wrapper to initialize useState with componentProps.assignAllTeamMembers instead of hardcoded false, allowing tests to properly test different states - Updated test expectations for ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE state to match actual component behavior (toggle should be present and checked) - Fixed 'should show Segment when toggled on' test to start with assignAllTeamMembers: false to properly test the flow of enabling it - Added explicit types to satisfy biome lint requirements Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: use JSX.Element instead of React.JSX.Element for type compatibility Co-Authored-By: benny@cal.com <sldisek783@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
import { useMemo, useRef } from "react";
|
|
import { useFormContext } from "react-hook-form";
|
|
import { z } from "zod";
|
|
|
|
import type { LocationObject } from "@calcom/app-store/locations";
|
|
import { getOrganizerInputLocationTypes } from "@calcom/app-store/locations";
|
|
import { DefaultEventLocationTypeEnum } from "@calcom/app-store/locations";
|
|
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 { SystemField } from "@calcom/lib/bookings/SystemField";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
|
|
|
type TouchedFields = {
|
|
responses?: Record<string, boolean>;
|
|
};
|
|
|
|
type Fields = NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
|
|
const PhoneLocationSchema = z.object({
|
|
value: z.literal(DefaultEventLocationTypeEnum.Phone),
|
|
optionValue: z.string().optional(),
|
|
});
|
|
export const BookingFields = ({
|
|
fields,
|
|
locations,
|
|
rescheduleUid,
|
|
isDynamicGroupBooking,
|
|
bookingData,
|
|
isPaidEvent,
|
|
paymentCurrency = "USD",
|
|
}: {
|
|
fields: Fields;
|
|
locations: LocationObject[];
|
|
rescheduleUid?: string;
|
|
bookingData?: GetBookingType | null;
|
|
isDynamicGroupBooking: boolean;
|
|
isPaidEvent?: boolean;
|
|
paymentCurrency?: string;
|
|
}) => {
|
|
const { t, i18n } = useLocale();
|
|
const { watch, setValue, formState } = useFormContext();
|
|
const locationResponse = watch("responses.location");
|
|
const currentView = rescheduleUid ? "reschedule" : "";
|
|
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
|
|
|
|
// Identify all phone fields (except location field)
|
|
const otherPhoneFieldNames = useMemo(
|
|
() => fields.filter((f) => f.type === "phone" && f.name !== SystemField.Enum.location).map((f) => f.name),
|
|
[fields]
|
|
);
|
|
|
|
// Track last synced value to avoid redundant updates
|
|
const lastSyncedPhoneRef = useRef<string | null>(null);
|
|
|
|
// Event-driven sync function
|
|
const syncPhoneFields = (locationValue: unknown) => {
|
|
const parsed = PhoneLocationSchema.safeParse(locationValue);
|
|
if (!parsed.success) return;
|
|
const { optionValue } = parsed.data;
|
|
const phone = (optionValue ?? "").trim();
|
|
|
|
// Skip if empty or same as last sync (avoid redundant updates during typing)
|
|
if (!phone || phone === lastSyncedPhoneRef.current) return;
|
|
|
|
// Copy phone to other phone fields (only if user hasn't manually touched them)
|
|
otherPhoneFieldNames.forEach((name) => {
|
|
const targetTouched = !!(formState.touchedFields as TouchedFields)?.responses?.[name];
|
|
|
|
if (!targetTouched) {
|
|
setValue(`responses.${name}`, phone, {
|
|
shouldDirty: false,
|
|
shouldValidate: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
lastSyncedPhoneRef.current = phone;
|
|
};
|
|
|
|
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
|
|
<div>
|
|
{fields.map((field, index) => {
|
|
// Don't Display Location field in case of instant meeting as only Cal Video is supported
|
|
if (isInstantMeeting && field.name === "location") return null;
|
|
|
|
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
|
|
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
|
|
const rescheduleReadOnly =
|
|
(field.editable === "system" || field.editable === "system-but-optional") &&
|
|
!!rescheduleUid &&
|
|
bookingData !== null;
|
|
|
|
const bookingReadOnly = field.editable === "user-readonly";
|
|
|
|
let readOnly = bookingReadOnly || rescheduleReadOnly;
|
|
|
|
let hidden = !!field.hidden;
|
|
const fieldViews = field.views;
|
|
|
|
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
|
|
return null;
|
|
}
|
|
|
|
if (field.name === SystemField.Enum.rescheduleReason) {
|
|
if (bookingData === null) {
|
|
return null;
|
|
}
|
|
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
|
readOnly = false;
|
|
}
|
|
|
|
if (field.name === SystemField.Enum.smsReminderNumber) {
|
|
// `smsReminderNumber` and location.optionValue when location.value===phone are the same data point. We should solve it in a better way in the Form Builder itself.
|
|
// I think we should have a way to connect 2 fields together and have them share the same value in Form Builder
|
|
if (locationResponse?.value === "phone") {
|
|
setValue(`responses.${SystemField.Enum.smsReminderNumber}`, locationResponse?.optionValue);
|
|
// Just don't render the field now, as the value is already connected to attendee phone location
|
|
return null;
|
|
}
|
|
// `smsReminderNumber` can be edited during reschedule even though it's a system field
|
|
readOnly = false;
|
|
}
|
|
|
|
if (field.name === SystemField.Enum.guests) {
|
|
readOnly = false;
|
|
// No matter what user configured for Guests field, we don't show it for dynamic group booking as that doesn't support guests
|
|
hidden = isDynamicGroupBooking ? true : !!field.hidden;
|
|
}
|
|
|
|
// We don't show `notes` field during reschedule but since it's a query param we better valid if rescheduleUid brought any bookingData
|
|
if (field.name === SystemField.Enum.notes && bookingData !== null) {
|
|
return null;
|
|
}
|
|
|
|
if (field.name === SystemField.Enum.location) {
|
|
readOnly = false;
|
|
}
|
|
|
|
// Dynamically populate location field options
|
|
if (field.name === SystemField.Enum.location && field.type === "radioInput") {
|
|
if (!field.optionsInputs) {
|
|
throw new Error("radioInput must have optionsInputs");
|
|
}
|
|
const optionsInputs = field.optionsInputs;
|
|
|
|
// TODO: Instead of `getLocationOptionsForSelect` options should be retrieved from dataStore[field.getOptionsAt]. It would make it agnostic of the `name` of the field.
|
|
const options = getLocationOptionsForSelect(locations, t);
|
|
options.forEach((option) => {
|
|
const optionInput = optionsInputs[option.value as keyof typeof optionsInputs];
|
|
if (optionInput) {
|
|
optionInput.placeholder = option.inputPlaceholder;
|
|
}
|
|
});
|
|
field.options = options.filter(
|
|
(location): location is NonNullable<(typeof options)[number]> => !!location
|
|
);
|
|
}
|
|
|
|
if (field?.options) {
|
|
const organizerInputTypes = getOrganizerInputLocationTypes();
|
|
const organizerInputObj: Record<string, number> = {};
|
|
|
|
field.options.forEach((f) => {
|
|
if (f.value in organizerInputObj) {
|
|
organizerInputObj[f.value]++;
|
|
} else {
|
|
organizerInputObj[f.value] = 1;
|
|
}
|
|
});
|
|
|
|
field.options = field.options.map((field) => {
|
|
return {
|
|
...field,
|
|
value:
|
|
organizerInputTypes.includes(field.value) && organizerInputObj[field.value] > 1
|
|
? field.label
|
|
: field.value,
|
|
};
|
|
});
|
|
}
|
|
|
|
// 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 (
|
|
<FormBuilderField
|
|
className="mb-4"
|
|
field={{ ...fieldWithPrice, hidden }}
|
|
readOnly={readOnly}
|
|
key={index}
|
|
{...(field.name === SystemField.Enum.location && {
|
|
onValueChange: ({ value }) => {
|
|
syncPhoneFields(value);
|
|
},
|
|
})}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|