diff --git a/.changeset/free-shipping-default-selection.md b/.changeset/free-shipping-default-selection.md new file mode 100644 index 000000000..934cb465a --- /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 is kept until the page is reloaded or the shopper switches to local pickup. 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..91963e795 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx @@ -0,0 +1,239 @@ +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', () => { + 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()).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' })], + ]); + }); + + 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' })], + ]); + }); + + 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()).toEqual([ + [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..29fbd2dd7 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,19 @@ 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 = + matchedMethod?.cost?.value === 0 + ? matchedMethod + : 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 @@ -198,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); } @@ -275,6 +300,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 +310,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 +343,7 @@ export function ShippingMethodForm() { .catch(() => { form.setValue('shippingMethod', previousValue); lastProcessedStateRef.current = previousProcessedState; + userSelectedServiceCodeRef.current = previousUserSelectedServiceCode; }); } };