diff --git a/.changeset/pickup-customer-names.md b/.changeset/pickup-customer-names.md new file mode 100644 index 00000000..22be05ef --- /dev/null +++ b/.changeset/pickup-customer-names.md @@ -0,0 +1,5 @@ +--- +'@godaddy/react': patch +--- + +Require customer first and last name for in-person pickup orders. Names are collected in the Pickup section, synced to order billing, and validated for card, offline, free, and express checkout paths. 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..de15cb05 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 @@ -163,6 +163,43 @@ describe('Checkout form validation', () => { expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); }); + it('requires pickup customer names for paid pickup with offline payment', async () => { + const draftOrder = makePaidPickupOrder({ + billing: { + firstName: '', + lastName: '', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + sessionOverrides: { + draftOrder, + paymentMethods: { + ...stripeOnlyPaymentMethods(), + card: null as never, + offline: { + type: PaymentMethodType.OFFLINE, + processor: PaymentProvider.OFFLINE, + checkoutTypes: ['standard'], + }, + }, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await clickSubmitButton(/complete your order/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.enterFirstName); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + 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/__tests__/checkout-free-payment-form.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx index e418a910..a14e044c 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx @@ -53,7 +53,7 @@ async function submitFreeOrder( } describe('Checkout FreePaymentForm integration', () => { - it('renders names-only billing for a free pickup order without a billing address', async () => { + it('renders pickup customer names in the pickup section for a free pickup order', async () => { const draftOrder = buildFreeDraftOrder({ lineItems: [{ fulfillmentMode: 'PICKUP' }], billing: { @@ -83,6 +83,9 @@ describe('Checkout FreePaymentForm integration', () => { expect(document.querySelector('input[name="billingLastName"]')).toHaveValue( 'Pickup' ); + expect( + document.querySelectorAll('input[name="billingFirstName"]') + ).toHaveLength(1); expect( document.querySelector('input[name="billingAddressLine1"]') ).not.toBeInTheDocument(); diff --git a/packages/react/src/components/checkout/__tests__/checkout-pickup.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-pickup.test.tsx index e33d10a0..38f343bf 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-pickup.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-pickup.test.tsx @@ -10,6 +10,21 @@ import { } from './checkout-test-env'; describe('Checkout pickup behavior', () => { + it('shows customer name fields in the pickup section', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('ApplyCheckoutSessionFulfillmentLocation'); + + expect( + document.querySelector('input[name="billingFirstName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingLastName"]') + ).toBeInTheDocument(); + }); + it('switches from shipping to pickup and calculates taxes with pickup location', async () => { const { user } = renderCheckout(); await waitForCheckoutReady(); diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index 73cc8894..48785f01 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -54,8 +54,10 @@ import type { Address } from '@/types'; interface AddressFormProps { sectionKey: string; - /** When true, only show first name and last name fields (used for free pickup orders) */ + /** When true, only show first name and last name fields (used for pickup orders) */ onlyNames?: boolean; + /** When true, hide first/last name fields (names collected elsewhere, e.g. pickup section) */ + hideNames?: boolean; } export function mapAutocompleteAddressFields(selectedAddress?: Address) { @@ -73,7 +75,10 @@ export function mapAutocompleteAddressFields(selectedAddress?: Address) { export function AddressForm({ sectionKey, onlyNames = false, + hideNames = false, }: AddressFormProps) { + const showNames = onlyNames || !hideNames; + const showAddressFields = !onlyNames; const form = useFormContext(); const { session } = useCheckoutContext(); const { t } = useGoDaddyContext(); @@ -389,7 +394,7 @@ export function AddressForm({ return (
- {!onlyNames && ( + {showAddressFields && ( )} + {showNames ? (
+ ) : null} - {!onlyNames && ( + {showAddressFields ? ( <> - )} + ) : null}
); } diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index e3efb6ff..f3933f9a 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -282,12 +282,12 @@ export function Checkout(props: CheckoutProps) { } } - // Billing address validation - only required if not using shipping address OR pickup - // BUT skip for free orders (paymentMethod === 'offline') - const isFreeOrder = data.paymentMethod === PaymentMethodType.OFFLINE; + // Billing address validation - only required when billing is separate from shipping. + // Offline pickup (pay in store / $0 order) only requires customer names. + const isOfflinePayment = data.paymentMethod === PaymentMethodType.OFFLINE; const isPickup = data.deliveryMethod === DeliveryMethods.PICKUP; const isShipping = data.deliveryMethod === DeliveryMethods.SHIP; - const isFreePickup = isFreeOrder && isPickup; + const isOfflinePickup = isOfflinePayment && isPickup; // Billing is separate from shipping when there is no shipping address // to copy from. `mapOrderToFormValues` canonicalizes deliveryMethod @@ -297,18 +297,31 @@ export function Checkout(props: CheckoutProps) { const billingIsSeparateFromShipping = !isShipping || !data.paymentUseShippingAddress; + const billingNameFields = [ + { key: 'billingFirstName', message: t.validation.enterFirstName }, + { key: 'billingLastName', message: t.validation.enterLastName }, + ]; + + // Pickup orders always require customer name (collected in Pickup section). + if (isPickup) { + for (const { key, message } of billingNameFields) { + if (!String(data[key as keyof typeof data] ?? '').trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: [key], + }); + } + } + } + const requireBillingNamesOnly = - (!enableBillingAddressCollection && billingIsSeparateFromShipping) || - isFreePickup; + !isPickup && + (!enableBillingAddressCollection && billingIsSeparateFromShipping); if (requireBillingNamesOnly) { - const nameFields = [ - { key: 'billingFirstName', message: t.validation.enterFirstName }, - { key: 'billingLastName', message: t.validation.enterLastName }, - ]; - - for (const { key, message } of nameFields) { - if (!data[key as keyof typeof data]) { + for (const { key, message } of billingNameFields) { + if (!String(data[key as keyof typeof data] ?? '').trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, message, @@ -320,7 +333,7 @@ export function Checkout(props: CheckoutProps) { const requireBillingAddress = enableBillingAddressCollection && - !isFreePickup && + !isOfflinePickup && billingIsSeparateFromShipping; if (requireBillingAddress) { 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 7e0ead41..dc2699b6 100644 --- a/packages/react/src/components/checkout/form/custom-form-provider.tsx +++ b/packages/react/src/components/checkout/form/custom-form-provider.tsx @@ -56,15 +56,15 @@ export function CustomFormProvider< values.paymentUseShippingAddress as unknown as boolean; const isPickup = deliveryMethod === DeliveryMethods.PICKUP; const isShipping = deliveryMethod === DeliveryMethods.SHIP; - const isFreeOrder = paymentMethod === PaymentMethodType.OFFLINE; - const isFreePickup = isFreeOrder && isPickup; + const isOfflinePayment = paymentMethod === PaymentMethodType.OFFLINE; + const isOfflinePickup = isOfflinePayment && isPickup; // Get all field names and filter based on conditions const allFieldNames = Object.keys(values); let fieldNames = [...allFieldNames] as Array>; - /* For free pickup orders, only validate billingFirstName and billingLastName */ - if (isFreePickup) { + /* Offline pickup only validates billing name fields among billing inputs */ + if (isOfflinePickup) { fieldNames = fieldNames.filter( fieldName => !fieldName.startsWith('billing') || @@ -90,7 +90,7 @@ export function CustomFormProvider< // Trigger validation only on the filtered fields if any condition is true, // otherwise trigger on all fields - if (paymentUseShippingAddress || isPickup || isFreeOrder) { + if (paymentUseShippingAddress || isPickup || isOfflinePayment) { result = await methods.trigger(fieldNames, triggerOptions); } else { result = await methods.trigger(undefined, triggerOptions); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index 5d2ddd19..b1d7c4de 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import type { CheckoutFormData } from '@/components/checkout/checkout'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { useDraftOrder, @@ -27,6 +29,7 @@ import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-loa import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes'; +import { validatePickupPrerequisites } from '@/components/checkout/utils/use-validate-pickup-prerequisites'; import { useConvertMajorToMinorUnits, useFormatCurrency, @@ -45,6 +48,7 @@ import type { CalculatedAdjustments, CalculatedTaxes } from '@/types'; export function ExpressCheckoutButton() { const formatCurrency = useFormatCurrency(); const convertMajorToMinorUnits = useConvertMajorToMinorUnits(); + const form = useFormContext(); const { session, setCheckoutErrors, isConfirmingCheckout } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); @@ -186,6 +190,11 @@ export function ExpressCheckoutButton() { return; } + const pickupValid = await validatePickupPrerequisites(form, session); + if (!pickupValid) { + return; + } + // Read from refs to get current values (avoid stale closure) const currentCouponCode = appliedCouponCodeRef.current; const currentAdjustments = calculatedAdjustmentsRef.current; @@ -296,6 +305,8 @@ export function ExpressCheckoutButton() { totals, formatCurrency, isDisabled, + form, + session, ] ); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 03fb32fc..c3f5436c 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -9,7 +9,9 @@ import type { StripeExpressCheckoutElementShippingRateChangeEvent, } from '@stripe/stripe-js'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import type { CheckoutFormData } from '@/components/checkout/checkout'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { useDraftOrder, @@ -21,6 +23,7 @@ import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use- import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes'; +import { validatePickupPrerequisites } from '@/components/checkout/utils/use-validate-pickup-prerequisites'; import { Skeleton } from '@/components/ui/skeleton'; import { useGoDaddyContext } from '@/godaddy-provider'; @@ -42,6 +45,7 @@ interface StripePartialAddress { export function StripeExpressCheckoutForm() { const { t } = useGoDaddyContext(); + const form = useFormContext(); const { session, setCheckoutErrors, isConfirmingCheckout } = useCheckoutContext(); const elements = useElements(); @@ -369,13 +373,19 @@ export function StripeExpressCheckoutForm() { // Handle click event - configure initial details const handleClick = useCallback( - (event: StripeExpressCheckoutElementClickEvent) => { + async (event: StripeExpressCheckoutElementClickEvent) => { // Reject if payment is disabled if (isDisabled) { event.reject(); return; } + const pickupValid = await validatePickupPrerequisites(form, session); + if (!pickupValid) { + event.reject(); + return; + } + // Track click if (event.expressPaymentType === 'apple_pay') { track({ @@ -404,7 +414,7 @@ export function StripeExpressCheckoutForm() { lineItems: buildLineItems({ discountAmount }), }); }, - [buildLineItems, setCheckoutErrors, isDisabled] + [buildLineItems, setCheckoutErrors, isDisabled, form, session] ); // Handle shipping address change 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 ac26e230..ee2fb3d4 100644 --- a/packages/react/src/components/checkout/payment/free-payment-form.tsx +++ b/packages/react/src/components/checkout/payment/free-payment-form.tsx @@ -1,9 +1,7 @@ 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-methods'; import { PaymentProvider, useConfirmCheckout, @@ -22,9 +20,6 @@ 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) { @@ -48,16 +43,20 @@ export function FreePaymentForm() { } }, [form, confirmCheckout.mutateAsync, setCheckoutErrors]); - const submitButton = isConfirmingCheckout ? ( - - ) : ( + if (isConfirmingCheckout) { + return ( + + ); + } + + return ( ); - - // For pickup orders, show name fields - if (isPickup) { - return ( -
- - {submitButton} -
- ); - } - - return submitButton; } diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index d3f7c026..5c8d4392 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -308,6 +308,7 @@ export function PaymentForm( const billingIsSeparateFromShipping = !isShipping || !useShippingAddress; const shouldShowBillingNamesOnly = + !isPickup && !isPaymentMethodWithInlineBilling && session?.enableBillingAddressCollection === false && billingIsSeparateFromShipping; @@ -569,6 +570,7 @@ export function PaymentForm( ) : null} diff --git a/packages/react/src/components/checkout/pickup/local-pickup.tsx b/packages/react/src/components/checkout/pickup/local-pickup.tsx index f004771a..d6772929 100644 --- a/packages/react/src/components/checkout/pickup/local-pickup.tsx +++ b/packages/react/src/components/checkout/pickup/local-pickup.tsx @@ -3,6 +3,7 @@ import { format as formatTz, toZonedTime } from 'date-fns-tz'; import { CalendarIcon, ChevronDown, Clock, MapPin, Store } from 'lucide-react'; import React, { useCallback, useEffect, useState } 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-methods'; import { useApplyFulfillmentLocation } from '@/components/checkout/delivery/utils/use-apply-fulfillment-location'; @@ -440,6 +441,8 @@ export function LocalPickupForm({ return (
+ + , + session?: CheckoutSession | null +): Array> { + if (values.deliveryMethod !== DeliveryMethods.PICKUP) { + return []; + } + + const fields: Array> = [ + 'contactEmail', + 'billingFirstName', + 'billingLastName', + 'pickupLocationId', + ]; + + const location = session?.locations?.find( + loc => loc.id === values.pickupLocationId + ); + const storeHours = + location?.operatingHours ?? session?.defaultOperatingHours; + + if (storeHours?.pickupWindowInDays !== 0) { + fields.push('pickupDate', 'pickupTime'); + } + + return fields; +} + +export async function validatePickupPrerequisites( + form: UseFormReturn, + session?: CheckoutSession | null +): Promise { + const values = form.getValues(); + const fields = getPickupPrerequisiteFields(values, session); + + if (fields.length === 0) { + return true; + } + + return form.trigger(fields); +} + +export function useValidatePickupPrerequisites( + session?: CheckoutSession | null +) { + const form = useFormContext(); + + return useCallback(async () => { + return validatePickupPrerequisites(form, session); + }, [form, session]); +}