diff --git a/.changeset/checkout-final-draft-order-sync.md b/.changeset/checkout-final-draft-order-sync.md new file mode 100644 index 00000000..c4c45f2a --- /dev/null +++ b/.changeset/checkout-final-draft-order-sync.md @@ -0,0 +1,15 @@ +--- +'@godaddy/react': patch +--- + +Centralize draft-order syncing behind a registration-based sync controller and run a +single final sync before checkout confirmation. + +- Form sections (contact, phone, address, notes) now register how their current values + map to a draft-order patch instead of firing their own debounced updates. +- On confirm, checkout drains any queued sync work, diffs the current form values against + the latest backend draft order, sends at most one final update, and refetches only when + that update was sent — so in-flight edits (including name-only edits and pickup names) + are no longer lost or duplicated. +- Background sync is suppressed once confirmation starts; only the final checkout sync may + still write, and a failed final update blocks confirmation and surfaces the error. \ No newline at end of file diff --git a/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx index 22c3bbd6..aa1c8a67 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx @@ -343,7 +343,11 @@ describe('Checkout address behavior', () => { await typeIntoNamedField(user, 'shippingAddressLine1', '456 Shipping Ln'); await advanceCheckoutDebounce(); - expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Ship', lastName: 'Buyer' }, + }); + expect(getLastUpdateInput()?.shipping).not.toHaveProperty('address'); // Provide remaining fields → sync fires once with the full address. await typeIntoNamedField(user, 'shippingAdminArea2', 'Jasper'); diff --git a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx index b1a99223..1724ab89 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx @@ -354,16 +354,11 @@ describe('Checkout confirm errors', () => { }); mockGodaddyApi({ session, draftOrder }); - const { user, queryClient } = renderCheckoutWithConfirmSeam({ + const { user } = renderCheckoutWithConfirmSeam({ session, draftOrder, }); await waitForCheckoutReady(); - queryClient.setQueryDefaults(['draft-order', { sessionId: session.id }], { - retry: false, - refetchOnWindowFocus: false, - staleTime: 0, - }); setApiError('getDraftOrder', 'draft fetch failed'); clearOperations(); @@ -372,8 +367,16 @@ describe('Checkout confirm errors', () => { ); await waitForOperation('DraftOrder'); + // `useDraftOrder` keeps `retry: 3`, so the failed in-confirm fetch only + // rejects after its backoff retries are exhausted. + await waitFor( + () => { + expect(document.body).toHaveTextContent(/Failed to update order/i); + }, + { timeout: 15_000 } + ); expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); - }); + }, 20_000); it('rejects a duplicate confirm while the first confirm is in flight without treating it as success', async () => { const draftOrder = buildDraftOrder({ diff --git a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx index e62bbeb9..799a1054 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx @@ -1,5 +1,6 @@ import { screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { @@ -11,6 +12,8 @@ import { flushPromises, getCurrentDraftOrder, getNamedInput, + getOperationNames, + getOperationOrder, getOperations, renderCheckout, setApiError, @@ -443,6 +446,45 @@ describe('Checkout draft-order field sync', () => { expect(getLastUpdateInput()?.billing).not.toHaveProperty('lastName'); }); + it('keeps a partial shipping name dirty after address-only sync refetches the order', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: '', + lastName: '', + address: buildShippingAddress({ + addressLine1: '', + addressLine2: '', + adminArea1: 'GA', + adminArea2: '', + postalCode: '', + countryCode: 'US', + }), + }, + billing: { + firstName: '', + lastName: '', + address: null, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Partial'); + await typeIntoNamedField(user, 'shippingAddressLine1', '456 Shipping Ln'); + await typeIntoNamedField(user, 'shippingAdminArea2', 'Jasper'); + await typeIntoNamedField(user, 'shippingPostalCode', '30143'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + expect(getLastUpdateInput()?.shipping).not.toHaveProperty('firstName'); + await waitForOperation('DraftOrder'); + + await waitFor(() => { + expect(getNamedInput('shippingFirstName')).toHaveValue('Partial'); + }); + }); + it('syncs a complete billing address without requiring first or last name', async () => { const { user } = renderCheckout({ draftOrderOverrides: { @@ -528,6 +570,31 @@ describe('Checkout draft-order field sync', () => { expect(getLastUpdateInput()).toMatchObject({ notes: null }); }); + it('does not clear order notes while a custom required notes field is empty', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + notes: [{ authorType: 'CUSTOMER', content: 'Leave at door' }], + }, + checkoutProps: { + checkoutFormSchema: { + notes: z.string().min(1, 'notes are required'), + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const notes = document.querySelector( + 'textarea[name="notes"]' + ); + expect(notes).toBeTruthy(); + await user.clear(notes as HTMLTextAreaElement); + await advanceCheckoutDebounce(); + await flushPromises(); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + }); + it('syncs names-only billing without stale address fields', async () => { const { user } = renderCheckout({ draftOrderOverrides: { @@ -614,6 +681,232 @@ describe('Checkout draft-order field sync', () => { }); }); + it('syncs current notes before immediate offline confirmation', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const notes = document.querySelector( + 'textarea[name="notes"]' + ); + expect(notes).toBeTruthy(); + await user.clear(notes as HTMLTextAreaElement); + await user.type(notes as HTMLTextAreaElement, 'Race note'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + const [updateIdx, confirmIdx] = getOperationOrder([ + 'UpdateCheckoutSessionDraftOrder', + 'ConfirmCheckoutSession', + ]); + expect(updateIdx).toBeGreaterThanOrEqual(0); + expect(confirmIdx).toBeGreaterThan(updateIdx); + expect(getLastUpdateInput()).toMatchObject({ + notes: [{ authorType: 'CUSTOMER', content: 'Race note' }], + }); + }); + + it('syncs current shipping name before immediate offline confirmation', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Race'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + const [updateIdx, confirmIdx] = getOperationOrder([ + 'UpdateCheckoutSessionDraftOrder', + 'ConfirmCheckoutSession', + ]); + expect(updateIdx).toBeGreaterThanOrEqual(0); + expect(confirmIdx).toBeGreaterThan(updateIdx); + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Race', lastName: 'Buyer' }, + billing: { firstName: 'Race', lastName: 'Buyer' }, + }); + }); + + it('syncs names-only billing edits before immediate free-pickup confirmation', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: '', + lastName: '', + phone: '', + email: 'jane@example.com', + address: null, + }, + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + // No debounce advance: the names are only in the form when Pay is clicked. + await typeIntoNamedField(user, 'billingFirstName', 'Pickup'); + await typeIntoNamedField(user, 'billingLastName', 'Person'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + const [updateIndex, confirmIndex] = getOperationOrder([ + 'UpdateCheckoutSessionDraftOrder', + 'ConfirmCheckoutSession', + ]); + expect(updateIndex).toBeGreaterThanOrEqual(0); + expect(confirmIndex).toBeGreaterThan(updateIndex); + expect(getLastUpdateInput()).toMatchObject({ + billing: { firstName: 'Pickup', lastName: 'Person' }, + }); + }); + + it('disables form edits while the final sync runs', async () => { + const { user } = renderCheckout({ + apiOverrides: { delayMs: 100 }, + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + const payButton = await screen.findByRole('button', { + name: /complete your order/i, + }); + await waitFor(() => { + expect(payButton).not.toBeDisabled(); + }); + + await typeIntoNamedField(user, 'shippingFirstName', 'Locked'); + const click = user.click(payButton); + + await waitFor(() => { + expect(getNamedInput('shippingFirstName')).toBeDisabled(); + }); + + await click; + await waitForOperation('ConfirmCheckoutSession'); + }); + + it('refetches the draft order after the final sync patch and before confirming', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Refetch'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + const operations = getOperationNames(); + const updateIndex = operations.indexOf('UpdateCheckoutSessionDraftOrder'); + const confirmIndex = operations.indexOf('ConfirmCheckoutSession'); + const refetchIndex = operations.findIndex( + (name, index) => name === 'DraftOrder' && index > updateIndex + ); + + expect(updateIndex).toBeGreaterThanOrEqual(0); + expect(refetchIndex).toBeGreaterThan(updateIndex); + expect(confirmIndex).toBeGreaterThan(refetchIndex); + }); + + it('does not send a final sync patch when the form already matches the order', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + }); + + it('blocks confirmation and surfaces a sync error when the final sync patch fails', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + setApiError('updateDraftOrder', new Error('update failed')); + + await typeIntoNamedField(user, 'shippingFirstName', 'Broken'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + await waitFor(() => { + expect(document.body).toHaveTextContent(/Failed to update order/i); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('does not sync an invalid email and syncs it once corrected', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await typeIntoNamedField(user, 'contactEmail', 'not-an-email'); + await advanceCheckoutDebounce(); + await flushPromises(); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + + await typeIntoNamedField(user, 'contactEmail', 'valid@example.com'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: { email: 'valid@example.com' }, + billing: { email: 'valid@example.com' }, + }); + expect( + getOperations('UpdateCheckoutSessionDraftOrder').map(operation => + JSON.stringify(operation.input) + ) + ).not.toContain(expect.stringContaining('not-an-email')); + }); + it('resetField after a successful sync makes the typed value pristine for later refetches', async () => { const { user, queryClient, session } = renderCheckout({ draftOrderOverrides: { shipping: { firstName: '' } }, diff --git a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx index fa377efc..455da15f 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx @@ -2,6 +2,7 @@ import { enUs } from '@godaddy/localizations'; import { screen, waitFor } from '@testing-library/react'; import { useFormContext } from 'react-hook-form'; import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { PaymentMethodType, PaymentProvider } from '@/types'; import { buildDraftOrder, @@ -163,6 +164,319 @@ describe('Checkout form validation', () => { expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); }); + it('enforces a custom checkoutFormSchema rule on a field free pickup validation skips', async () => { + const customMessage = 'Phone number is required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + phone: '', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + billingPhone: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(customMessage); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + + await user.type(screen.getByLabelText(/phone/i), '4805551234'); + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + }); + + it.each([false, null])( + 'does not enforce custom phone rules when phone collection is %s', + async enablePhoneCollection => { + const customMessage = 'Phone number is required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + phone: '', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + billingPhone: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + enablePhoneCollection, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect(screen.queryByLabelText(/phone/i)).not.toBeInTheDocument(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + expect(document.body).not.toHaveTextContent(customMessage); + } + ); + + it('enforces custom notes rules when notes collection is enabled', async () => { + const customMessage = 'notes are required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + notes: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: { + ...stripeOnlyPaymentMethods(), + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: ['standard'], + }, + }, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + enableNotesCollection: true, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const notes = document.querySelector( + 'textarea[name="notes"]' + ); + expect(notes).toBeInTheDocument(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(customMessage); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + + await user.type(notes as HTMLTextAreaElement, 'Please include utensils'); + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + }); + + it('shows a custom notes validation error when existing notes are deleted before submit', async () => { + const customMessage = 'notes are required'; + const draftOrder = makeFreePickupOrder({ + notes: [{ authorType: 'CUSTOMER', content: 'Existing note' }], + billing: { + firstName: 'Pat', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + notes: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: { + ...stripeOnlyPaymentMethods(), + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: ['standard'], + }, + }, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + enableNotesCollection: true, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const notes = document.querySelector( + 'textarea[name="notes"]' + ); + expect(notes).toHaveValue('Existing note'); + + await user.clear(notes as HTMLTextAreaElement); + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(customMessage); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('does not enforce custom notes rules when notes collection is not enabled', async () => { + const customMessage = 'Notes are required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + notes: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + enableNotesCollection: null, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect(document.querySelector('textarea[name="notes"]')).toBeNull(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + expect(document.body).not.toHaveTextContent(customMessage); + }); + + it('does not enforce custom shipping rules when pickup is selected', async () => { + const customMessage = 'Shipping field is required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + shippingAddressLine2: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: true, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect( + document.querySelector('input[name="shippingAddressLine2"]') + ).not.toBeInTheDocument(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + expect(document.body).not.toHaveTextContent(customMessage); + }); + + it('does not enforce custom billing address rules when shipping address is reused', async () => { + const customMessage = 'Billing address line 2 is required'; + const sharedAddress = buildShippingAddress({ addressLine2: '' }); + const draftOrder = buildDraftOrder({ + shipping: { + firstName: 'Jane', + lastName: 'Buyer', + email: 'jane@example.com', + phone: '+12015550123', + address: sharedAddress, + }, + billing: { + firstName: 'Jane', + lastName: 'Buyer', + email: 'jane@example.com', + phone: '+12015550123', + address: sharedAddress, + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + billingAddressLine2: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect( + document.querySelector('input[name="billingAddressLine2"]') + ).not.toBeInTheDocument(); + + await user.click(await clickSubmitButton(/pay now/i)); + + await waitFor(() => { + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(1); + }); + expect(document.body).not.toHaveTextContent(customMessage); + }); + it('pins current paid pickup card behavior when the billing address line is empty', async () => { const draftOrder = makePaidPickupOrder(); const { user } = renderCheckout({ diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index dcc66fda..3258b588 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -12,10 +12,16 @@ import { import { isAddressComplete } from '@/components/checkout/address/utils/is-address-complete'; import { mapAddressFieldsToInput } from '@/components/checkout/address/utils/map-address-fields-to-input'; import { useAddressMatches } from '@/components/checkout/address/utils/use-address-matches'; -import { useCheckoutContext } from '@/components/checkout/checkout'; +import { + type CheckoutFormData, + useCheckoutContext, +} from '@/components/checkout/checkout'; import { PhoneInput } from '@/components/checkout/contact/phone-input'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderFieldSync } from '@/components/checkout/order/use-draft-order-sync'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { AutoComplete } from '@/components/ui/autocomplete'; import { Button } from '@/components/ui/button'; import { @@ -50,10 +56,12 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { cn } from '@/lib/utils'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; -import type { Address } from '@/types'; +import type { Address, DraftOrder } from '@/types'; + +type SectionKey = 'shipping' | 'billing'; interface AddressFormProps { - sectionKey: string; + sectionKey: SectionKey; /** When true, only show first name and last name fields (used for free pickup orders) */ onlyNames?: boolean; } @@ -70,11 +78,101 @@ export function mapAutocompleteAddressFields(selectedAddress?: Address) { } satisfies Record; } +const addressFieldSuffixes = [ + 'AddressLine1', + 'AddressLine2', + 'AddressLine3', + 'AdminArea4', + 'AdminArea3', + 'AdminArea2', + 'AdminArea1', + 'PostalCode', + 'CountryCode', +] as const; + +function getFormString(values: CheckoutFormData, fieldName: string) { + return String(values[fieldName as keyof CheckoutFormData] ?? ''); +} + +function getSectionAddress(values: CheckoutFormData, sectionKey: SectionKey) { + return { + addressLine1: getFormString(values, `${sectionKey}AddressLine1`), + addressLine2: getFormString(values, `${sectionKey}AddressLine2`), + addressLine3: getFormString(values, `${sectionKey}AddressLine3`), + adminArea4: getFormString(values, `${sectionKey}AdminArea4`), + adminArea3: getFormString(values, `${sectionKey}AdminArea3`), + adminArea2: getFormString(values, `${sectionKey}AdminArea2`), + adminArea1: getFormString(values, `${sectionKey}AdminArea1`), + postalCode: getFormString(values, `${sectionKey}PostalCode`), + countryCode: getFormString(values, `${sectionKey}CountryCode`), + }; +} + +function getDraftOrderSection( + draftOrder: DraftOrder | null | undefined, + sectionKey: SectionKey +) { + return sectionKey === 'shipping' ? draftOrder?.shipping : draftOrder?.billing; +} + +function getDraftOrderAddress( + draftOrder: DraftOrder | null | undefined, + sectionKey: SectionKey +) { + const section = getDraftOrderSection(draftOrder, sectionKey); + return { + addressLine1: section?.address?.addressLine1 || '', + addressLine2: section?.address?.addressLine2 || '', + addressLine3: section?.address?.addressLine3 || '', + adminArea4: section?.address?.adminArea4 || '', + adminArea3: section?.address?.adminArea3 || '', + adminArea2: section?.address?.adminArea2 || '', + adminArea1: section?.address?.adminArea1 || '', + postalCode: section?.address?.postalCode || '', + countryCode: section?.address?.countryCode || '', + }; +} + +function sectionNameHasChanged( + values: CheckoutFormData, + draftOrder: DraftOrder | null | undefined, + sectionKey: SectionKey +) { + const section = getDraftOrderSection(draftOrder, sectionKey); + return ( + (section?.firstName || '') !== + getFormString(values, `${sectionKey}FirstName`) || + (section?.lastName || '') !== getFormString(values, `${sectionKey}LastName`) + ); +} + +function sectionAddressHasChanged( + values: CheckoutFormData, + draftOrder: DraftOrder | null | undefined, + sectionKey: SectionKey +) { + if (!draftOrder) return false; + + const orderAddress = getDraftOrderAddress(draftOrder, sectionKey); + const formAddress = getSectionAddress(values, sectionKey); + const orderSection = getDraftOrderSection(draftOrder, sectionKey); + + if (!orderSection?.address) { + return Object.entries(formAddress).some( + ([key, value]) => key !== 'countryCode' && Boolean(value.trim()) + ); + } + + return Object.entries(orderAddress).some( + ([key, value]) => value !== formAddress[key as keyof typeof formAddress] + ); +} + export function AddressForm({ sectionKey, onlyNames = false, }: AddressFormProps) { - const form = useFormContext(); + const form = useFormContext(); const { session } = useCheckoutContext(); const { t } = useGoDaddyContext(); const { isConfirmingCheckout, requiredFields } = useCheckoutContext(); @@ -98,216 +196,167 @@ export function AddressForm({ return () => window.removeEventListener('resize', updateWidth); }, []); - const addressValue = form.watch(`${sectionKey}AddressLine1`); - const countryValue = form.watch(`${sectionKey}CountryCode`); - const useShippingAddress = form.watch('paymentUseShippingAddress'); - - const [ - firstName, - lastName, - addressLine1, - addressLine2, - addressLine3, - adminArea1, - adminArea2, - adminArea3, - adminArea4, - postalCode, - countryCode, - ] = form.watch([ - `${sectionKey}FirstName`, - `${sectionKey}LastName`, + const [addressValue, countryValue] = form.watch([ `${sectionKey}AddressLine1`, - `${sectionKey}AddressLine2`, - `${sectionKey}AddressLine3`, - `${sectionKey}AdminArea1`, - `${sectionKey}AdminArea2`, - `${sectionKey}AdminArea3`, - `${sectionKey}AdminArea4`, - `${sectionKey}PostalCode`, `${sectionKey}CountryCode`, ]); - const contact = React.useMemo( - () => ({ firstName, lastName }), - [firstName, lastName] - ); - const serializedContact = React.useMemo( - () => JSON.stringify(contact), - [contact] - ); - - const [debouncedContact] = useDebouncedValue(serializedContact, { - wait: 1000, - }); - const [debouncedAddressValue] = useDebouncedValue(addressValue, { wait: 200, }); - // Check if name values differ from order values - const nameHasChanged = React.useMemo(() => { - if (!draftOrder) return true; // If no order, allow sync - const section = - sectionKey === 'shipping' ? draftOrder.shipping : draftOrder.billing; - - return ( - (section?.firstName || '') !== (firstName || '') || - (section?.lastName || '') !== (lastName || '') - ); - }, [draftOrder, sectionKey, firstName, lastName]); - - const shouldVerifyName = - onlyNames && - nameHasChanged && // Only sync if values differ from order - !!firstName?.trim() && - !!lastName?.trim() && - debouncedContact === serializedContact; - - useDraftOrderFieldSync({ - key: 'name', - data: contact, - deps: [contact, serializedContact, debouncedContact], - enabled: shouldVerifyName, - fieldNames: [`${sectionKey}FirstName`, `${sectionKey}LastName`], - preserveFormData: false, - mapToInput: data => { - const fields = { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - address: null, - }; - - return mapAddressFieldsToInput( - fields, - sectionKey as 'shipping' | 'billing', - useShippingAddress - ); - }, - }); - - const address = React.useMemo( - () => ({ - addressLine1, - addressLine2, - addressLine3, - adminArea1, - adminArea2, - adminArea3, - adminArea4, - postalCode, - countryCode, - }), - [ - addressLine1, - addressLine2, - addressLine3, - adminArea1, - adminArea2, - adminArea3, - adminArea4, - postalCode, - countryCode, - ] + const nameFieldNames = React.useMemo( + () => [`${sectionKey}FirstName`, `${sectionKey}LastName`], + [sectionKey] ); - - const sectionContactAndAddress = React.useMemo( - () => ({ - ...contact, - address, - }), - [contact, address] + const allAddressFieldNames = React.useMemo( + () => addressFieldSuffixes.map(suffix => `${sectionKey}${suffix}`), + [sectionKey] ); - const serializedSectionContactAndAddress = React.useMemo( - () => JSON.stringify(sectionContactAndAddress), - [sectionContactAndAddress] + const addressSyncDependencyFieldNames = React.useMemo( + () => ['paymentUseShippingAddress'], + [] ); - const [debouncedSectionContactAndAddress] = useDebouncedValue( - serializedSectionContactAndAddress, - { wait: 1000 } + const orderAddress = React.useMemo( + () => getDraftOrderAddress(draftOrder, sectionKey), + [draftOrder, sectionKey] ); - // Get existing order address data for comparison - const orderAddress = React.useMemo(() => { - if (!draftOrder) return null; - const section = - sectionKey === 'shipping' ? draftOrder.shipping : draftOrder.billing; - return section - ? { - addressLine1: section?.address?.addressLine1 || '', - addressLine2: section?.address?.addressLine2 || '', - addressLine3: section?.address?.addressLine3 || '', - adminArea1: section?.address?.adminArea1 || '', - adminArea2: section?.address?.adminArea2 || '', - adminArea3: section?.address?.adminArea3 || '', - adminArea4: section?.address?.adminArea4 || '', - postalCode: section?.address?.postalCode || '', - countryCode: section?.address?.countryCode || '', - } - : null; - }, [draftOrder, sectionKey]); - - // Check if current form values differ from order values - const addressHasChanged = React.useMemo(() => { - if (!orderAddress) return true; // If no order address, allow sync - - return ( - orderAddress.addressLine1 !== (addressLine1 || '') || - orderAddress.addressLine2 !== (addressLine2 || '') || - orderAddress.addressLine3 !== (addressLine3 || '') || - orderAddress.adminArea1 !== (adminArea1 || '') || - orderAddress.adminArea2 !== (adminArea2 || '') || - orderAddress.adminArea3 !== (adminArea3 || '') || - orderAddress.adminArea4 !== (adminArea4 || '') || - orderAddress.postalCode !== (postalCode || '') || - orderAddress.countryCode !== (countryCode || '') - ); - }, [ - orderAddress, - addressLine1, - addressLine2, - addressLine3, - adminArea1, - adminArea2, - adminArea3, - adminArea4, - postalCode, - countryCode, - ]); + const addressLine1HasChanged = React.useMemo( + () => + Boolean(draftOrder && orderAddress.addressLine1 !== (addressValue || '')), + [draftOrder, orderAddress, addressValue] + ); - const addressLine1HasChanged = React.useMemo(() => { - if (!orderAddress) return true; + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: `${sectionKey}-names-only`, + fieldNames: nameFieldNames, + dependencyFieldNames: addressSyncDependencyFieldNames, + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => + Boolean( + onlyNames && + sectionNameHasChanged(values, currentDraftOrder, sectionKey) && + getFormString(values, `${sectionKey}FirstName`).trim() && + getFormString(values, `${sectionKey}LastName`).trim() + ), + buildPatch: ({ values }) => + mapAddressFieldsToInput( + { + firstName: getFormString(values, `${sectionKey}FirstName`).trim(), + lastName: getFormString(values, `${sectionKey}LastName`).trim(), + address: null, + }, + sectionKey, + Boolean(values.paymentUseShippingAddress) + ), + }), + [addressSyncDependencyFieldNames, nameFieldNames, onlyNames, sectionKey] + ) + ); - return orderAddress.addressLine1 !== (addressLine1 || ''); - }, [orderAddress, addressLine1]); + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: `${sectionKey}-name`, + fieldNames: nameFieldNames, + dependencyFieldNames: addressSyncDependencyFieldNames, + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => + Boolean( + !onlyNames && + sectionNameHasChanged(values, currentDraftOrder, sectionKey) && + (sectionKey === 'shipping' || + !sectionAddressHasChanged( + values, + currentDraftOrder, + sectionKey + )) && + getFormString(values, `${sectionKey}FirstName`).trim() && + getFormString(values, `${sectionKey}LastName`).trim() + ), + buildPatch: ({ values }) => + mapAddressFieldsToInput( + { + firstName: getFormString(values, `${sectionKey}FirstName`).trim(), + lastName: getFormString(values, `${sectionKey}LastName`).trim(), + }, + sectionKey, + Boolean(values.paymentUseShippingAddress) + ), + }), + [addressSyncDependencyFieldNames, nameFieldNames, onlyNames, sectionKey] + ) + ); - const shouldUpdateNameOnly = Boolean( - nameHasChanged && - !addressHasChanged && - !!firstName?.trim() && - !!lastName?.trim() && - debouncedContact === serializedContact + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: `${sectionKey}-address`, + fieldNames: allAddressFieldNames, + dependencyFieldNames: addressSyncDependencyFieldNames, + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => + Boolean( + !onlyNames && + sectionAddressHasChanged(values, currentDraftOrder, sectionKey) && + isAddressComplete(getSectionAddress(values, sectionKey)) && + !isAutocompleteOpen + ), + buildPatch: ({ values }) => { + const hasCompleteName = Boolean( + getFormString(values, `${sectionKey}FirstName`).trim() && + getFormString(values, `${sectionKey}LastName`).trim() + ); + + return mapAddressFieldsToInput( + { + ...(hasCompleteName + ? { + firstName: getFormString( + values, + `${sectionKey}FirstName` + ).trim(), + lastName: getFormString( + values, + `${sectionKey}LastName` + ).trim(), + } + : {}), + address: getSectionAddress(values, sectionKey), + }, + sectionKey, + Boolean(values.paymentUseShippingAddress) + ); + }, + }), + [ + addressSyncDependencyFieldNames, + allAddressFieldNames, + isAutocompleteOpen, + onlyNames, + sectionKey, + ] + ) ); - useDraftOrderFieldSync({ - key: 'name', - data: contact, - deps: [contact, serializedContact, debouncedContact, addressHasChanged], - enabled: !onlyNames && shouldUpdateNameOnly, - fieldNames: [`${sectionKey}FirstName`, `${sectionKey}LastName`], - mapToInput: data => { - const fields = { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - }; - - return mapAddressFieldsToInput( - fields, - sectionKey as 'shipping' | 'billing', - useShippingAddress - ); - }, + useDraftOrderFieldDirtyMarker({ + id: `${sectionKey}-names-only`, + fieldNames: nameFieldNames, + disabled: !onlyNames || isConfirmingCheckout, + }); + useDraftOrderFieldDirtyMarker({ + id: `${sectionKey}-name`, + fieldNames: nameFieldNames, + disabled: onlyNames || isConfirmingCheckout, + }); + useDraftOrderFieldDirtyMarker({ + id: `${sectionKey}-address`, + fieldNames: allAddressFieldNames, + disabled: onlyNames || isConfirmingCheckout, }); const addressMatchesQuery = useAddressMatches(debouncedAddressValue, { @@ -324,8 +373,9 @@ export function AddressForm({ for (const [key, value] of Object.entries( mapAutocompleteAddressFields(selectedAddress) )) { - if (value && form.getValues(`${sectionKey}${key}`) !== value) { - form.setValue(`${sectionKey}${key}`, value, { + const fieldName = `${sectionKey}${key}` as keyof CheckoutFormData; + if (value && form.getValues(fieldName) !== value) { + form.setValue(fieldName, value, { shouldDirty: true, shouldValidate: true, }); @@ -333,60 +383,6 @@ export function AddressForm({ } } - const shouldUpdateAddress = Boolean( - addressHasChanged && // Only sync if address values differ from order - isAddressComplete(address) && - debouncedSectionContactAndAddress === - serializedSectionContactAndAddress && - !isAutocompleteOpen - ); - - const hasCompleteName = Boolean(firstName?.trim() && lastName?.trim()); - const addressSyncFieldNames = React.useMemo( - () => [ - ...(hasCompleteName - ? [`${sectionKey}FirstName`, `${sectionKey}LastName`] - : []), - `${sectionKey}AddressLine1`, - `${sectionKey}AddressLine2`, - `${sectionKey}AdminArea2`, - `${sectionKey}AdminArea1`, - `${sectionKey}PostalCode`, - `${sectionKey}CountryCode`, - ], - [hasCompleteName, sectionKey] - ); - - useDraftOrderFieldSync({ - key: 'address', - data: sectionContactAndAddress, - deps: [ - sectionContactAndAddress, - shouldUpdateAddress, - serializedSectionContactAndAddress, - debouncedSectionContactAndAddress, - ], - enabled: !onlyNames && shouldUpdateAddress, - fieldNames: addressSyncFieldNames, - mapToInput: data => { - const fields = { - ...(hasCompleteName - ? { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - } - : {}), - address: data.address, - }; - - return mapAddressFieldsToInput( - fields, - sectionKey as 'shipping' | 'billing', - useShippingAddress - ); - }, - }); - return (
{!onlyNames && ( diff --git a/packages/react/src/components/checkout/address/utils/check-is-valid-phone.ts b/packages/react/src/components/checkout/address/utils/check-is-valid-phone.ts index e0ea8a57..d476e252 100644 --- a/packages/react/src/components/checkout/address/utils/check-is-valid-phone.ts +++ b/packages/react/src/components/checkout/address/utils/check-is-valid-phone.ts @@ -1,7 +1,8 @@ import { isPossiblePhoneNumber } from 'react-phone-number-input'; export function checkIsValidPhone(phoneNumber: string): boolean { - if (!phoneNumber) return false; + const trimmed = phoneNumber?.trim(); + if (!trimmed) return false; - return isPossiblePhoneNumber(phoneNumber); + return isPossiblePhoneNumber(trimmed); } diff --git a/packages/react/src/components/checkout/address/utils/use-clear-billing-address.test.tsx b/packages/react/src/components/checkout/address/utils/use-clear-billing-address.test.tsx index f1882abd..b36babb1 100644 --- a/packages/react/src/components/checkout/address/utils/use-clear-billing-address.test.tsx +++ b/packages/react/src/components/checkout/address/utils/use-clear-billing-address.test.tsx @@ -7,7 +7,14 @@ import { type CheckoutFormData, checkoutContext, } from '@/components/checkout/checkout'; -import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { + DraftOrderSyncProvider, + type DraftOrderSyncRegistration, +} from '@/components/checkout/order/draft-order-sync-provider'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GoDaddyProvider } from '@/godaddy-provider'; @@ -20,11 +27,54 @@ import { waitForOperation, } from '../../__tests__/checkout-test-env'; +function ShippingSyncProbe() { + const registration = React.useMemo( + () => ({ + id: 'shipping-name', + fieldNames: ['shippingFirstName', 'shippingLastName'], + debounceMs: 1000, + enabled: ({ values, draftOrder }) => + Boolean( + draftOrder && + values.shippingFirstName?.trim() && + values.shippingLastName?.trim() && + ((draftOrder.shipping?.firstName || '') !== + values.shippingFirstName || + (draftOrder.shipping?.lastName || '') !== values.shippingLastName) + ), + buildPatch: ({ values }) => ({ + shipping: { + firstName: values.shippingFirstName.trim(), + lastName: values.shippingLastName.trim(), + }, + }), + }), + [] + ); + + useRegisterDraftOrderFieldSync(registration); + useDraftOrderFieldDirtyMarker({ + id: 'shipping-name', + fieldNames: ['shippingFirstName', 'shippingLastName'], + }); + + return null; +} + function BillingProbe() { const form = useFormContext(); return ( <> + + + {[ 'billingFirstName', @@ -52,12 +102,26 @@ function BillingProbe() { function ClearBillingHarness({ session, + paymentUseShippingAddress = true, }: { session: ReturnType; + paymentUseShippingAddress?: boolean; }) { const form = useForm({ defaultValues: { - paymentUseShippingAddress: true, + paymentUseShippingAddress, + shippingFirstName: 'Ship', + shippingLastName: 'Buyer', + shippingPhone: '+12015550123', + shippingAddressLine1: '123 Shipping St', + shippingAddressLine2: 'Unit 4', + shippingAddressLine3: 'Floor 2', + shippingAdminArea4: 'Neighborhood', + shippingAdminArea3: 'District', + shippingAdminArea2: 'Jasper', + shippingAdminArea1: 'GA', + shippingPostalCode: '30143', + shippingCountryCode: 'US', billingFirstName: 'Bill', billingLastName: 'Buyer', billingPhone: '+12015550123', @@ -93,7 +157,11 @@ function ClearBillingHarness({ ); } -function renderClearBillingHarness() { +function renderClearBillingHarness({ + paymentUseShippingAddress = true, +}: { + paymentUseShippingAddress?: boolean; +} = {}) { const draftOrder = buildDraftOrder(); const session = buildCheckoutSession({ draftOrder }); const queryClient = createTestQueryClient(); @@ -105,7 +173,10 @@ function renderClearBillingHarness() { render( - + ); } @@ -148,4 +219,56 @@ describe('useClearBillingAddress', () => { }); }); }); + + it('flushes pending shipping edits with the billing clear when toggling off use-shipping', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderClearBillingHarness(); + + await user.clear(screen.getByLabelText('shippingFirstName')); + await user.type(screen.getByLabelText('shippingFirstName'), 'Updated'); + + const toggle = screen.getByRole('checkbox', { + name: /use shipping address/i, + }); + expect(toggle).toBeChecked(); + + await user.click(toggle); + + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + await waitFor(() => { + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + }); + expect( + getOperations('UpdateCheckoutSessionDraftOrder')[0].input + ).toMatchObject({ + shipping: { firstName: 'Updated', lastName: 'Buyer' }, + billing: null, + }); + }); + + it('flushes pending shipping edits with the billing copy when toggling on use-shipping', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderClearBillingHarness({ paymentUseShippingAddress: false }); + + await user.clear(screen.getByLabelText('shippingFirstName')); + await user.type(screen.getByLabelText('shippingFirstName'), 'Updated'); + + const toggle = screen.getByRole('checkbox', { + name: /use shipping address/i, + }); + expect(toggle).not.toBeChecked(); + + await user.click(toggle); + + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + await waitFor(() => { + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + }); + expect( + getOperations('UpdateCheckoutSessionDraftOrder')[0].input + ).toMatchObject({ + shipping: { firstName: 'Updated', lastName: 'Buyer' }, + billing: { firstName: 'Updated', lastName: 'Buyer' }, + }); + }); }); diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index a66fb390..6bae0493 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -79,6 +79,7 @@ export type SquareConfig = { export type PayPalConfig = { clientId: string; + merchantId?: string; disableFunding?: Array<'credit' | 'card' | 'paylater' | 'venmo'>; }; @@ -110,6 +111,12 @@ interface CheckoutContextValue { checkoutErrors?: string[] | undefined; setCheckoutErrors: (error?: string[] | undefined) => void; requiredFields?: { [key: string]: boolean }; + /** + * Field names supplied through the `checkoutFormSchema` prop. Consumer rules + * must always be validated, even when the built-in conditional validation + * would skip that field for the current delivery/payment combination. + */ + customSchemaFields?: string[]; } export const checkoutContext = React.createContext({ @@ -410,6 +417,10 @@ export function Checkout(props: CheckoutProps) { return getRequiredFieldsFromSchema(formSchema); }, [formSchema]); + const customSchemaFields = React.useMemo(() => { + return Object.keys(checkoutFormSchema ?? {}); + }, [checkoutFormSchema]); + if (!props.isLoading && !isLoadingJWT && !session) { return (
@@ -459,6 +470,7 @@ export function Checkout(props: CheckoutProps) { paypalConfig, ccavenueConfig, requiredFields, + customSchemaFields, isConfirmingCheckout, setIsConfirmingCheckout, checkoutErrors, diff --git a/packages/react/src/components/checkout/contact/contact-form.tsx b/packages/react/src/components/checkout/contact/contact-form.tsx index 5e1102cb..dddeb505 100644 --- a/packages/react/src/components/checkout/contact/contact-form.tsx +++ b/packages/react/src/components/checkout/contact/contact-form.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useDebouncedValue } from '@tanstack/react-pacer'; import { useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderFieldSync } from '@/components/checkout/order/use-draft-order-sync'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { FormControl, FormField, @@ -22,58 +23,43 @@ export function ContactForm() { const form = useFormContext(); const { t } = useGoDaddyContext(); const { isConfirmingCheckout, requiredFields } = useCheckoutContext(); - const { data: draftOrder } = useDraftOrder(); - - const contactEmail = form.watch('contactEmail'); - - // Check if email values differ from order values - const emailHasChanged = useMemo(() => { - if (!draftOrder) return true; // If no order, allow sync - const shippingEmailMissing = !draftOrder?.shipping?.email; - const billingEmailMissing = !draftOrder?.billing?.email; + useRegisterDraftOrderFieldSync( + useMemo( + () => ({ + id: 'contact-email', + fieldNames: ['contactEmail'], + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => { + const email = values.contactEmail?.trim(); + if (!currentDraftOrder || !email) return false; - const shippingIsDifferent = draftOrder?.shipping?.email !== contactEmail; - const billingIsDifferent = draftOrder?.billing?.email !== contactEmail; + return ( + currentDraftOrder.shipping?.email !== email || + currentDraftOrder.billing?.email !== email + ); + }, + buildPatch: ({ values, draftOrder: currentDraftOrder }) => { + const email = values.contactEmail?.trim(); + if (!email || !currentDraftOrder) return null; - return ( - !!contactEmail?.trim() && - (shippingEmailMissing || - billingEmailMissing || - shippingIsDifferent || - billingIsDifferent) - ); - }, [draftOrder, contactEmail]); - - const [email] = useDebouncedValue(contactEmail, { - wait: 1000, - }); - - useDraftOrderFieldSync({ - key: 'email', - data: email, - deps: [email, emailHasChanged, draftOrder], - enabled: - emailHasChanged && - email?.trim() && - email === contactEmail && - !!draftOrder, + return { + ...(currentDraftOrder.shipping?.email !== email + ? { shipping: { email } } + : {}), + ...(currentDraftOrder.billing?.email !== email + ? { billing: { email } } + : {}), + }; + }, + }), + [] + ) + ); + useDraftOrderFieldDirtyMarker({ + id: 'contact-email', fieldNames: ['contactEmail'], - mapToInput: emailValue => { - if (!draftOrder) return {}; - - const shippingIsDifferent = draftOrder?.shipping?.email !== emailValue; - const billingIsDifferent = draftOrder?.billing?.email !== emailValue; - - return { - ...(shippingIsDifferent - ? { shipping: { email: emailValue?.trim() } } - : {}), - ...(billingIsDifferent - ? { billing: { email: emailValue?.trim() } } - : {}), - }; - }, + disabled: isConfirmingCheckout, }); return ( diff --git a/packages/react/src/components/checkout/contact/phone-input.tsx b/packages/react/src/components/checkout/contact/phone-input.tsx index 35b44446..7bf2e248 100644 --- a/packages/react/src/components/checkout/contact/phone-input.tsx +++ b/packages/react/src/components/checkout/contact/phone-input.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useDebouncedValue } from '@tanstack/react-pacer'; import { CheckIcon, ChevronsUpDown } from 'lucide-react'; import React from 'react'; import { useFormContext } from 'react-hook-form'; @@ -10,7 +9,10 @@ import { checkIsValidPhone } from '@/components/checkout/address/utils/check-is- import { mapAddressFieldsToInput } from '@/components/checkout/address/utils/map-address-fields-to-input'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderFieldSync } from '@/components/checkout/order/use-draft-order-sync'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { Button } from '@/components/ui/button'; import { Command, @@ -212,13 +214,6 @@ export function PhoneInput({ const { session, requiredFields } = useCheckoutContext(); const { data: draftOrder } = useDraftOrder(); - const phoneValue = form.watch(`${sectionKey}Phone`); - const useShippingAddress = form.watch('paymentUseShippingAddress'); - - const [phone] = useDebouncedValue(phoneValue, { - wait: 1000, - }); - const section = sectionKey === 'shipping' ? draftOrder?.shipping : draftOrder?.billing; @@ -227,33 +222,59 @@ export function PhoneInput({ session?.shipping?.originAddress?.countryCode || 'US'; - const isValidPhone = React.useMemo(() => checkIsValidPhone(phone), [phone]); + const phoneFieldName = `${sectionKey}Phone`; + const registrationId = `${sectionKey}-phone`; + + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: registrationId, + fieldNames: [phoneFieldName], + dependencyFieldNames: ['paymentUseShippingAddress'], + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => { + if (!session?.enablePhoneCollection || !currentDraftOrder) { + return false; + } - // Check if phone value differs from order value - const phoneHasChanged = React.useMemo(() => { - if (!draftOrder) return true; // If no order, allow sync - const orderSection = - sectionKey === 'shipping' ? draftOrder.shipping : draftOrder.billing; - return (orderSection?.phone || '') !== (phone || ''); - }, [draftOrder, sectionKey, phone]); + const phone = String( + values[phoneFieldName as keyof typeof values] ?? '' + ); + const orderSection = + sectionKey === 'shipping' + ? currentDraftOrder.shipping + : currentDraftOrder.billing; - useDraftOrderFieldSync({ - key: 'phone', - data: phone, - deps: [phone, isValidPhone], - enabled: - phoneHasChanged && // Only sync if values differ from order - phone === phoneValue && - (phone - ? isValidPhone && phone?.trim() !== '' - : !phone && phoneValue === ''), - fieldNames: [`${sectionKey}Phone`], - mapToInput: data => - mapAddressFieldsToInput( - { phone: data }, - sectionKey as 'shipping' | 'billing', - useShippingAddress - ), + if ((orderSection?.phone || '') === (phone.trim() || '')) { + return false; + } + // An empty value clears the phone on the order; anything else has to + // be dialable before it is worth sending. + return phone.trim() ? checkIsValidPhone(phone) : true; + }, + buildPatch: ({ values }) => { + const phone = String( + values[phoneFieldName as keyof typeof values] ?? '' + ).trim(); + return mapAddressFieldsToInput( + { phone }, + sectionKey as 'shipping' | 'billing', + Boolean(values.paymentUseShippingAddress) + ); + }, + }), + [ + phoneFieldName, + registrationId, + sectionKey, + session?.enablePhoneCollection, + ] + ) + ); + useDraftOrderFieldDirtyMarker({ + id: registrationId, + fieldNames: [phoneFieldName], + disabled, }); return session?.enablePhoneCollection ? ( diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index 47eaa8ef..3eacb11f 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -402,7 +402,7 @@ export function CheckoutForm({ return ( - +
{ methodsRef.current = methods; + customSchemaFieldsRef.current = customSchemaFields; + sessionRef.current = session; }); const enhancedMethods = useMemo(() => { @@ -58,10 +67,108 @@ export function CustomFormProvider< const isShipping = deliveryMethod === DeliveryMethods.SHIP; const isFreeOrder = paymentMethod === PaymentMethodType.OFFLINE; const isFreePickup = isFreeOrder && isPickup; + const currentSession = sessionRef.current; + let billingContext: + | 'top-level' + | 'inline-payment-form' + | 'free-payment-form' = 'top-level'; + if (hasInlineBillingForm(paymentMethod)) { + billingContext = 'inline-payment-form'; + } else if (isFreeOrder) { + billingContext = 'free-payment-form'; + } + const billingMode = getBillingCollectionMode({ + context: billingContext, + deliveryMethod, + paymentMethod, + paymentUseShippingAddress, + enableBillingAddressCollection: + currentSession?.enableBillingAddressCollection, + enableTaxCollection: currentSession?.enableTaxCollection, + }); // Get all field names and filter based on conditions const allFieldNames = Object.keys(values); let fieldNames = [...allFieldNames] as Array>; + const shippingAddressFieldNames = new Set([ + 'shippingFirstName', + 'shippingLastName', + 'shippingAddressLine1', + 'shippingAddressLine2', + 'shippingAddressLine3', + 'shippingAdminArea4', + 'shippingAdminArea3', + 'shippingAdminArea2', + 'shippingAdminArea1', + 'shippingPostalCode', + 'shippingCountryCode', + ]); + const billingAddressFieldNames = new Set([ + 'billingAddressLine1', + 'billingAddressLine2', + 'billingAddressLine3', + 'billingAdminArea4', + 'billingAdminArea3', + 'billingAdminArea2', + 'billingAdminArea1', + 'billingPostalCode', + 'billingCountryCode', + ]); + const billingNameFieldNames = new Set([ + 'billingFirstName', + 'billingLastName', + ]); + const shippingSectionIsCollectable = Boolean( + isShipping && currentSession?.enableShipping + ); + const shippingAddressIsCollectable = Boolean( + shippingSectionIsCollectable && + currentSession?.enableShippingAddressCollection + ); + const billingNamesAreCollectable = billingMode !== 'none'; + const billingAddressIsCollectable = billingMode === 'address'; + const phoneIsCollectable = + currentSession?.enablePhoneCollection === true; + const notesAreCollectable = + currentSession?.enableNotesCollection === true; + + const isCollectable = (fieldName: string) => { + if (fieldName === 'shippingPhone') { + return shippingAddressIsCollectable && phoneIsCollectable; + } + if (fieldName === 'billingPhone') { + return billingNamesAreCollectable && phoneIsCollectable; + } + if (shippingAddressFieldNames.has(fieldName)) { + return shippingAddressIsCollectable; + } + if (fieldName === 'shippingMethod') { + return shippingSectionIsCollectable; + } + if (billingNameFieldNames.has(fieldName)) { + return billingNamesAreCollectable; + } + if (billingAddressFieldNames.has(fieldName)) { + return billingAddressIsCollectable; + } + if (fieldName.startsWith('shipping')) { + return shippingSectionIsCollectable; + } + if (fieldName.startsWith('billing')) { + return billingNamesAreCollectable; + } + if (fieldName === 'notes') { + return notesAreCollectable; + } + return true; + }; + fieldNames = fieldNames.filter(fieldName => isCollectable(fieldName)); + + const customFieldNames = new Set( + (customSchemaFieldsRef.current ?? []).filter(isCollectable) + ); + const isSkippable = (fieldName: string) => + !customFieldNames.has(fieldName); /* For free pickup orders, only validate billingFirstName and billingLastName */ if (isFreePickup) { @@ -69,7 +176,8 @@ export function CustomFormProvider< fieldName => !fieldName.startsWith('billing') || fieldName === 'billingFirstName' || - fieldName === 'billingLastName' + fieldName === 'billingLastName' || + !isSkippable(fieldName) ); } else if (paymentUseShippingAddress && isShipping) { /* If using shipping address for billing, filter out billing-related field validations. @@ -77,24 +185,20 @@ export function CustomFormProvider< * fulfillment orders, or sessions with enableShipping: false, still validate * billing fields — there's no shipping address to copy from in those cases. */ fieldNames = fieldNames.filter( - fieldName => !fieldName.startsWith('billing') + fieldName => + !fieldName.startsWith('billing') || !isSkippable(fieldName) ); } /* If the delivery method is not shipping (i.e. pickup), filter out shipping-related field validations */ if (!isShipping) { fieldNames = fieldNames.filter( - fieldName => !fieldName.startsWith('shipping') + fieldName => + !fieldName.startsWith('shipping') || !isSkippable(fieldName) ); } - // Trigger validation only on the filtered fields if any condition is true, - // otherwise trigger on all fields - if (paymentUseShippingAddress || isPickup || isFreeOrder) { - result = await methods.trigger(fieldNames, triggerOptions); - } else { - result = await methods.trigger(undefined, triggerOptions); - } + result = await methods.trigger(fieldNames, triggerOptions); } // Force update to ensure error messages show immediately diff --git a/packages/react/src/components/checkout/notes/notes-form.tsx b/packages/react/src/components/checkout/notes/notes-form.tsx index db203193..2b14a1bb 100644 --- a/packages/react/src/components/checkout/notes/notes-form.tsx +++ b/packages/react/src/components/checkout/notes/notes-form.tsx @@ -4,8 +4,10 @@ import { useDebouncedValue } from '@tanstack/react-pacer'; import React from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderFieldSync } from '@/components/checkout/order/use-draft-order-sync'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { FormField, FormItem, @@ -21,8 +23,6 @@ export function NotesForm() { const form = useFormContext(); const { t } = useGoDaddyContext(); const { isConfirmingCheckout, requiredFields } = useCheckoutContext(); - const { data: draftOrder } = useDraftOrder(); - const notesField = form.watch('notes'); const [notes] = useDebouncedValue(notesField, { @@ -43,32 +43,37 @@ export function NotesForm() { } }, [notes]); - // Check if notes value differs from order value - const notesHasChanged = React.useMemo(() => { - if (!draftOrder) return true; // If no order, allow sync - const orderNotes = - draftOrder.notes?.find(note => note.authorType === 'CUSTOMER')?.content || - ''; - return orderNotes !== (notes || ''); - }, [draftOrder, notes]); - - useDraftOrderFieldSync({ - key: 'notes', - data: notes, - deps: [notes, notesHasChanged], - enabled: notesHasChanged, + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: 'notes', + fieldNames: ['notes'], + debounceMs: 1000, + enabled: ({ values, draftOrder }) => { + if (!draftOrder) return false; + const orderNotes = + draftOrder.notes?.find(note => note.authorType === 'CUSTOMER') + ?.content || ''; + return orderNotes !== (values.notes || ''); + }, + buildPatch: ({ values }) => ({ + notes: values.notes?.trim() + ? [ + { + authorType: 'CUSTOMER', + content: values.notes.trim(), + }, + ] + : null, + }), + }), + [] + ) + ); + useDraftOrderFieldDirtyMarker({ + id: 'notes', fieldNames: ['notes'], - preserveFormData: false, - mapToInput: notesValue => ({ - notes: notesValue?.trim() - ? [ - { - authorType: 'CUSTOMER', - content: notesValue.trim(), - }, - ] - : null, - }), + disabled: isConfirmingCheckout, }); return ( diff --git a/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx b/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx index 4f64cf7d..7715c072 100644 --- a/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx +++ b/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx @@ -1,8 +1,14 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { FormProvider, useForm, useFormContext } from 'react-hook-form'; +import { + FormProvider, + useForm, + useFormContext, + useWatch, +} from 'react-hook-form'; import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { type CheckoutFormData, checkoutContext, @@ -22,22 +28,74 @@ import { flushPromises, getOperations, mockGodaddyApi, + setApiError, setApiErrorOnce, waitForOperation, } from '../__tests__/checkout-test-env'; import { getLastUpdateInput } from '../__tests__/checkout-test-fixtures'; function SyncConsumer() { - const { enqueueDraftOrderPatch, flushDraftOrderSync } = - useDraftOrderSyncQueue(); + const { + enqueueDraftOrderPatch, + flushDraftOrderSync, + markDraftOrderSyncDirty, + registerDraftOrderSync, + } = useDraftOrderSyncQueue(); const form = useFormContext(); + const [registrationVersion, setRegistrationVersion] = React.useState(0); + const shippingFirstName = useWatch({ + control: form.control, + name: 'shippingFirstName', + }); + + React.useEffect( + () => + registerDraftOrderSync({ + id: 'shipping-name', + fieldNames: ['shippingFirstName', 'shippingLastName'], + dependencyFieldNames: ['paymentUseShippingAddress'], + debounceMs: 100, + enabled: ({ values, draftOrder }) => + Boolean( + draftOrder && + values.shippingFirstName?.trim() && + values.shippingLastName?.trim() && + ((draftOrder.shipping?.firstName || '') !== + values.shippingFirstName || + (draftOrder.shipping?.lastName || '') !== + values.shippingLastName) + ), + buildPatch: ({ values }) => ({ + shipping: { + firstName: values.shippingFirstName.trim(), + lastName: values.shippingLastName.trim(), + }, + ...(values.paymentUseShippingAddress + ? { + billing: { + firstName: values.shippingFirstName.trim(), + lastName: values.shippingLastName.trim(), + }, + } + : {}), + }), + }), + [registerDraftOrderSync, registrationVersion, shippingFirstName] + ); return (
+ + {String(!!form.formState.dirtyFields.shippingFirstName)} + {shippingFirstName} + + + + +
); } @@ -82,16 +185,19 @@ function SyncHarness({ session, draftOrder, isConfirmingCheckout = false, + schema, }: { session: CheckoutSession | null; draftOrder: DraftOrder; isConfirmingCheckout?: boolean; + schema?: z.ZodTypeAny; }) { const [confirming, setConfirming] = React.useState(isConfirmingCheckout); const form = useForm({ defaultValues: { shippingFirstName: 'Initial', shippingLastName: 'Buyer', + paymentUseShippingAddress: false, } as CheckoutFormData, }); @@ -107,7 +213,7 @@ function SyncHarness({ setCheckoutErrors: () => undefined, }} > - +