diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx index 2cefd049e5..5f7b338393 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx @@ -99,6 +99,7 @@ const CheckoutOneClick = () => { const hasDeliveryShipments = deliveryShipments.length > 0 const isPickupOnly = hasPickupShipments && !hasDeliveryShipments const [billingSameAsShipping, setBillingSameAsShipping] = useState(true) + const [isShipmentCleanupComplete, setIsShipmentCleanupComplete] = useState(false) // For billing=shipping, align with legacy: use the first delivery shipment's address const selectedShippingAddress = deliveryShipments.length > 0 ? deliveryShipments[0]?.shippingAddress : null @@ -135,8 +136,23 @@ const CheckoutOneClick = () => { // Remove any empty shipments whenever navigating to the checkout page // Using basketId ensures that the basket is in a valid state before removing empty shipments useEffect(() => { - if (basket?.shipments?.length > 1) { - removeEmptyShipments(basket) + if (!basket?.basketId) { + return + } + if (basket?.shipments?.length <= 1) { + setIsShipmentCleanupComplete(true) + return + } + + let cancelled = false + setIsShipmentCleanupComplete(false) + removeEmptyShipments(basket).then(() => { + if (!cancelled) { + setIsShipmentCleanupComplete(true) + } + }) + return () => { + cancelled = true } }, [basket?.basketId]) @@ -587,7 +603,10 @@ const CheckoutOneClick = () => { /> {hasPickupShipments && } {hasDeliveryShipments && ( - + )} {hasDeliveryShipments && } { w="full" onClick={onPlaceOrder} isLoading={isLoading} - disabled={isOtpLoading || isPlacingOrder} + disabled={ + isOtpLoading || + isPlacingOrder || + !isShipmentCleanupComplete + } data-testid="place-order-button" size="lg" px={8} diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/index.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/index.test.js index 28faa58c7c..c9b45ab1ae 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/index.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/index.test.js @@ -28,6 +28,20 @@ jest.setTimeout(40_000) mockConfig.app.oneClickCheckout.enabled = true +const mockRemoveEmptyShipments = jest.fn().mockResolvedValue(undefined) +jest.mock('@salesforce/retail-react-app/app/hooks/use-multiship', () => { + const actual = jest.requireActual('@salesforce/retail-react-app/app/hooks/use-multiship') + return { + useMultiship: jest.fn((basket) => ({ + ...actual.useMultiship(basket), + removeEmptyShipments: (...args) => { + mockRemoveEmptyShipments(...args) + return Promise.resolve() + } + })) + } +}) + jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => { return { getConfig: jest.fn() @@ -306,6 +320,46 @@ describe('Checkout One Click', () => { ) getConfig.mockImplementation(() => mockConfig) + mockRemoveEmptyShipments.mockClear() + }) + + test('calls removeEmptyShipments when basket has multiple shipments', async () => { + const basketWithMultipleShipments = JSON.parse(JSON.stringify(scapiBasketWithItem)) + basketWithMultipleShipments.shipments = [ + basketWithMultipleShipments.shipments[0], + { + shipmentId: 'shipment-2', + shippingAddress: null, + shippingMethod: null + } + ] + global.server.use( + rest.get('*/baskets', (req, res, ctx) => { + return res( + ctx.json({ + baskets: [basketWithMultipleShipments], + total: 1 + }) + ) + }) + ) + window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout')) + renderWithProviders(, { + wrapperProps: { + isGuest: true, + siteAlias: 'uk', + appConfig: mockConfig.app + } + }) + await waitFor( + () => { + expect(mockRemoveEmptyShipments).toHaveBeenCalled() + const [basket] = mockRemoveEmptyShipments.mock.calls[0] + expect(basket?.basketId).toBe(basketWithMultipleShipments.basketId) + expect(basket?.shipments?.length).toBeGreaterThan(1) + }, + {timeout: 5000} + ) }) test('renders pickup and shipping sections for mixed baskets', async () => { diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.jsx index 125e99f177..f93e110975 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.jsx @@ -45,7 +45,7 @@ const shippingAddressAriaLabel = defineMessage({ }) export default function ShippingAddress(props) { - const {enableUserRegistration = false} = props + const {enableUserRegistration = false, isShipmentCleanupComplete = true} = props const {formatMessage} = useIntl() const [isManualSubmitLoading, setIsManualSubmitLoading] = useState(false) const [isMultiShipping, setIsMultiShipping] = useState(false) @@ -210,6 +210,7 @@ export default function ShippingAddress(props) { getPreferredItem: (addresses) => addresses.find((addr) => addr.preferred === true) || addresses[0], shouldSkip: () => { + if (!isShipmentCleanupComplete) return true if (openedByUser) return true if (selectedShippingAddress?.address1) { if (typeof goToNextStep === 'function') { @@ -223,6 +224,7 @@ export default function ShippingAddress(props) { applyItem: async (address) => { await submitAndContinue(address) }, + enabled: isShipmentCleanupComplete, // Navigation is already handled inside submitAndContinue (goToStep/goToNextStep) onSuccess: () => {}, onError: (error) => { @@ -230,7 +232,7 @@ export default function ShippingAddress(props) { } }) - const isLoading = isAutoSelectLoading || isManualSubmitLoading + const isLoading = isAutoSelectLoading || isManualSubmitLoading || !isShipmentCleanupComplete const handleEdit = () => { setOpenedByUser(true) @@ -311,5 +313,6 @@ export default function ShippingAddress(props) { } ShippingAddress.propTypes = { - enableUserRegistration: PropTypes.bool + enableUserRegistration: PropTypes.bool, + isShipmentCleanupComplete: PropTypes.bool } diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.test.js index ea35326e74..6dabd6cddc 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.test.js @@ -317,6 +317,26 @@ describe('ShippingAddress Component', () => { ).toBeInTheDocument() }) + test('does not run auto-select when isShipmentCleanupComplete is false', async () => { + mockUpdateShippingAddress.mutateAsync.mockResolvedValue({}) + renderWithProviders() + // Allow time for useCheckoutAutoSelect effect to run (it should skip due to enabled: false) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + // Auto-select must not have called updateShippingAddressForShipment + expect(mockUpdateShippingAddress.mutateAsync).not.toHaveBeenCalled() + }) + + test('runs auto-select when isShipmentCleanupComplete is true', async () => { + mockUpdateShippingAddress.mutateAsync.mockResolvedValue({}) + renderWithProviders() + await waitFor(() => { + expect(mockUpdateShippingAddress.mutateAsync).toHaveBeenCalled() + }) + await waitForNotLoading() + }) + test('handles submission errors gracefully', async () => { mockUpdateShippingAddress.mutateAsync.mockRejectedValue(new Error('API Error'))