Skip to content

Commit 7196092

Browse files
feat(checkout): CHECKOUT-10151 add billing same as shipping toggle
1 parent 623d21d commit 7196092

8 files changed

Lines changed: 294 additions & 69 deletions

File tree

packages/core/src/app/payment/billingForm/PaymentBillingBlock.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type CheckoutService,
55
createCheckoutService,
66
} from '@bigcommerce/checkout-sdk';
7-
import { fireEvent, render, screen } from '@testing-library/react';
7+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
88
import React, { type FunctionComponent } from 'react';
99

1010
import {
@@ -23,6 +23,7 @@ import { type BillingFormValues } from '../../billing/billingFormConfig';
2323
import { getCheckout } from '../../checkout/checkouts.mock';
2424
import { getStoreConfig } from '../../config/config.mock';
2525
import { getCustomer } from '../../customer/customers.mock';
26+
import { getShippingAddress } from '../../shipping/shipping-addresses.mock';
2627

2728
import { PaymentBillingBlock } from './PaymentBillingBlock';
2829
import { type PaymentBillingFormProps } from './PaymentBillingForm';
@@ -80,6 +81,7 @@ describe('PaymentBillingBlock', () => {
8081
jest.spyOn(checkoutState.data, 'getBillingAddressFields').mockReturnValue(formFields);
8182
jest.spyOn(checkoutState.data, 'getAddressExtraFields').mockReturnValue([]);
8283
jest.spyOn(checkoutState.data, 'getBillingAddress').mockReturnValue(undefined);
84+
jest.spyOn(checkoutState.data, 'getShippingAddress').mockReturnValue(getShippingAddress());
8385

8486
PaymentBillingBlockTest = ({ methodId }) => (
8587
<CheckoutProvider checkoutService={checkoutService}>
@@ -124,6 +126,52 @@ describe('PaymentBillingBlock', () => {
124126
expect(mockCapturedProps.methodId).toBe('amazonpay');
125127
});
126128

129+
it('defaults the same-as-shipping toggle from checkout settings', async () => {
130+
render(<PaymentBillingBlockTest />);
131+
132+
await screen.findByTestId('trigger-persist');
133+
134+
expect(mockCapturedProps.isBillingSameAsShipping).toBe(true);
135+
});
136+
137+
it('copies the shipping address to billing when the toggle is checked', async () => {
138+
render(<PaymentBillingBlockTest />);
139+
140+
await screen.findByTestId('trigger-persist');
141+
142+
mockCapturedProps.onBillingSameAsShippingChange(true);
143+
144+
await new Promise((resolve) => process.nextTick(resolve));
145+
146+
expect(checkoutService.updateBillingAddress).toHaveBeenCalledWith(getShippingAddress());
147+
});
148+
149+
it('does not copy shipping to billing when the toggle is unchecked', async () => {
150+
render(<PaymentBillingBlockTest />);
151+
152+
await screen.findByTestId('trigger-persist');
153+
154+
mockCapturedProps.onBillingSameAsShippingChange(false);
155+
156+
await new Promise((resolve) => process.nextTick(resolve));
157+
158+
expect(checkoutService.updateBillingAddress).not.toHaveBeenCalled();
159+
});
160+
161+
it('remembers the shopper unchecking the toggle across re-renders', async () => {
162+
render(<PaymentBillingBlockTest />);
163+
164+
await screen.findByTestId('trigger-persist');
165+
166+
expect(mockCapturedProps.isBillingSameAsShipping).toBe(true);
167+
168+
act(() => {
169+
mockCapturedProps.onBillingSameAsShippingChange(false);
170+
});
171+
172+
await waitFor(() => expect(mockCapturedProps.isBillingSameAsShipping).toBe(false));
173+
});
174+
127175
it('shows a warning instead of the form when manual entry is restricted and there are no saved addresses', async () => {
128176
jest.spyOn(checkoutState.data, 'getCustomer').mockReturnValue({
129177
...getCustomer(),

packages/core/src/app/payment/billingForm/PaymentBillingBlock.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
5353
getBillingAddressFields,
5454
getAddressExtraFields,
5555
},
56-
// getBillingAddress guarantees the latest state inside async callbacks
56+
// getBillingAddress / getShippingAddress guarantee the latest state inside
57+
// async callbacks
5758
checkoutState: {
58-
data: { getBillingAddress },
59+
data: { getBillingAddress, getShippingAddress },
5960
},
6061
checkoutService,
6162
} = useCheckout(({ data, statuses }) => ({
@@ -84,6 +85,28 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
8485
const customerMessage = checkout.customerMessage;
8586
const billingAddress = getBillingAddress();
8687

88+
const [isBillingSameAsShipping, setIsBillingSameAsShipping] = useState(
89+
config.checkoutSettings.checkoutBillingSameAsShippingEnabled ?? true,
90+
);
91+
92+
const handleBillingSameAsShippingChange = (checked: boolean) => {
93+
setIsBillingSameAsShipping(checked);
94+
95+
if (!checked) {
96+
return;
97+
}
98+
99+
const shippingAddress = getShippingAddress();
100+
101+
if (shippingAddress && !isEqualAddress(shippingAddress, getBillingAddress())) {
102+
checkoutService.updateBillingAddress(shippingAddress).catch((error) => {
103+
if (error instanceof Error) {
104+
onUnhandledError(error);
105+
}
106+
});
107+
}
108+
};
109+
87110
const getFields = useCallback(
88111
(countryCode?: string) =>
89112
getFieldsWithExtraFields(
@@ -163,8 +186,10 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
163186
billingAddress={billingAddress}
164187
customerMessage={customerMessage}
165188
getFields={getFields}
189+
isBillingSameAsShipping={isBillingSameAsShipping}
166190
isLoading={isInitializing}
167191
methodId={methodId}
192+
onBillingSameAsShippingChange={handleBillingSameAsShippingChange}
168193
onPersist={handlePersist}
169194
onUnhandledError={onUnhandledError}
170195
/>

packages/core/src/app/payment/billingForm/PaymentBillingForm.test.tsx

Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
createCheckoutService,
55
} from '@bigcommerce/checkout-sdk';
66
import { noop } from 'lodash';
7-
import React from 'react';
7+
import React, { type FunctionComponent } from 'react';
88

99
import {
1010
CapabilitiesContext,
@@ -35,26 +35,28 @@ describe('PaymentBillingForm', () => {
3535
let setEnsureBillingAddressSaved: jest.Mock;
3636
let defaultProps: PaymentBillingFormProps;
3737

38+
const PaymentBillingFormTest: FunctionComponent<PaymentBillingFormProps> = (props) => (
39+
<CheckoutProvider checkoutService={checkoutService}>
40+
<LocaleContext.Provider value={localeContext}>
41+
<CapabilitiesContext.Provider value={defaultCapabilities}>
42+
<PaymentContext.Provider
43+
value={{
44+
registerEnsureBillingAddressSaved,
45+
disableSubmit: jest.fn(),
46+
setSubmit: jest.fn(),
47+
setValidationSchema: jest.fn(),
48+
hidePaymentSubmitButton: jest.fn(),
49+
}}
50+
>
51+
<PaymentBillingForm {...props} />
52+
</PaymentContext.Provider>
53+
</CapabilitiesContext.Provider>
54+
</LocaleContext.Provider>
55+
</CheckoutProvider>
56+
);
57+
3858
const renderForm = (props: PaymentBillingFormProps) =>
39-
render(
40-
<CheckoutProvider checkoutService={checkoutService}>
41-
<LocaleContext.Provider value={localeContext}>
42-
<CapabilitiesContext.Provider value={defaultCapabilities}>
43-
<PaymentContext.Provider
44-
value={{
45-
setEnsureBillingAddressSaved,
46-
disableSubmit: jest.fn(),
47-
setSubmit: jest.fn(),
48-
setValidationSchema: jest.fn(),
49-
hidePaymentSubmitButton: jest.fn(),
50-
}}
51-
>
52-
<PaymentBillingForm {...props} />
53-
</PaymentContext.Provider>
54-
</CapabilitiesContext.Provider>
55-
</LocaleContext.Provider>
56-
</CheckoutProvider>,
57-
);
59+
render(<PaymentBillingFormTest {...props} />);
5860

5961
beforeEach(() => {
6062
checkoutService = createCheckoutService();
@@ -81,7 +83,9 @@ describe('PaymentBillingForm', () => {
8183
billingAddress: getBillingAddress(),
8284
customerMessage: '',
8385
getFields: () => getFormFields(),
86+
isBillingSameAsShipping: false,
8487
isLoading: false,
88+
onBillingSameAsShippingChange: jest.fn(),
8589
onPersist,
8690
onUnhandledError: noop,
8791
};
@@ -168,6 +172,20 @@ describe('PaymentBillingForm', () => {
168172
expect(onPersist).not.toHaveBeenCalled();
169173
});
170174

175+
it('does not include the same-as-shipping flag in the persisted billing values', async () => {
176+
renderForm(defaultProps);
177+
178+
await waitFor(() =>
179+
expect(capturedEnsureBillingAddressSaved).toEqual(expect.any(Function)),
180+
);
181+
182+
await capturedEnsureBillingAddressSaved?.();
183+
184+
expect(onPersist).toHaveBeenCalledWith(
185+
expect.not.objectContaining({ billingSameAsShipping: expect.anything() }),
186+
);
187+
});
188+
171189
it('reports a persist failure via onUnhandledError and resolves false without throwing', async () => {
172190
const error = new Error('failed to save billing address');
173191
const onUnhandledError = jest.fn();
@@ -183,4 +201,78 @@ describe('PaymentBillingForm', () => {
183201
await expect(capturedEnsureBillingAddressSaved?.()).resolves.toBe(false);
184202
expect(onUnhandledError).toHaveBeenCalledWith(error);
185203
});
204+
205+
describe('billing same as shipping toggle', () => {
206+
it('renders the toggle with the payment-step label', () => {
207+
renderForm(defaultProps);
208+
209+
expect(screen.getByTestId('billingSameAsShipping')).toBeInTheDocument();
210+
expect(screen.getByText('Same as shipping address')).toBeInTheDocument();
211+
});
212+
213+
it('collapses the billing address fields when checked', () => {
214+
renderForm({ ...defaultProps, isBillingSameAsShipping: true });
215+
216+
expect(screen.getByTestId('billingSameAsShipping')).toBeInTheDocument();
217+
expect(screen.queryByText('First Name')).not.toBeInTheDocument();
218+
});
219+
220+
it('shows the billing address fields when unchecked', () => {
221+
renderForm({ ...defaultProps, isBillingSameAsShipping: false });
222+
223+
expect(screen.getByText('First Name')).toBeInTheDocument();
224+
});
225+
226+
it('resolves true without persisting when checked (billing mirrors shipping)', async () => {
227+
renderForm({ ...defaultProps, isBillingSameAsShipping: true });
228+
229+
await waitFor(() =>
230+
expect(capturedEnsureBillingAddressSaved).toEqual(expect.any(Function)),
231+
);
232+
233+
await expect(capturedEnsureBillingAddressSaved?.()).resolves.toBe(true);
234+
expect(onPersist).not.toHaveBeenCalled();
235+
});
236+
237+
it('hides the toggle for static-address methods (e.g. Amazon Pay)', () => {
238+
renderForm({ ...defaultProps, methodId: 'amazonpay' });
239+
240+
expect(screen.queryByTestId('billingSameAsShipping')).not.toBeInTheDocument();
241+
});
242+
243+
it('stays unchecked and does not re-fire on a billing address reinitialize', async () => {
244+
const onBillingSameAsShippingChange = jest.fn();
245+
const props = {
246+
...defaultProps,
247+
isBillingSameAsShipping: false,
248+
billingAddress: getBillingAddress(),
249+
onBillingSameAsShippingChange,
250+
};
251+
252+
const { rerender } = renderForm(props);
253+
254+
expect(screen.getByText('First Name')).toBeInTheDocument();
255+
256+
rerender(
257+
<PaymentBillingFormTest {...props} billingAddress={getEmptyBillingAddress()} />,
258+
);
259+
260+
expect(await screen.findByText('First Name')).toBeInTheDocument();
261+
expect(onBillingSameAsShippingChange).not.toHaveBeenCalledWith(true);
262+
});
263+
264+
it('notifies onBillingSameAsShippingChange when the shopper checks it', async () => {
265+
const onBillingSameAsShippingChange = jest.fn();
266+
267+
renderForm({
268+
...defaultProps,
269+
isBillingSameAsShipping: false,
270+
onBillingSameAsShippingChange,
271+
});
272+
273+
fireEvent.click(screen.getByTestId('billingSameAsShipping'));
274+
275+
await waitFor(() => expect(onBillingSameAsShippingChange).toHaveBeenCalledWith(true));
276+
});
277+
});
186278
});

0 commit comments

Comments
 (0)