'use client' import { useMemo } from 'react' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { makeBookingSchema, isSupportedFormField, type MeetingAvailability, type MeetingBookingPayload, } from '../../schemas/meeting-booking-schema' import { Button, FieldWrapper, Input, Textarea, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, RadioGroup, RadioGroupItem, } from '../ui' import { HoneypotField } from '../ui/honeypot-field' /** * BookingForm — attendee details + the link's declared custom questions + * verbatim legal-consent copy. ContactForm's scaffolding (react-hook-form + * zodResolver + lib field primitives); a sibling rather than a `` * configuration because of the dynamic HubSpot `formFields`, the per-checkbox * consent model, and the first/last-name field model — none expressible via * `hideFields`/`extraTopField`. * * Bot protection is LOAD-BEARING: without the humanity signals in the body, * the host's `verifyHuman` degrades to first-party-only BotID (fails open for * external embedders). Honeypot + elapsed-ms are merged into the POST at * submit; the parent calls `resetSignals()` after a SLOT_TAKEN refetch so a * legitimate retry isn't flagged too-fast. */ export interface BookingFormProps { availability: MeetingAvailability meetingId: string startTimeMs: number durationMs: number /** IANA zone the confirmation/invite should render in (parent-resolved). */ timezone: string isSubmitting: boolean onSubmit: (payload: Record) => Promise /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */ honeypotInputProps: { ref: React.Ref; name: string } getSignals: () => Record } export function BookingForm({ availability, meetingId, startTimeMs, durationMs, timezone, isSubmitting, onSubmit, honeypotInputProps, getSignals, }: BookingFormProps) { const { formFields, legalConsent } = availability const supportedFields = useMemo(() => formFields.filter(isSupportedFormField), [formFields]) const schema = useMemo(() => makeBookingSchema(supportedFields, legalConsent), [supportedFields, legalConsent]) const { register, control, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(schema), defaultValues: { meetingId, startTimeMs, durationMs, timezone, locale: typeof navigator !== 'undefined' ? navigator.language : undefined, firstName: '', lastName: '', email: '', formFields: {}, legalConsentResponses: (legalConsent?.communicationConsentCheckboxes ?? []).map((c) => ({ communicationTypeId: c.communicationTypeId, consented: false, })), }, }) const submit = handleSubmit(async (data) => { await onSubmit({ ...data, meetingId, startTimeMs, durationMs, timezone, ...getSignals() }) }) const fieldError = (name: string): string | undefined => { const err = (errors.formFields as Record | undefined)?.[name] return err?.message } // Field chrome mirrors ContactForm 1:1 (`contact/contact-form.tsx`) — the // booking form must be indistinguishable from every other form in the app. const inputClass = 'bg-ods-card border-ods-border text-ods-text-primary placeholder-ods-text-secondary px-3 h-11 md:h-12' // One step ABOVE the `spacing system/m` the design names (16/24 instead of // 12/16), because the field messages hang out of flow: they need ~16px on a // phone and ~20 on desktop of clear space under the control, and `m` leaves // 12/16 — four pixels short at both ends, so an error would print over the // next field's label. The design has no error state drawn; this is the // smallest ODS step that houses it. const FORM_STACK = 'flex flex-col gap-[var(--spacing-system-l)]' return (
{/* Email first, name pair below — the order the desktop and mobile mocks both draw. The pair stays TWO columns even on a phone (164px each at 375): two short fields side by side cost one line instead of two on the layout that can least afford them. Every field goes through `FieldWrapper`, which hangs its message OUT OF FLOW below the control. That is the whole reason it is here: a message rendered in flow grows its field, which pushes everything under it down and — inside a card that states its height — walks the submit button off the bottom the moment validation fails. Required-ness is carried by `required` on the control (read out by assistive tech), not by an asterisk the design does not draw. */}
{supportedFields.map((field) => ( {field.type === 'textarea' && (