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 (
);
}
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 (
-
- );
- }
-
- 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]);
+}