Skip to content
Merged
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/free-shipping-default-selection.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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'] });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);

useEffect(() => {
if (
isShippingMethodsLoading ||
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -314,6 +343,7 @@ export function ShippingMethodForm() {
.catch(() => {
form.setValue('shippingMethod', previousValue);
lastProcessedStateRef.current = previousProcessedState;
userSelectedServiceCodeRef.current = previousUserSelectedServiceCode;
});
}
};
Expand Down
Loading