From ec23ba5ac880d97a4f6fcbaee603c9f27f2549f6 Mon Sep 17 00:00:00 2001 From: Aaron Nam <112416280+anam-godaddy@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:30:52 -0700 Subject: [PATCH 1/3] Default to free shipping when a free rate is available The shipping method effect kept any already-applied rate that was still listed, so a paid rate that was auto-applied or already on the draft order won over a $0 rate. Prefer the shopper's explicit pick from this mount, then a $0 rate, then the existing behavior. AIROBUILD-7255 Co-Authored-By: Claude Opus 5.5 --- .changeset/free-shipping-default-selection.md | 5 + .../__tests__/checkout-free-order.test.tsx | 3 + .../checkout-free-shipping-default.test.tsx | 203 ++++++++++++++++++ .../checkout/shipping/shipping-method.tsx | 23 +- 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 .changeset/free-shipping-default-selection.md create mode 100644 packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx diff --git a/.changeset/free-shipping-default-selection.md b/.changeset/free-shipping-default-selection.md new file mode 100644 index 000000000..a3f20da3b --- /dev/null +++ b/.changeset/free-shipping-default-selection.md @@ -0,0 +1,5 @@ +--- +'@godaddy/react': patch +--- + +Select a free shipping rate by default when one is available, even if a paid rate was already applied automatically or is already on the draft order. A rate the shopper picks explicitly is still kept. diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx index 6d3a85385..ea06205c5 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx @@ -521,6 +521,9 @@ describe('Checkout free / offline orders', () => { apiOverrides: { shippingMethods: buildShippingRates() }, }); await waitForCheckoutReady(); + await user.click( + await screen.findByRole('radio', { name: /weight based/i }) + ); expect( await screen.findByRole('button', { name: /pay now/i }) ).toBeInTheDocument(); diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx new file mode 100644 index 000000000..2f150e1e8 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx @@ -0,0 +1,203 @@ +import { screen, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import type { ShippingMethod } from '@/types'; +import { + advanceCheckoutDebounce, + buildDraftOrder, + buildShippingAddress, + buildShippingRates, + clearOperations, + flushPromises, + getOperations, + renderCheckout, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; + +const freeRate: ShippingMethod = buildShippingRates()[0]; +const weightRate: ShippingMethod = buildShippingRates()[1]; +const expressRate: ShippingMethod = { + ...weightRate, + serviceCode: 'express', + displayName: 'Express', + description: 'Express', + cost: { value: 900, currencyCode: 'USD' }, +}; +const paidOnlyRates: ShippingMethod[] = [weightRate, expressRate]; + +function checkedShippingRadios(): string[] { + return screen + .getAllByRole('radio') + .filter(radio => radio.getAttribute('aria-checked') === 'true') + .map(radio => radio.getAttribute('aria-label') ?? '') + .filter(label => label !== 'Shipping'); +} + +function shippingLine(service: string, value: number) { + return { + requestedService: service, + requestedProvider: 'unknown', + name: service, + amount: { value, currencyCode: 'USD' }, + discounts: [], + }; +} + +function appliedInputs(): unknown[] { + return getOperations('ApplyCheckoutSessionShippingMethod').map( + operation => operation.input + ); +} + +describe('Checkout free shipping default selection', () => { + // H1: a paid shipping line already on the draft order wins over a $0 rate + it('selects and applies the free rate when the draft order already has a paid shipping line', async () => { + renderCheckout({ + draftOrderOverrides: { + shippingLines: [shippingLine('weight-based', 100)], + }, + }); + await waitForCheckoutReady(); + await advanceCheckoutDebounce(2500); + + expect(checkedShippingRadios()).toEqual(['Free FREE']); + expect(appliedInputs()).toContainEqual( + expect.arrayContaining([ + expect.objectContaining({ requestedService: 'free-shipping' }), + ]) + ); + }); + + // H1 control: with no shipping line the first (cheapest) rate is applied + it('applies the free rate when the draft order has no shipping line', async () => { + renderCheckout(); + await waitForCheckoutReady(); + await advanceCheckoutDebounce(2500); + + expect(checkedShippingRadios()).toEqual(['Free FREE']); + expect(appliedInputs()).toEqual([ + [expect.objectContaining({ requestedService: 'free-shipping' })], + ]); + }); + + // H1: a paid rate is auto-applied first, then a $0 rate appears in the rates list + it('selects the free rate when it appears after a paid rate was auto-applied', async () => { + const { queryClient, session } = renderCheckout({ + apiOverrides: { shippingMethods: paidOnlyRates }, + }); + await waitForCheckoutReady(); + await advanceCheckoutDebounce(2500); + + expect(checkedShippingRadios()).toEqual(['Weight Based $1.00']); + expect(appliedInputs()).toHaveLength(1); + clearOperations(); + + const address = + buildDraftOrder().shipping?.address ?? buildShippingAddress(); + act(() => { + queryClient.setQueryData( + [ + ...checkoutQueryKeys.draftOrderShippingMethods(session.id), + { + addressLine1: address.addressLine1, + adminArea1: address.adminArea1, + adminArea2: address.adminArea2, + postalCode: address.postalCode, + countryCode: address.countryCode, + }, + ], + { + checkoutSession: { + draftOrder: { + calculatedShippingRates: { + rates: [freeRate, ...paidOnlyRates], + }, + }, + }, + } + ); + }); + await flushPromises(); + await advanceCheckoutDebounce(2500); + + expect(checkedShippingRadios()).toEqual(['Free FREE']); + expect(appliedInputs()).toContainEqual( + expect.arrayContaining([ + expect.objectContaining({ requestedService: 'free-shipping' }), + ]) + ); + }); + + it('keeps a paid rate the shopper picked when the rates are refreshed with a free rate', async () => { + const { user, queryClient, session } = renderCheckout(); + await waitForCheckoutReady(); + await advanceCheckoutDebounce(2500); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /weight based/i })); + await waitFor(() => { + expect(queryClient.isMutating()).toBe(0); + expect(queryClient.isFetching()).toBe(0); + }); + clearOperations(); + + const address = + buildDraftOrder().shipping?.address ?? buildShippingAddress(); + act(() => { + queryClient.setQueryData( + [ + ...checkoutQueryKeys.draftOrderShippingMethods(session.id), + { + addressLine1: address.addressLine1, + adminArea1: address.adminArea1, + adminArea2: address.adminArea2, + postalCode: address.postalCode, + countryCode: address.countryCode, + }, + ], + { + checkoutSession: { + draftOrder: { + calculatedShippingRates: { + rates: [freeRate, weightRate, expressRate], + }, + }, + }, + } + ); + }); + await flushPromises(); + await advanceCheckoutDebounce(2500); + + expect(checkedShippingRadios()).toEqual(['Weight Based $1.00']); + expect(appliedInputs()).toHaveLength(0); + }); + + it('re-applies the order discount code after switching an auto-applied paid rate to free', async () => { + renderCheckout({ + draftOrderOverrides: { + shippingLines: [shippingLine('weight-based', 100)], + discounts: [ + { + code: 'SAVE', + name: 'SAVE', + amount: { value: 100, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect(appliedInputs()).toEqual([ + [expect.objectContaining({ requestedService: 'free-shipping' })], + ]); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect( + getOperations('ApplyCheckoutSessionDiscount')[0].input + ).toMatchObject({ discountCodes: ['SAVE'] }); + }); +}); diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 4a8a8172a..6744df830 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -98,6 +98,11 @@ export function ShippingMethodForm() { blockedFulfillmentKey: null, }); + // Only an explicit click in this mount counts as the shopper's choice. A + // shipping line already on the draft order may have been auto-applied, so + // it must not keep a free rate from being selected. + const userSelectedServiceCodeRef = useRef(null); + useEffect(() => { if ( isShippingMethodsLoading || @@ -167,7 +172,18 @@ export function ShippingMethodForm() { ? shippingMethods.find(m => m.serviceCode === existingMethod) : null; - const methodToApply = matchedMethod || firstMethod; + const userSelectedServiceCode: string | null = + userSelectedServiceCodeRef.current; + const userSelectedMethod: ShippingMethod | undefined = + userSelectedServiceCode + ? shippingMethods.find(m => m.serviceCode === userSelectedServiceCode) + : undefined; + const freeMethod: ShippingMethod | undefined = shippingMethods.find( + m => m.cost?.value === 0 + ); + + const methodToApply = + userSelectedMethod || freeMethod || matchedMethod || firstMethod; // Check if we've already processed this exact state. If cart contents // changed after a shipping method was selected, shippingLines can still // match the selected rate while new line items are NONE. In that case we @@ -275,6 +291,8 @@ export function ShippingMethodForm() { const previousValue = form.getValues('shippingMethod'); const previousProcessedState = lastProcessedStateRef.current; + const previousUserSelectedServiceCode: string | null = + userSelectedServiceCodeRef.current; form.setValue('shippingMethod', value, { shouldDirty: true, shouldValidate: true, @@ -283,6 +301,8 @@ export function ShippingMethodForm() { const method = shippingMethods?.find(m => m.serviceCode === value); if (method) { + userSelectedServiceCodeRef.current = method.serviceCode || value; + // The mutation success path already recalculates taxes. Optimistically // record the selected method as processed so the post-mutation draft-order // refetch does not look like a new unprocessed shipping state and trigger @@ -314,6 +334,7 @@ export function ShippingMethodForm() { .catch(() => { form.setValue('shippingMethod', previousValue); lastProcessedStateRef.current = previousProcessedState; + userSelectedServiceCodeRef.current = previousUserSelectedServiceCode; }); } }; From 0983902d3d5a01deb4796e1efe08f8b2c2108f54 Mon Sep 17 00:00:00 2001 From: Aaron Nam <112416280+anam-godaddy@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:45:08 -0700 Subject: [PATCH 2/3] Keep an applied free rate and restore the form when auto-apply fails Prefer a $0 rate already on the draft order over another $0 rate that sorts first, so no extra apply is sent. When the auto-apply of a free rate fails, show the rate the draft order still has instead of leaving the free radio checked. AIROBUILD-7255 Co-Authored-By: Claude Opus 5.5 --- .changeset/free-shipping-default-selection.md | 2 +- .../checkout-free-shipping-default.test.tsx | 62 +++++++++++++++---- .../checkout/shipping/shipping-method.tsx | 17 +++-- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/.changeset/free-shipping-default-selection.md b/.changeset/free-shipping-default-selection.md index a3f20da3b..d139db123 100644 --- a/.changeset/free-shipping-default-selection.md +++ b/.changeset/free-shipping-default-selection.md @@ -2,4 +2,4 @@ '@godaddy/react': patch --- -Select a free shipping rate by default when one is available, even if a paid rate was already applied automatically or is already on the draft order. A rate the shopper picks explicitly is still kept. +Select a free shipping rate by default when one is available, even if a paid rate was already applied automatically or is already on the draft order. A rate the shopper picks is kept for the rest of the checkout visit, until the page is reloaded. diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx index 2f150e1e8..91963e795 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx @@ -52,7 +52,6 @@ function appliedInputs(): unknown[] { } describe('Checkout free shipping default selection', () => { - // H1: a paid shipping line already on the draft order wins over a $0 rate it('selects and applies the free rate when the draft order already has a paid shipping line', async () => { renderCheckout({ draftOrderOverrides: { @@ -63,14 +62,54 @@ describe('Checkout free shipping default selection', () => { await advanceCheckoutDebounce(2500); expect(checkedShippingRadios()).toEqual(['Free FREE']); - expect(appliedInputs()).toContainEqual( - expect.arrayContaining([ - expect.objectContaining({ requestedService: 'free-shipping' }), - ]) - ); + expect(appliedInputs()).toEqual([ + [expect.objectContaining({ requestedService: 'free-shipping' })], + ]); + }); + + it('keeps a free rate already on the draft order when another free rate sorts first', async () => { + const standardFreeRate: ShippingMethod = { + ...freeRate, + serviceCode: 'standard-free', + displayName: 'Standard Free', + description: 'Standard Free', + }; + renderCheckout({ + draftOrderOverrides: { + shippingLines: [shippingLine('standard-free', 0)], + }, + apiOverrides: { + shippingMethods: [freeRate, standardFreeRate, weightRate], + }, + }); + await waitForCheckoutReady(); + await advanceCheckoutDebounce(2500); + + expect(checkedShippingRadios()).toEqual(['Standard Free FREE']); + expect(appliedInputs()).toEqual([]); + }); + + it('shows the paid rate still on the draft order when applying the free rate fails', async () => { + renderCheckout({ + draftOrderOverrides: { + shippingLines: [shippingLine('weight-based', 100)], + }, + apiOverrides: { + errors: { applyShippingMethod: new Error('apply failed') }, + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + await advanceCheckoutDebounce(2500); + + await waitFor(() => { + expect(checkedShippingRadios()).toEqual(['Weight Based $1.00']); + }); + expect(appliedInputs()).toEqual([ + [expect.objectContaining({ requestedService: 'free-shipping' })], + ]); }); - // H1 control: with no shipping line the first (cheapest) rate is applied it('applies the free rate when the draft order has no shipping line', async () => { renderCheckout(); await waitForCheckoutReady(); @@ -82,7 +121,6 @@ describe('Checkout free shipping default selection', () => { ]); }); - // H1: a paid rate is auto-applied first, then a $0 rate appears in the rates list it('selects the free rate when it appears after a paid rate was auto-applied', async () => { const { queryClient, session } = renderCheckout({ apiOverrides: { shippingMethods: paidOnlyRates }, @@ -123,11 +161,9 @@ describe('Checkout free shipping default selection', () => { await advanceCheckoutDebounce(2500); expect(checkedShippingRadios()).toEqual(['Free FREE']); - expect(appliedInputs()).toContainEqual( - expect.arrayContaining([ - expect.objectContaining({ requestedService: 'free-shipping' }), - ]) - ); + expect(appliedInputs()).toEqual([ + [expect.objectContaining({ requestedService: 'free-shipping' })], + ]); }); it('keeps a paid rate the shopper picked when the rates are refreshed with a free rate', async () => { diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 6744df830..29fbd2dd7 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -178,9 +178,10 @@ export function ShippingMethodForm() { userSelectedServiceCode ? shippingMethods.find(m => m.serviceCode === userSelectedServiceCode) : undefined; - const freeMethod: ShippingMethod | undefined = shippingMethods.find( - m => m.cost?.value === 0 - ); + const freeMethod: ShippingMethod | undefined = + matchedMethod?.cost?.value === 0 + ? matchedMethod + : shippingMethods.find(m => m.cost?.value === 0); const methodToApply = userSelectedMethod || freeMethod || matchedMethod || firstMethod; @@ -214,7 +215,15 @@ export function ShippingMethodForm() { }; } - applyShippingMethod.mutate(buildShippingPayload(methodToApply)); + // On failure, show the rate the draft order still has. The processed + // state is left as is so the failed rate is not retried in a loop. + applyShippingMethod.mutate(buildShippingPayload(methodToApply), { + onError: () => { + form.setValue('shippingMethod', currentServiceCode ?? '', { + shouldDirty: false, + }); + }, + }); } else if (session?.enableTaxCollection) { updateTaxes.mutate(undefined); } From a33afd2536e578121259f995e2e6262ab5b156da Mon Sep 17 00:00:00 2001 From: Aaron Nam <112416280+anam-godaddy@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:49:27 -0700 Subject: [PATCH 3/3] Reword changeset: a shipping pick is also dropped on switch to local pickup Co-Authored-By: Claude Opus 5.5 --- .changeset/free-shipping-default-selection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/free-shipping-default-selection.md b/.changeset/free-shipping-default-selection.md index d139db123..934cb465a 100644 --- a/.changeset/free-shipping-default-selection.md +++ b/.changeset/free-shipping-default-selection.md @@ -2,4 +2,4 @@ '@godaddy/react': patch --- -Select a free shipping rate by default when one is available, even if a paid rate was already applied automatically or is already on the draft order. A rate the shopper picks is kept for the rest of the checkout visit, until the page is reloaded. +Select a free shipping rate by default when one is available, even if a paid rate was already applied automatically or is already on the draft order. A rate the shopper picks is kept until the page is reloaded or the shopper switches to local pickup.