import { CardForm, CardFormView } from '@stripe/stripe-react-native' import { FormikProvider, useFormik } from 'formik' import React, { ComponentRef, useCallback, useMemo, useRef, useState } from 'react' import { ActivityIndicator, findNodeHandle, Text, TouchableOpacity, View, ViewProps, } from 'react-native' import { Loading } from '../../../components' import { logger } from '../../../utils/Logger' import { readableError } from '../../../utils/handlers' import { FormikField } from '../../form' import { FieldType } from '../../form/types' import AddonsContainer from '../components/AddonsContainer' import Conditions from '../components/Conditions' import OrderReview, { IOrderItem } from '../components/OrderReview' import { createCheckoutFormConfig, formFieldsOrder } from './config' import { CheckoutFormComputedStyles, mergeCheckoutFormStyles } from './styles' import { mergeCheckoutFormTexts } from './texts' import { CheckoutFormOrderSummaryTexts, CheckoutFormPaymentTexts, CheckoutFormProps, CheckoutFormTexts, PaymentFormProps, } from './types' import { getFieldType } from '../utils' const MemoizedAddonsContainer = React.memo(AddonsContainer) const MemoizedConditions = React.memo(Conditions) const MemoizedOrderReview = React.memo(OrderReview) export const CheckoutForm: React.FC = ({ initialValues, isSubmitting, isLoggedIn, isAgeRequired = false, minimumAge, isTicketFree = false, isPhoneRequired = false, isPhoneHidden = false, countries, states, onCountryChange, orderItems, availableAddons = [], onAddonChange, onSubmit: onSubmitCallback, scrollRef, isInitialLoading, eventCurrency, conditions, isSinglePageCheckout = true, orderCustomFields = [], ticketCustomFields = [], addonCustomFields = [], styles: stylesProp, texts: textsProp, }) => { // Handle card form state for payment const [isCardFormComplete, setIsCardFormComplete] = useState(false) const [cardFormError, setCardFormError] = useState() const styles = useMemo( () => mergeCheckoutFormStyles(stylesProp), [stylesProp] ) const texts = useMemo>( () => mergeCheckoutFormTexts(textsProp), [textsProp] ) const fieldComponentStyles = styles.fields const addonsStyles = useMemo( () => ({ container: styles.addonSection, title: styles.sectionTitle, addonItem: styles.addonItem, addonInfo: styles.addonInfo, addonName: styles.addonName, addonRow: styles.addonRow, addonPrice: styles.addonPrice, addonPriceWithFees: styles.addonPriceWithFees, addonDescription: styles.addonDescription, addonSelectContainer: styles.addonSelectContainer, fieldComponentStyles, }), [fieldComponentStyles, styles] ) // Initial values already include default custom field values from the hook const modifiedInitialValues = { ...initialValues, isCardFormComplete: isTicketFree ? true : initialValues.isCardFormComplete, } const requiredConditions = useMemo( () => conditions?.filter((c) => c.is_required) || [], [conditions] ) const formik = useFormik({ initialValues: modifiedInitialValues, onSubmit: onSubmitCallback, validationSchema: createCheckoutFormConfig({ minimumAge, isAgeRequired, requirePassword: !isLoggedIn, isTicketFree, isSinglePageCheckout, requiredConditions, isPhoneRequired, orderCustomFields, ticketCustomFields, }), validateOnChange: true, enableReinitialize: true, }) const fieldTopRef = useRef>({}) // Helper: scroll to a field by key using onLayout-captured positions const scrollToField = (key: string, attempt: number = 0) => { if (key === 'isCardFormComplete' || !scrollRef.current) return let y = fieldTopRef.current[key] // Fallback for nested ticket holder fields if ( !(typeof y === 'number' && Number.isFinite(y)) && key.startsWith('ticketHolders[') ) { const holderMatch = key.match(/^ticketHolders\[(\d+)\]/) const index = holderMatch ? holderMatch[1] : undefined if (index != null) { const holderKey = `ticketHolders[${index}]` y = fieldTopRef.current[holderKey] if (!(typeof y === 'number' && Number.isFinite(y))) { y = fieldTopRef.current.ticketHolders } } } if (typeof y === 'number' && Number.isFinite(y)) { try { scrollRef.current.scrollTo({ y: Math.max(0, y - 16), animated: true }) logger.debug('[CheckoutForm] Scrolled to field', { key, position: y }) } catch (e) { logger.debug('[CheckoutForm] scrollTo failed', { key, error: e instanceof Error ? e.message : String(e), }) } } else if (attempt < 3) { setTimeout(() => scrollToField(key, attempt + 1), 50) } else { logger.warn('[CheckoutForm] Could not scroll to field - position not found', { key, }) } } const onPressSubmit = async () => { logger.debug('[CheckoutForm] Form submission initiated') const allFields = Object.keys(initialValues) // Create touched fields with special handling for nested ticket holders const touchedFields = allFields.reduce( (acc, field) => { // Handle ticketHolders specially as a nested structure if (field === 'ticketHolders') { // Create a properly structured touched fields object for ticket holders acc[field] = formik.values.ticketHolders.map(() => ({ firstName: true, lastName: true, email: true, phone: true, })) } else { // Mark all other fields as touched acc[field] = true } return acc }, {} as Record[]> ) const result = await formik.setTouched(touchedFields, true) const formErrors = typeof result === 'object' ? result : {} const paymentValid = isCardFormComplete || isTicketFree || !isSinglePageCheckout logger.debug('[CheckoutForm] submitting', { paymentValid, isCardFormComplete, isTicketFree, isSinglePageCheckout, }) if (!paymentValid) { setCardFormError(texts.payment.errorRequired) } if (Object.keys(formErrors).length === 0 && paymentValid) { logger.debug('[CheckoutForm] Form validation passed, submitting', { email: formik.values.email, }) formik.handleSubmit() return } logger.warn('[CheckoutForm] Form validation failed', { errors: Object.keys(formErrors), paymentValid, }) const firstErroredKey = formFieldsOrder.find( (k) => (formErrors as any)[k as keyof typeof formErrors] != null ) if (firstErroredKey === 'ticketHolders') { const thErrors = (formErrors as any).ticketHolders as Array if (Array.isArray(thErrors)) { const erroredIndex = thErrors.findIndex((e) => e && Object.keys(e).length > 0) if (erroredIndex >= 0) { scrollToField(`ticketHolders[${erroredIndex}]`) logger.debug('[CheckoutForm] Scrolling to errored ticket holder', { index: erroredIndex, }) return } } scrollToField('ticketHolders') } else if (firstErroredKey) { scrollToField(firstErroredKey) } else if (!paymentValid) { scrollToField('isCardFormComplete') } } const handleAddonChange = useCallback( (addonId: string, quantity: number) => { formik.setFieldValue(`addons.${addonId}.quantity`, quantity) if (quantity > 0) { // reapply defaults if coming from 0 addonCustomFields.forEach((field) => { if (field.defaultValue != null) { formik.setFieldValue( `addons.${addonId}.customFields.${field.name}`, Array.isArray(field.defaultValue) ? field.defaultValue.join(',') : field.defaultValue ) } }) } onAddonChange?.(addonId, quantity) }, [formik, onAddonChange] ) return ( {isInitialLoading && } (fieldTopRef.current.firstName = e.nativeEvent.layout.y)}> (fieldTopRef.current.lastName = e.nativeEvent.layout.y)}> (fieldTopRef.current.email = e.nativeEvent.layout.y)}> (fieldTopRef.current.emailConfirmation = e.nativeEvent.layout.y) } > (fieldTopRef.current.password = e.nativeEvent.layout.y)}> (fieldTopRef.current.passwordConfirmation = e.nativeEvent.layout.y) } > (fieldTopRef.current.phone = e.nativeEvent.layout.y)}> (fieldTopRef.current.phone = e.nativeEvent.layout.y)}> (fieldTopRef.current.dateOfBirth = e.nativeEvent.layout.y)} > (fieldTopRef.current.street = e.nativeEvent.layout.y)}> (fieldTopRef.current.country = e.nativeEvent.layout.y)}> ({ value: String(c.id), label: c.name, })), onChange: (val) => { onCountryChange(val) }, }} styles={fieldComponentStyles} /> (fieldTopRef.current.country = e.nativeEvent.layout.y)}> (fieldTopRef.current.postalCode = e.nativeEvent.layout.y)}> {orderCustomFields.map((field) => ( (fieldTopRef.current[field.name] = e.nativeEvent.layout.y)} > ({ value: option.value, label: option.name, })), }} styles={fieldComponentStyles} /> ))} { fieldTopRef.current.ticketHolders = e.nativeEvent.layout.y }} > {texts.ticketHolders.title} {formik.values.ticketHolders.map((_, index) => { const ticketLabel = ( texts.ticketHolders.itemTitle || 'Ticket {index}' ).replace('{index}', String(index + 1)) return ( { const sectionY = fieldTopRef.current.ticketHolders ?? 0 const holderY = sectionY + e.nativeEvent.layout.y fieldTopRef.current[`ticketHolders[${index}]`] = holderY }} > {ticketLabel} { const baseY = fieldTopRef.current[`ticketHolders[${index}]`] ?? fieldTopRef.current.ticketHolders ?? 0 fieldTopRef.current[`ticketHolders[${index}].firstName`] = baseY + e.nativeEvent.layout.y }} > { const baseY = fieldTopRef.current[`ticketHolders[${index}]`] ?? fieldTopRef.current.ticketHolders ?? 0 fieldTopRef.current[`ticketHolders[${index}].lastName`] = baseY + e.nativeEvent.layout.y }} > { const baseY = fieldTopRef.current[`ticketHolders[${index}]`] ?? fieldTopRef.current.ticketHolders ?? 0 fieldTopRef.current[`ticketHolders[${index}].email`] = baseY + e.nativeEvent.layout.y }} > { const baseY = fieldTopRef.current[`ticketHolders[${index}]`] ?? fieldTopRef.current.ticketHolders ?? 0 fieldTopRef.current[`ticketHolders[${index}].phone`] = baseY + e.nativeEvent.layout.y }} > {/* Ticket custom fields */} {ticketCustomFields.map((field) => ( ({ value: option.value, label: option.name, })), }} styles={fieldComponentStyles} /> ))} ) })} {/* Event Conditions */} {conditions && conditions.length > 0 && ( { formik.setFieldValue('acceptedConditions', { ...formik.values.acceptedConditions, [conditionId]: isAccepted, }) }} texts={{ title: texts.conditions.title, viewButton: texts.conditions.viewButton, hideButton: texts.conditions.hideButton, acceptCheckbox: texts.conditions.acceptLabel, }} /> )} {/* Payment - only show in single-page mode and if ticket is not free */} {!isTicketFree && isSinglePageCheckout && ( (fieldTopRef.current.isCardFormComplete = e.nativeEvent.layout.y) } > { logger.debug('[CheckoutForm] Payment form complete', { ...details }) if (details.complete) { setCardFormError(undefined) } setIsCardFormComplete(details.complete) }} error={cardFormError} styles={styles} texts={{ orderSummary: texts.orderSummary, payment: texts.payment, }} /> )} {/* Submit Button */} {isSubmitting ? ( ) : ( {isSinglePageCheckout ? texts.buttons.singlePageSubmit : texts.buttons.goToPayment} )} ) } interface PaymentProps { onFormComplete: (details: CardFormView.Details) => void error?: string orderItems: IOrderItem[] paymentViewProps?: ViewProps styles: CheckoutFormComputedStyles texts: { orderSummary: CheckoutFormOrderSummaryTexts payment: CheckoutFormPaymentTexts } } export const Payment = ({ onFormComplete, error, orderItems, paymentViewProps, styles, texts, }: PaymentProps) => { return ( {texts.orderSummary.sectionTitle} {texts.payment.sectionTitle} { logger.warn('[PaymentForm] CardForm onFormComplete triggered', { complete: details.complete, details: JSON.stringify(details), }) onFormComplete(details) }} style={styles.cardContainer} cardStyle={styles.cardStyle} autofocus={false} /> {error ? {error} : null} ) } export const PaymentForm = ({ orderItems, onSubmit, styles: stylesProp, texts: textsProp, }: PaymentFormProps) => { const [error, setError] = useState() const [isSubmitting, setIsSubmitting] = useState(false) const [isCardFormComplete, setIsCardFormComplete] = useState(false) const styles = useMemo( () => mergeCheckoutFormStyles(stylesProp), [stylesProp] ) const texts = useMemo>( () => mergeCheckoutFormTexts(textsProp), [textsProp] ) const onFormComplete = (details: CardFormView.Details) => { logger.warn('[PaymentForm] CardForm onFormComplete triggered', { complete: details.complete, details: JSON.stringify(details), }) setIsCardFormComplete(details.complete) if (details.complete) { setError(undefined) logger.debug('[PaymentForm] Card form marked as complete') } else { logger.debug('[PaymentForm] Card form incomplete') } } const onPressSubmit = async () => { try { setIsSubmitting(true) // Don't validate isCardFormComplete here - onFormComplete doesn't trigger on Android v40+ // Let Stripe's confirmPayment handle validation instead logger.debug('[PaymentForm] Submitting payment', { isCardFormComplete }) await onSubmit() } catch (e) { const errorMsg = readableError(e) logger.error('[PaymentForm] Payment submission failed', { error: errorMsg }) setError(errorMsg) } finally { setIsSubmitting(false) } } return ( {/* Submit Button */} {isSubmitting ? ( ) : ( {texts.buttons.paymentSubmit} )} ) }