diff --git a/.changeset/fix-free-pickup-validation.md b/.changeset/fix-free-pickup-validation.md new file mode 100644 index 00000000..6a903497 --- /dev/null +++ b/.changeset/fix-free-pickup-validation.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Fix "Complete your free order" button not working for free pickup orders. Now only requires billing first and last name instead of full billing address. diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index dcdba20d..ef8e9258 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -52,7 +52,13 @@ import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; import type { Address } from '@/types'; -export function AddressForm({ sectionKey }: { sectionKey: string }) { +interface AddressFormProps { + sectionKey: string; + /** When true, only show first name and last name fields (used for free pickup orders) */ + onlyNames?: boolean; +} + +export function AddressForm({ sectionKey, onlyNames = false }: AddressFormProps) { const form = useFormContext(); const { session } = useCheckoutContext(); const { t } = useGoDaddyContext(); @@ -303,126 +309,131 @@ export function AddressForm({ sectionKey }: { sectionKey: string }) { return (
- ( - - {t.shipping.country} - - - - - - - ( + + {t.shipping.country} + - - - - {t.shipping.noCountryFound} - - {countries.map(country => ( - { - // Get current country before setting the new one - const previousCountry = form.getValues( - `${sectionKey}CountryCode` - ); - - // Set the new country value - form.setValue( - `${sectionKey}CountryCode`, - country.value, - { - shouldValidate: true, + + + + + + + + + + {t.shipping.noCountryFound} + + {countries.map(country => ( + { + // Get current country before setting the new one + const previousCountry = form.getValues( + `${sectionKey}CountryCode` + ); + + // Set the new country value + form.setValue( + `${sectionKey}CountryCode`, + country.value, + { + shouldValidate: true, + } + ); + + if (previousCountry !== country.value) { + form.setValue(`${sectionKey}AddressLine1`, '', { + shouldDirty: true, + shouldValidate: false, + }); + form.setValue(`${sectionKey}AdminArea1`, '', { + shouldDirty: true, + shouldValidate: false, + }); + form.setValue(`${sectionKey}AdminArea2`, '', { + shouldDirty: true, + shouldValidate: false, + }); + form.setValue(`${sectionKey}PostalCode`, '', { + shouldDirty: true, + shouldValidate: false, + }); } - ); - if (previousCountry !== country.value) { - form.setValue(`${sectionKey}AddressLine1`, '', { - shouldDirty: true, - shouldValidate: false, + // Track country selection event + track({ + eventId: eventIds.changeCountry, + type: TrackingEventType.CLICK, + properties: { + sectionKey, + countryCode: country.value, + countryName: country.label, + }, }); - form.setValue(`${sectionKey}AdminArea1`, '', { - shouldDirty: true, - shouldValidate: false, - }); - form.setValue(`${sectionKey}AdminArea2`, '', { - shouldDirty: true, - shouldValidate: false, - }); - form.setValue(`${sectionKey}PostalCode`, '', { - shouldDirty: true, - shouldValidate: false, - }); - } - // Track country selection event - track({ - eventId: eventIds.changeCountry, - type: TrackingEventType.CLICK, - properties: { - sectionKey, - countryCode: country.value, - countryName: country.label, - }, - }); - - setCountrySelectOpen(false); - }} - disabled={isConfirmingCheckout} - > - {country.label} - - - ))} - - - - - - - - )} - /> + setCountrySelectOpen(false); + }} + disabled={isConfirmingCheckout} + > + {country.label} + + + ))} + + + + + + + + )} + /> + )}
( - {t.shipping.firstName} ({t.general.optional}) + {t.shipping.firstName}{' '} + {!onlyNames && `(${t.general.optional})`}
- ( - - {t.shipping.address1} - - {countryValue === 'US' && session?.enableAddressAutocomplete ? ( - { - handleUpdateAddress(selectedAddress as Address); - }} - onOpenChange={setIsAutocompleteOpen} - isLoading={ - addressMatchesQuery?.isLoading || - addressMatchesQuery?.isFetching - } - hasError={!!fieldState.error} - aria-required={requiredFields?.[`${sectionKey}AddressLine1`]} - disabled={isConfirmingCheckout} - /> - ) : ( - - )} - - - - )} - /> - - ( - - {t.shipping.address2} - - - - - - )} - /> - -
- ( - - {t.shipping.city} - - - - - - )} - /> - ( - - {t.shipping.region} - - {hasRegionData(countryValue) ? ( - - ) : ( + {!onlyNames && ( + <> + ( + + {t.shipping.address1} + + {countryValue === 'US' && + session?.enableAddressAutocomplete ? ( + { + handleUpdateAddress(selectedAddress as Address); + }} + onOpenChange={setIsAutocompleteOpen} + isLoading={ + addressMatchesQuery?.isLoading || + addressMatchesQuery?.isFetching + } + hasError={!!fieldState.error} + aria-required={ + requiredFields?.[`${sectionKey}AddressLine1`] + } + disabled={isConfirmingCheckout} + /> + ) : ( + + )} + + + + )} + /> + + ( + + {t.shipping.address2} + - )} - - - - )} - /> - - ( - - {t.shipping.postalCode} - - - - - - )} - /> -
+ + + + )} + /> + +
+ ( + + {t.shipping.city} + + + + + + )} + /> + ( + + {t.shipping.region} + + {hasRegionData(countryValue) ? ( + + ) : ( + + )} + + + + )} + /> + + ( + + + {t.shipping.postalCode} + + + + + + + )} + /> +
- + + + )}
); } diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index 5012b3ed..934f02b8 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -13,7 +13,7 @@ import { type Theme, useTheme } from '@/hooks/use-theme'; import { useVariables } from '@/hooks/use-variables'; import type { TrackingProperties } from '@/tracking/event-properties'; import { TrackingProvider } from '@/tracking/tracking-provider'; -import type { CheckoutSession } from '@/types'; +import { PaymentMethodType, type CheckoutSession } from '@/types'; import { CheckoutFormContainer } from './form/checkout-form-container'; import type { Target } from './target/target'; @@ -280,32 +280,19 @@ export function Checkout(props: CheckoutProps) { } // Billing address validation - only required if not using shipping address OR pickup - const requireBillingAddress = - !data.paymentUseShippingAddress || - data.deliveryMethod === DeliveryMethods.PICKUP; - - if (requireBillingAddress) { - // Basic billing fields required for all countries - const billingFields = [ + // BUT skip for free orders (paymentMethod === 'offline') + const isFreeOrder = data.paymentMethod === PaymentMethodType.OFFLINE; + const isPickup = data.deliveryMethod === DeliveryMethods.PICKUP; + const isFreePickup = isFreeOrder && isPickup; + + // For free pickup orders, only require first/last name (no address) + if (isFreePickup) { + const nameFields = [ { key: 'billingFirstName', message: t.validation.enterFirstName }, { key: 'billingLastName', message: t.validation.enterLastName }, - { key: 'billingAddressLine1', message: t.validation.enterAddress }, - { key: 'billingAdminArea2', message: t.validation.enterCity }, - { - key: 'billingPostalCode', - message: t.validation.enterZipPostalCode, - }, - { key: 'billingCountryCode', message: t.validation.enterCountry }, ]; - if (hasRegionData(String(data.billingCountryCode))) { - billingFields.push({ - key: 'billingAdminArea1', - message: t.validation.selectState, - }); - } - - for (const { key, message } of billingFields) { + for (const { key, message } of nameFields) { if (!data[key as keyof typeof data]) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -314,6 +301,42 @@ export function Checkout(props: CheckoutProps) { }); } } + } else { + // Full billing address validation - required if not using shipping address OR pickup + const requireBillingAddress = + !data.paymentUseShippingAddress || isPickup; + + if (requireBillingAddress) { + // Basic billing fields required for all countries + const billingFields = [ + { key: 'billingFirstName', message: t.validation.enterFirstName }, + { key: 'billingLastName', message: t.validation.enterLastName }, + { key: 'billingAddressLine1', message: t.validation.enterAddress }, + { key: 'billingAdminArea2', message: t.validation.enterCity }, + { + key: 'billingPostalCode', + message: t.validation.enterZipPostalCode, + }, + { key: 'billingCountryCode', message: t.validation.enterCountry }, + ]; + + if (hasRegionData(String(data.billingCountryCode))) { + billingFields.push({ + key: 'billingAdminArea1', + message: t.validation.selectState, + }); + } + + for (const { key, message } of billingFields) { + if (!data[key as keyof typeof data]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: [key], + }); + } + } + } } // Shipping address validation - only required if delivery method is SHIP diff --git a/packages/react/src/components/checkout/form/custom-form-provider.tsx b/packages/react/src/components/checkout/form/custom-form-provider.tsx index 2b9eba50..4602126c 100644 --- a/packages/react/src/components/checkout/form/custom-form-provider.tsx +++ b/packages/react/src/components/checkout/form/custom-form-provider.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { FieldPath, UseFormReturn, UseFormTrigger } from 'react-hook-form'; import { FormProvider } from 'react-hook-form'; +import { PaymentMethodType } from '@/types'; import type { CheckoutFormData } from '../checkout'; import { DeliveryMethods } from '../delivery/delivery-method'; @@ -50,18 +51,28 @@ export function CustomFormProvider< else { const values = currentMethods.getValues(); const deliveryMethod = values.deliveryMethod as unknown as string; + const paymentMethod = values.paymentMethod as unknown as string; const paymentUseShippingAddress = values.paymentUseShippingAddress as unknown as boolean; const isPickup = deliveryMethod === DeliveryMethods.PICKUP; const isShipping = deliveryMethod === DeliveryMethods.SHIP; - const requireBillingAddress = !paymentUseShippingAddress || isPickup; + const isFreeOrder = paymentMethod === PaymentMethodType.OFFLINE; + const isFreePickup = isFreeOrder && isPickup; // Get all field names and filter based on conditions const allFieldNames = Object.keys(values); let fieldNames = [...allFieldNames] as Array>; - /* If using shipping address for billing (and not pickup), filter out billing-related field validations */ - if (!requireBillingAddress) { + /* For free pickup orders, only validate billingFirstName and billingLastName */ + if (isFreePickup) { + fieldNames = fieldNames.filter( + fieldName => + !fieldName.startsWith('billing') || + fieldName === 'billingFirstName' || + fieldName === 'billingLastName' + ); + } else if (paymentUseShippingAddress && !isPickup) { + /* If using shipping address for billing (and not pickup), filter out billing-related field validations */ fieldNames = fieldNames.filter( fieldName => !fieldName.startsWith('billing') ); @@ -76,7 +87,7 @@ export function CustomFormProvider< // Trigger validation only on the filtered fields if any condition is true, // otherwise trigger on all fields - if (paymentUseShippingAddress || isPickup) { + if (paymentUseShippingAddress || isPickup || isFreeOrder) { result = await methods.trigger(fieldNames, triggerOptions); } else { result = await methods.trigger(undefined, triggerOptions); diff --git a/packages/react/src/components/checkout/payment/free-payment-form.test.ts b/packages/react/src/components/checkout/payment/free-payment-form.test.ts new file mode 100644 index 00000000..198dbf3b --- /dev/null +++ b/packages/react/src/components/checkout/payment/free-payment-form.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Helper function to determine which billing fields are required + * based on payment method and delivery method. + * + * This mirrors the validation logic in checkout.tsx and custom-form-provider.tsx + */ +function getRequiredBillingFields( + paymentMethod: string, + deliveryMethod: string, + paymentUseShippingAddress: boolean +): string[] { + const isFreeOrder = paymentMethod === 'offline'; + const isPickup = deliveryMethod === 'PICKUP'; + const isFreePickup = isFreeOrder && isPickup; + + // For free pickup orders, only require name fields + if (isFreePickup) { + return ['billingFirstName', 'billingLastName']; + } + + // For paid pickup or when not using shipping address, require full billing + const requireBillingAddress = !paymentUseShippingAddress || isPickup; + + if (requireBillingAddress) { + return [ + 'billingFirstName', + 'billingLastName', + 'billingAddressLine1', + 'billingAdminArea2', + 'billingPostalCode', + 'billingCountryCode', + ]; + } + + // When using shipping address for billing (non-pickup), no billing fields required + return []; +} + +/** + * Helper function to filter billing field names for validation + * This mirrors the logic in custom-form-provider.tsx + */ +function filterBillingFieldsForValidation( + allFieldNames: string[], + paymentMethod: string, + deliveryMethod: string, + paymentUseShippingAddress: boolean +): string[] { + const isFreeOrder = paymentMethod === 'offline'; + const isPickup = deliveryMethod === 'PICKUP'; + const isFreePickup = isFreeOrder && isPickup; + + if (isFreePickup) { + // For free pickup, only include billingFirstName and billingLastName + return allFieldNames.filter( + fieldName => + !fieldName.startsWith('billing') || + fieldName === 'billingFirstName' || + fieldName === 'billingLastName' + ); + } + + if (paymentUseShippingAddress && !isPickup) { + // Filter out all billing fields when using shipping address + return allFieldNames.filter(fieldName => !fieldName.startsWith('billing')); + } + + // Return all fields for other cases + return allFieldNames; +} + +describe('FreePaymentForm - Billing validation logic', () => { + describe('getRequiredBillingFields', () => { + it('should only require first and last name for free pickup orders', () => { + const result = getRequiredBillingFields('offline', 'PICKUP', true); + + expect(result).toEqual(['billingFirstName', 'billingLastName']); + expect(result).not.toContain('billingAddressLine1'); + expect(result).not.toContain('billingPostalCode'); + }); + + it('should require full billing address for paid pickup orders', () => { + const result = getRequiredBillingFields('card', 'PICKUP', true); + + expect(result).toContain('billingFirstName'); + expect(result).toContain('billingLastName'); + expect(result).toContain('billingAddressLine1'); + expect(result).toContain('billingAdminArea2'); + expect(result).toContain('billingPostalCode'); + expect(result).toContain('billingCountryCode'); + }); + + it('should require full billing when not using shipping address', () => { + const result = getRequiredBillingFields('card', 'SHIP', false); + + expect(result).toContain('billingFirstName'); + expect(result).toContain('billingAddressLine1'); + }); + + it('should not require billing fields when using shipping address for shipping orders', () => { + const result = getRequiredBillingFields('card', 'SHIP', true); + + expect(result).toEqual([]); + }); + + it('should only require names for free orders with SHIP delivery using shipping address', () => { + // Free order with shipping that uses shipping address - no billing needed + const result = getRequiredBillingFields('offline', 'SHIP', true); + + // For free non-pickup orders using shipping address, no billing required + expect(result).toEqual([]); + }); + }); + + describe('filterBillingFieldsForValidation', () => { + const allFields = [ + 'contactEmail', + 'deliveryMethod', + 'billingFirstName', + 'billingLastName', + 'billingAddressLine1', + 'billingAdminArea1', + 'billingAdminArea2', + 'billingPostalCode', + 'billingCountryCode', + 'shippingFirstName', + 'shippingLastName', + 'shippingAddressLine1', + ]; + + it('should filter billing fields to only names for free pickup', () => { + const result = filterBillingFieldsForValidation( + allFields, + 'offline', + 'PICKUP', + true + ); + + expect(result).toContain('contactEmail'); + expect(result).toContain('billingFirstName'); + expect(result).toContain('billingLastName'); + expect(result).not.toContain('billingAddressLine1'); + expect(result).not.toContain('billingPostalCode'); + }); + + it('should keep all billing fields for paid pickup', () => { + const result = filterBillingFieldsForValidation( + allFields, + 'card', + 'PICKUP', + true + ); + + expect(result).toContain('billingFirstName'); + expect(result).toContain('billingAddressLine1'); + expect(result).toContain('billingPostalCode'); + }); + + it('should filter out all billing fields when using shipping address for non-pickup', () => { + const result = filterBillingFieldsForValidation( + allFields, + 'card', + 'SHIP', + true + ); + + expect(result).toContain('contactEmail'); + expect(result).toContain('shippingFirstName'); + expect(result).not.toContain('billingFirstName'); + expect(result).not.toContain('billingAddressLine1'); + }); + + it('should keep all fields when not using shipping address', () => { + const result = filterBillingFieldsForValidation( + allFields, + 'card', + 'SHIP', + false + ); + + expect(result).toContain('billingFirstName'); + expect(result).toContain('billingAddressLine1'); + expect(result).toContain('shippingFirstName'); + }); + }); +}); diff --git a/packages/react/src/components/checkout/payment/free-payment-form.tsx b/packages/react/src/components/checkout/payment/free-payment-form.tsx index 399012cd..4ca61617 100644 --- a/packages/react/src/components/checkout/payment/free-payment-form.tsx +++ b/packages/react/src/components/checkout/payment/free-payment-form.tsx @@ -1,7 +1,9 @@ import { LoaderCircle } from 'lucide-react'; import React from 'react'; import { useFormContext } from 'react-hook-form'; +import { AddressForm } from '@/components/checkout/address/address-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-method'; import { PaymentProvider, useConfirmCheckout, @@ -20,6 +22,9 @@ export function FreePaymentForm() { const form = useFormContext(); const confirmCheckout = useConfirmCheckout(); + const deliveryMethod = form.watch('deliveryMethod'); + const isPickup = deliveryMethod === DeliveryMethods.PICKUP; + const handleSubmit = React.useCallback(async () => { const valid = await form.trigger(); if (!valid) { @@ -43,7 +48,7 @@ export function FreePaymentForm() { } }, [form, confirmCheckout.mutateAsync, setCheckoutErrors]); - return isConfirmingCheckout ? ( + const submitButton = isConfirmingCheckout ? ( ); + + // For pickup orders, show name fields + if (isPickup) { + return ( +
+ + {submitButton} +
+ ); + } + + return submitButton; }