useBillingCustomerDataForm.ts243 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import type { |
| 3 | StripeAddressElement, |
| 4 | StripeAddressElementChangeEvent, |
| 5 | StripeAddressElementOptions, |
| 6 | } from '@stripe/stripe-js' |
| 7 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| 8 | import { useForm } from 'react-hook-form' |
| 9 | |
| 10 | import { TaxIdFormValues, TaxIdSchema } from './BillingCustomerDataForm' |
| 11 | import { TAX_IDS } from './TaxID.constants' |
| 12 | import { getEffectiveTaxCountry, resolveStoredTaxId, sanitizeTaxIdValue } from './TaxID.utils' |
| 13 | import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types' |
| 14 | |
| 15 | type StripeAddressValue = StripeAddressElementChangeEvent['value'] |
| 16 | type StripeAddressValidationState = 'unknown' | 'complete' | 'incomplete' |
| 17 | type StripeAddressElementValueResult = Awaited<ReturnType<StripeAddressElement['getValue']>> |
| 18 | |
| 19 | interface UseBillingCustomerDataFormProps { |
| 20 | customerProfile?: { |
| 21 | address?: CustomerAddress | null |
| 22 | billing_name?: string |
| 23 | } | null |
| 24 | taxId?: CustomerTaxId | null |
| 25 | onCustomerDataChange: (data: BillingAddressPayload) => Promise<void> |
| 26 | } |
| 27 | |
| 28 | export type BillingAddressPayload = { |
| 29 | address: CustomerAddress | undefined |
| 30 | billing_name: string |
| 31 | tax_id: CustomerTaxId | null |
| 32 | } |
| 33 | |
| 34 | type SubmittedBillingFormState = { |
| 35 | addressValue: StripeAddressValue |
| 36 | taxIdValues: TaxIdFormValues |
| 37 | } |
| 38 | |
| 39 | type BillingFormSubmitResult = |
| 40 | | { |
| 41 | status: 'error' |
| 42 | message: string |
| 43 | } |
| 44 | | { |
| 45 | status: 'success' |
| 46 | submittedState: SubmittedBillingFormState |
| 47 | } |
| 48 | |
| 49 | export function useBillingCustomerDataForm({ |
| 50 | customerProfile, |
| 51 | taxId, |
| 52 | onCustomerDataChange, |
| 53 | }: UseBillingCustomerDataFormProps) { |
| 54 | const initialStripeAddressValue: StripeAddressValue = useMemo( |
| 55 | () => ({ |
| 56 | name: customerProfile?.billing_name ?? '', |
| 57 | address: { |
| 58 | line1: customerProfile?.address?.line1 ?? '', |
| 59 | line2: customerProfile?.address?.line2 ?? '', |
| 60 | city: customerProfile?.address?.city ?? '', |
| 61 | state: customerProfile?.address?.state ?? '', |
| 62 | postal_code: customerProfile?.address?.postal_code ?? '', |
| 63 | country: customerProfile?.address?.country ?? '', |
| 64 | }, |
| 65 | }), |
| 66 | [customerProfile] |
| 67 | ) |
| 68 | |
| 69 | const addressOptions: StripeAddressElementOptions = useMemo( |
| 70 | () => ({ |
| 71 | mode: 'billing', |
| 72 | fields: { phone: 'never' }, |
| 73 | autocomplete: { |
| 74 | apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!, |
| 75 | mode: 'google_maps_api' as const, |
| 76 | }, |
| 77 | display: { name: 'full' as const }, |
| 78 | defaultValues: initialStripeAddressValue, |
| 79 | }), |
| 80 | [initialStripeAddressValue] |
| 81 | ) |
| 82 | |
| 83 | const initialTaxIdValues = useMemo( |
| 84 | () => ({ |
| 85 | tax_id_type: taxId?.type || '', |
| 86 | tax_id_value: taxId?.value || '', |
| 87 | tax_id_name: taxId |
| 88 | ? (resolveStoredTaxId(taxId.type, taxId.country, customerProfile?.address?.country)?.name ?? |
| 89 | '') |
| 90 | : '', |
| 91 | }), |
| 92 | [customerProfile, taxId] |
| 93 | ) |
| 94 | |
| 95 | const form = useForm<TaxIdFormValues>({ |
| 96 | resolver: zodResolver(TaxIdSchema as any), |
| 97 | defaultValues: initialTaxIdValues, |
| 98 | }) |
| 99 | |
| 100 | const stripeAddressRef = useRef<StripeAddressValue>(initialStripeAddressValue) |
| 101 | const savedStripeAddressRef = useRef<StripeAddressValue>(initialStripeAddressValue) |
| 102 | const stripeAddressValidationRef = useRef<StripeAddressValidationState>('unknown') |
| 103 | const savedTaxIdValuesRef = useRef<TaxIdFormValues>(initialTaxIdValues) |
| 104 | const [isAddressDirty, setIsAddressDirty] = useState(false) |
| 105 | const [addressCountry, setAddressCountry] = useState<string | undefined>( |
| 106 | initialStripeAddressValue.address.country || undefined |
| 107 | ) |
| 108 | const [resetKey, setResetKey] = useState(0) |
| 109 | |
| 110 | useEffect(() => { |
| 111 | savedStripeAddressRef.current = initialStripeAddressValue |
| 112 | savedTaxIdValuesRef.current = initialTaxIdValues |
| 113 | form.reset(initialTaxIdValues) |
| 114 | stripeAddressRef.current = initialStripeAddressValue |
| 115 | stripeAddressValidationRef.current = 'unknown' |
| 116 | setIsAddressDirty(false) |
| 117 | setAddressCountry(initialStripeAddressValue.address.country || undefined) |
| 118 | setResetKey((c) => c + 1) |
| 119 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 120 | }, [initialStripeAddressValue, initialTaxIdValues]) |
| 121 | |
| 122 | const onAddressChange = useCallback((evt: StripeAddressElementChangeEvent) => { |
| 123 | stripeAddressRef.current = evt.value |
| 124 | stripeAddressValidationRef.current = evt.complete ? 'complete' : 'incomplete' |
| 125 | setAddressCountry(evt.value.address.country || undefined) |
| 126 | setIsAddressDirty(!isAddressEqual(evt.value, savedStripeAddressRef.current)) |
| 127 | }, []) |
| 128 | |
| 129 | const applyAddressElementValue = useCallback((result: StripeAddressElementValueResult) => { |
| 130 | stripeAddressRef.current = result.value |
| 131 | stripeAddressValidationRef.current = result.complete ? 'complete' : 'incomplete' |
| 132 | setAddressCountry(result.value.address.country || undefined) |
| 133 | setIsAddressDirty(!isAddressEqual(result.value, savedStripeAddressRef.current)) |
| 134 | }, []) |
| 135 | |
| 136 | const isDirty = isAddressDirty || form.formState.isDirty |
| 137 | |
| 138 | const syncCurrentState = useCallback( |
| 139 | (addressValue: StripeAddressValue, taxIdValues: TaxIdFormValues) => { |
| 140 | form.reset(taxIdValues) |
| 141 | stripeAddressRef.current = addressValue |
| 142 | stripeAddressValidationRef.current = 'unknown' |
| 143 | setIsAddressDirty(false) |
| 144 | setAddressCountry(addressValue.address.country || undefined) |
| 145 | }, |
| 146 | [form] |
| 147 | ) |
| 148 | |
| 149 | const handleSubmit = async (): Promise<BillingFormSubmitResult> => { |
| 150 | const address = stripeAddressRef.current |
| 151 | const addressWasEdited = |
| 152 | isAddressDirty || !isAddressEqual(address, savedStripeAddressRef.current) |
| 153 | |
| 154 | if (!address.name?.trim()) { |
| 155 | return { status: 'error', message: 'Full name is required.' } |
| 156 | } |
| 157 | if (!address.address.country?.trim()) { |
| 158 | return { status: 'error', message: 'Country is required.' } |
| 159 | } |
| 160 | if (!address.address.line1?.trim()) { |
| 161 | return { status: 'error', message: 'Address Line 1 is required.' } |
| 162 | } |
| 163 | if (addressWasEdited && stripeAddressValidationRef.current === 'incomplete') { |
| 164 | return { status: 'error', message: 'Please enter a valid billing address.' } |
| 165 | } |
| 166 | |
| 167 | const taxIdValues = form.getValues() |
| 168 | const selectedTaxId = TAX_IDS.find((option) => option.name === taxIdValues.tax_id_name) |
| 169 | |
| 170 | const payload = { |
| 171 | address: { |
| 172 | line1: address.address.line1.trim(), |
| 173 | line2: address.address.line2?.trim() || undefined, |
| 174 | city: address.address.city.trim(), |
| 175 | state: address.address.state?.trim() || undefined, |
| 176 | postal_code: address.address.postal_code.trim(), |
| 177 | country: address.address.country.trim(), |
| 178 | }, |
| 179 | billing_name: address.name.trim(), |
| 180 | tax_id: |
| 181 | selectedTaxId && taxIdValues.tax_id_type?.length && taxIdValues.tax_id_value?.length |
| 182 | ? { |
| 183 | type: taxIdValues.tax_id_type, |
| 184 | value: sanitizeTaxIdValue({ |
| 185 | value: taxIdValues.tax_id_value, |
| 186 | name: taxIdValues.tax_id_name, |
| 187 | }), |
| 188 | country: getEffectiveTaxCountry(selectedTaxId), |
| 189 | } |
| 190 | : null, |
| 191 | } |
| 192 | |
| 193 | await onCustomerDataChange(payload) |
| 194 | |
| 195 | return { |
| 196 | status: 'success', |
| 197 | submittedState: { addressValue: address, taxIdValues }, |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | const markCurrentValuesAsSaved = ( |
| 202 | addressValue: StripeAddressValue, |
| 203 | taxIdValues: TaxIdFormValues |
| 204 | ) => { |
| 205 | savedStripeAddressRef.current = addressValue |
| 206 | savedTaxIdValuesRef.current = taxIdValues |
| 207 | syncCurrentState(addressValue, taxIdValues) |
| 208 | } |
| 209 | |
| 210 | const handleReset = () => { |
| 211 | syncCurrentState(savedStripeAddressRef.current, savedTaxIdValuesRef.current) |
| 212 | setResetKey((c) => c + 1) |
| 213 | } |
| 214 | |
| 215 | return { |
| 216 | form, |
| 217 | handleSubmit, |
| 218 | handleReset, |
| 219 | isDirty, |
| 220 | resetKey, |
| 221 | onAddressChange, |
| 222 | applyAddressElementValue, |
| 223 | markCurrentValuesAsSaved, |
| 224 | addressCountry, |
| 225 | addressOptions, |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | function isAddressEqual(current: StripeAddressValue, original: StripeAddressValue): boolean { |
| 230 | return ( |
| 231 | normalize(current.name) === normalize(original.name) && |
| 232 | normalize(current.address.line1) === normalize(original.address.line1) && |
| 233 | normalize(current.address.line2) === normalize(original.address.line2) && |
| 234 | normalize(current.address.city) === normalize(original.address.city) && |
| 235 | normalize(current.address.state) === normalize(original.address.state) && |
| 236 | normalize(current.address.postal_code) === normalize(original.address.postal_code) && |
| 237 | normalize(current.address.country) === normalize(original.address.country) |
| 238 | ) |
| 239 | } |
| 240 | |
| 241 | function normalize(val: string | null | undefined): string { |
| 242 | return (val ?? '').trim() |
| 243 | } |