Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pickup-customer-names.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 11 additions & 4 deletions packages/react/src/components/checkout/address/address-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -389,7 +394,7 @@ export function AddressForm({

return (
<fieldset className='space-y-2' disabled={isConfirmingCheckout}>
{!onlyNames && (
{showAddressFields && (
<FormField
control={form.control}
name={`${sectionKey}CountryCode`}
Expand Down Expand Up @@ -516,6 +521,7 @@ export function AddressForm({
/>
)}

{showNames ? (
<div className='grid grid-cols-1 sm:grid-cols-2 gap-2'>
<FormField
control={form.control}
Expand Down Expand Up @@ -556,8 +562,9 @@ export function AddressForm({
)}
/>
</div>
) : null}

{!onlyNames && (
{showAddressFields ? (
<>
<FormField
control={form.control}
Expand Down Expand Up @@ -764,7 +771,7 @@ export function AddressForm({

<PhoneInput sectionKey={sectionKey} disabled={isConfirmingCheckout} />
</>
)}
) : null}
</fieldset>
);
}
41 changes: 27 additions & 14 deletions packages/react/src/components/checkout/checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -320,7 +333,7 @@ export function Checkout(props: CheckoutProps) {

const requireBillingAddress =
enableBillingAddressCollection &&
!isFreePickup &&
!isOfflinePickup &&
billingIsSeparateFromShipping;

if (requireBillingAddress) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FieldPath<TFormValues>>;

/* 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') ||
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -45,6 +48,7 @@ import type { CalculatedAdjustments, CalculatedTaxes } from '@/types';
export function ExpressCheckoutButton() {
const formatCurrency = useFormatCurrency();
const convertMajorToMinorUnits = useConvertMajorToMinorUnits();
const form = useFormContext<CheckoutFormData>();
const { session, setCheckoutErrors, isConfirmingCheckout } =
useCheckoutContext();
const isPaymentDisabled = useIsPaymentDisabled();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -296,6 +305,8 @@ export function ExpressCheckoutButton() {
totals,
formatCurrency,
isDisabled,
form,
session,
]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -42,6 +45,7 @@ interface StripePartialAddress {

export function StripeExpressCheckoutForm() {
const { t } = useGoDaddyContext();
const form = useFormContext<CheckoutFormData>();
const { session, setCheckoutErrors, isConfirmingCheckout } =
useCheckoutContext();
const elements = useElements();
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -404,7 +414,7 @@ export function StripeExpressCheckoutForm() {
lineItems: buildLineItems({ discountAmount }),
});
},
[buildLineItems, setCheckoutErrors, isDisabled]
[buildLineItems, setCheckoutErrors, isDisabled, form, session]
);

// Handle shipping address change
Expand Down
Loading