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
9 changes: 9 additions & 0 deletions packages/core/src/app/checkout/CheckoutPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,13 @@ const Checkout = ({
[navigateToNextIncompleteStep, navigateToStep],
);

const handleBillingSameAsShippingChange = useCallback(
(isBillingSameAsShipping: boolean): void => {
setState((prev) => ({ ...prev, isBillingSameAsShipping }));
},
[],
);

const handleShippingSignIn = useCallback((): void => {
setCustomerViewType(CustomerViewType.Login);
}, [setCustomerViewType]);
Expand Down Expand Up @@ -533,12 +540,14 @@ const Checkout = ({
checkEmbeddedSupport={checkEmbeddedSupport}
consignments={consignments}
errorLogger={errorLogger}
isBillingSameAsShipping={isBillingSameAsShipping}
isEmbedded={isEmbedded()}
isUsingMultiShipping={
cart && consignments
? isUsingMultiShipping(consignments, cart.lineItems)
: false
}
onBillingSameAsShippingChange={handleBillingSameAsShippingChange}
onCartChangedError={handleCartChangedError}
onEdit={handleEditStep}
onExpanded={handleExpanded}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/app/checkout/components/PaymentStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const PaymentStep = ({
capabilities,
consignments,
errorLogger,
isBillingSameAsShipping,
onBillingSameAsShippingChange,
onEdit,
onExpanded,
checkEmbeddedSupport,
Expand All @@ -60,12 +62,14 @@ const PaymentStep = ({
capabilities={capabilities}
checkEmbeddedSupport={checkEmbeddedSupport}
errorLogger={errorLogger}
isBillingSameAsShipping={isBillingSameAsShipping}
isEmbedded={isEmbedded()}
isUsingMultiShipping={
cart && consignments
? isUsingMultiShipping(consignments, cart.lineItems)
: false
}
onBillingSameAsShippingChange={onBillingSameAsShippingChange}
onCartChangedError={onCartChangedError}
onFinalize={onFinalize}
onReady={onReady}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/app/payment/Payment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,11 @@ import { getFilteredPaymentMethodsWithDefault } from './paymentMethodFilters';
export interface PaymentProps {
capabilities: Capabilities;
errorLogger: ErrorLogger;
isBillingSameAsShipping?: boolean;
isEmbedded?: boolean;
isUsingMultiShipping?: boolean;
checkEmbeddedSupport?(methodIds: string[]): void; // TODO: We're currently doing this check in multiple places, perhaps we should move it up so this check get be done in a single place instead.
onBillingSameAsShippingChange?(isBillingSameAsShipping: boolean): void;
onCartChangedError?(): void;
onFinalize?(): void;
onFinalizeError?(error: Error): void;
Expand Down Expand Up @@ -783,13 +785,15 @@ const Payment = (
defaultMethodId={props.defaultMethod?.id || ''}
didExceedSpamLimit={state.didExceedSpamLimit}
disableStoreCredit={disableStoreCredit}
isBillingSameAsShipping={props.isBillingSameAsShipping}
isEmbedded={props.isEmbedded}
isInitializingPayment={props.isInitializingPayment}
isPaymentDataRequired={props.isPaymentDataRequired}
isStoreCreditApplied={props.isStoreCreditApplied}
isTermsConditionsRequired={props.isTermsConditionsRequired}
isUsingMultiShipping={props.isUsingMultiShipping}
methods={props.methods}
onBillingSameAsShippingChange={props.onBillingSameAsShippingChange}
onMethodSelect={setSelectedMethod}
onStoreCreditChange={handleStoreCreditChange}
onSubmit={handleSubmit}
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/app/payment/PaymentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface PaymentFormProps {
defaultGatewayId?: string;
defaultMethodId: string;
didExceedSpamLimit?: boolean;
isBillingSameAsShipping?: boolean;
isEmbedded?: boolean;
isInitializingPayment?: boolean;
isTermsConditionsRequired?: boolean;
Expand All @@ -68,6 +69,7 @@ export interface PaymentFormProps {
usableStoreCredit?: number;
validationSchema?: ObjectSchema<Partial<PaymentFormValues>>;
isPaymentDataRequired(): boolean;
onBillingSameAsShippingChange?(isBillingSameAsShipping: boolean): void;
onMethodSelect?(method: PaymentMethod): void;
onStoreCreditChange?(useStoreCredit?: boolean): void;
onSubmit?(values: PaymentFormValues): void;
Expand All @@ -81,6 +83,7 @@ const PaymentForm: FunctionComponent<
availableStoreCredit = 0,
disableStoreCredit = false,
didExceedSpamLimit,
isBillingSameAsShipping,
isEmbedded,
isInitializingPayment,
isPaymentDataRequired,
Expand All @@ -89,6 +92,7 @@ const PaymentForm: FunctionComponent<
isUsingMultiShipping,
language,
methods,
onBillingSameAsShippingChange,
onMethodSelect,
onStoreCreditChange,
onUnhandledError,
Expand Down Expand Up @@ -202,7 +206,9 @@ const PaymentForm: FunctionComponent<

{themeV2 && (
<PaymentBillingBlock
isBillingSameAsShipping={isBillingSameAsShipping ?? true}
methodId={selectedMethod?.id}
onBillingSameAsShippingChange={onBillingSameAsShippingChange ?? noop}
onUnhandledError={onUnhandledError ?? noop}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type CheckoutService,
createCheckoutService,
} from '@bigcommerce/checkout-sdk';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React, { type FunctionComponent } from 'react';

import {
Expand All @@ -23,6 +23,7 @@ import { type BillingFormValues } from '../../billing/billingFormConfig';
import { getCheckout } from '../../checkout/checkouts.mock';
import { getStoreConfig } from '../../config/config.mock';
import { getCustomer } from '../../customer/customers.mock';
import { getShippingAddress } from '../../shipping/shipping-addresses.mock';

import { PaymentBillingBlock } from './PaymentBillingBlock';
import { type PaymentBillingFormProps } from './PaymentBillingForm';
Expand All @@ -31,9 +32,6 @@ let mockCapturedProps: PaymentBillingFormProps;

let mockPersistValues: BillingFormValues;

// Stand in for PaymentBillingForm: capture the props the container passes and
// expose a button that invokes onPersist, so we can test the container's
// persistence wiring without driving the real address form.
jest.mock('./PaymentBillingForm', () => ({
PaymentBillingForm: (props: PaymentBillingFormProps) => {
mockCapturedProps = props;
Expand Down Expand Up @@ -80,19 +78,28 @@ describe('PaymentBillingBlock', () => {
jest.spyOn(checkoutState.data, 'getBillingAddressFields').mockReturnValue(formFields);
jest.spyOn(checkoutState.data, 'getAddressExtraFields').mockReturnValue([]);
jest.spyOn(checkoutState.data, 'getBillingAddress').mockReturnValue(undefined);

PaymentBillingBlockTest = ({ methodId }) => (
<CheckoutProvider checkoutService={checkoutService}>
<LocaleContext.Provider value={localeContext}>
<CapabilitiesContext.Provider value={defaultCapabilities}>
<PaymentBillingBlock
methodId={methodId}
onUnhandledError={onUnhandledError}
/>
</CapabilitiesContext.Provider>
</LocaleContext.Provider>
</CheckoutProvider>
);
jest.spyOn(checkoutState.data, 'getShippingAddress').mockReturnValue(getShippingAddress());

PaymentBillingBlockTest = ({ methodId }) => {
const [isBillingSameAsShipping, setIsBillingSameAsShipping] = React.useState(
getStoreConfig().checkoutSettings.checkoutBillingSameAsShippingEnabled ?? true,
);

return (
<CheckoutProvider checkoutService={checkoutService}>
<LocaleContext.Provider value={localeContext}>
<CapabilitiesContext.Provider value={defaultCapabilities}>
<PaymentBillingBlock
isBillingSameAsShipping={isBillingSameAsShipping}
methodId={methodId}
onBillingSameAsShippingChange={setIsBillingSameAsShipping}
onUnhandledError={onUnhandledError}
/>
</CapabilitiesContext.Provider>
</LocaleContext.Provider>
</CheckoutProvider>
);
};
});

it('renders the billing address heading', async () => {
Expand Down Expand Up @@ -124,6 +131,74 @@ describe('PaymentBillingBlock', () => {
expect(mockCapturedProps.methodId).toBe('amazonpay');
});

it('defaults the same-as-shipping toggle from checkout settings', async () => {
render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

expect(mockCapturedProps.isBillingSameAsShipping).toBe(true);
});

it('copies the shipping address to billing when the toggle is checked', async () => {
render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

act(() => {
mockCapturedProps.onBillingSameAsShippingChange(true);
});

await new Promise((resolve) => process.nextTick(resolve));

expect(checkoutService.updateBillingAddress).toHaveBeenCalledWith(getShippingAddress());
});

it('reverts the toggle and reports the error when copying shipping to billing fails', async () => {
const error = new Error('update failed');

jest.spyOn(checkoutService, 'updateBillingAddress').mockRejectedValue(error);

render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

act(() => {
mockCapturedProps.onBillingSameAsShippingChange(true);
});

await waitFor(() => expect(mockCapturedProps.isBillingSameAsShipping).toBe(false));

expect(onUnhandledError).toHaveBeenCalledWith(error);
});

it('does not copy shipping to billing when the toggle is unchecked', async () => {
render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

act(() => {
mockCapturedProps.onBillingSameAsShippingChange(false);
});

await new Promise((resolve) => process.nextTick(resolve));

expect(checkoutService.updateBillingAddress).not.toHaveBeenCalled();
});

it('remembers the shopper unchecking the toggle across re-renders', async () => {
render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

expect(mockCapturedProps.isBillingSameAsShipping).toBe(true);

act(() => {
mockCapturedProps.onBillingSameAsShippingChange(false);
});

await waitFor(() => expect(mockCapturedProps.isBillingSameAsShipping).toBe(false));
});

it('shows a warning instead of the form when manual entry is restricted and there are no saved addresses', async () => {
jest.spyOn(checkoutState.data, 'getCustomer').mockReturnValue({
...getCustomer(),
Expand All @@ -142,7 +217,11 @@ describe('PaymentBillingBlock', () => {
},
}}
>
<PaymentBillingBlock onUnhandledError={onUnhandledError} />
<PaymentBillingBlock
isBillingSameAsShipping={true}
onBillingSameAsShippingChange={jest.fn()}
onUnhandledError={onUnhandledError}
/>
</CapabilitiesContext.Provider>
</LocaleContext.Provider>
</CheckoutProvider>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface PaymentBillingBlockProps {
// billing form's method-specific behaviour (e.g. Amazon Pay's static address
// + reduced schema). Must reflect the live selection, not checkout.payments.
methodId?: string;
isBillingSameAsShipping: boolean;
onBillingSameAsShippingChange(isBillingSameAsShipping: boolean): void;
onUnhandledError(error: Error): void;
}

Expand All @@ -42,6 +44,8 @@ const getFieldsWithExtraFields = (

export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> = ({
methodId,
isBillingSameAsShipping,
onBillingSameAsShippingChange,
onUnhandledError,
}) => {
const {
Expand All @@ -53,9 +57,8 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
getBillingAddressFields,
getAddressExtraFields,
},
// getBillingAddress guarantees the latest state inside async callbacks
checkoutState: {
data: { getBillingAddress },
data: { getBillingAddress, getShippingAddress },
},
checkoutService,
} = useCheckout(({ data, statuses }) => ({
Expand Down Expand Up @@ -84,6 +87,26 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
const customerMessage = checkout.customerMessage;
const billingAddress = getBillingAddress();

const handleBillingSameAsShippingChange = (checked: boolean) => {
onBillingSameAsShippingChange(checked);

if (!checked) {
return;
}

const shippingAddress = getShippingAddress();

if (shippingAddress && !isEqualAddress(shippingAddress, getBillingAddress())) {
checkoutService.updateBillingAddress(shippingAddress).catch((error) => {
onBillingSameAsShippingChange(false);

if (error instanceof Error) {
onUnhandledError(error);
}
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
};
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

const getFields = useCallback(
(countryCode?: string) =>
getFieldsWithExtraFields(
Expand All @@ -96,9 +119,7 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
);

// Persist without navigating — the payment step's "Place Order" is the only
// submit. Called by PaymentBillingForm's pre-submit save; the isEqualAddress
// guard prevents a redundant updateBillingAddress when nothing changed. Errors
// propagate so the pre-submit save can block the order.
// submit. Called by PaymentBillingForm's pre-submit save;
const handlePersist = async ({
orderComment,
...addressValues
Expand Down Expand Up @@ -163,8 +184,10 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
billingAddress={billingAddress}
customerMessage={customerMessage}
getFields={getFields}
isBillingSameAsShipping={isBillingSameAsShipping}
isLoading={isInitializing}
methodId={methodId}
onBillingSameAsShippingChange={handleBillingSameAsShippingChange}
onPersist={handlePersist}
onUnhandledError={onUnhandledError}
/>
Expand Down
Loading