diff --git a/packages/template-retail-react-app/app/hooks/use-pickup-shipment.js b/packages/template-retail-react-app/app/hooks/use-pickup-shipment.js index 131dec80f2..c98d2bd9c1 100644 --- a/packages/template-retail-react-app/app/hooks/use-pickup-shipment.js +++ b/packages/template-retail-react-app/app/hooks/use-pickup-shipment.js @@ -68,13 +68,21 @@ export const usePickupShipment = (basket) => { * @returns {Promise} The updated shipment response */ const updatePickupShipment = async (basketId, storeInfo, options = {}) => { - const defaultPickupShippingMethodId = '005' - const {pickupShippingMethodId = defaultPickupShippingMethodId} = options + let {pickupShippingMethodId} = options if (!storeInfo?.inventoryId) { return } + if (!pickupShippingMethodId) { + const {data: fetchedShippingMethods} = await refetchShippingMethods() + pickupShippingMethodId = getPickupShippingMethodId(fetchedShippingMethods) + } + + if (!pickupShippingMethodId) { + throw new Error('No pickup shipping method available for this site') + } + // Update shipment to ensure pickup configuration return await updateShipmentForBasketMutation.mutateAsync({ parameters: { 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 bded2e4fe7..26c9c9f58a 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 @@ -39,12 +39,12 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur import CheckoutSkeleton from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-checkout-skeleton' import UnavailableProductConfirmationModal from '@salesforce/retail-react-app/app/components/unavailable-product-confirmation-modal' import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' +import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils' import OrderSummary from '@salesforce/retail-react-app/app/components/order-summary' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import { API_ERROR_MESSAGE, - TOAST_MESSAGE_REMOVED_ITEM_FROM_CART, - STORE_LOCATOR_IS_ENABLED + TOAST_MESSAGE_REMOVED_ITEM_FROM_CART } from '@salesforce/retail-react-app/app/constants' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import { @@ -78,10 +78,12 @@ const CheckoutOneClick = () => { const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(null) const [isEditingPayment, setIsEditingPayment] = useState(false) - // Only enable BOPIS functionality if the feature toggle is on - const isPickupOrder = STORE_LOCATOR_IS_ENABLED - ? basket?.shipments[0]?.shippingMethod?.c_storePickupEnabled === true - : false + // Compute shipment types + const pickupShipments = basket?.shipments?.filter((s) => isPickupShipment(s)) || [] + const deliveryShipments = basket?.shipments?.filter((s) => !isPickupShipment(s)) || [] + const hasPickupShipments = pickupShipments.length > 0 + const hasDeliveryShipments = deliveryShipments.length > 0 + const isPickupOnly = hasPickupShipments && !hasDeliveryShipments const selectedShippingAddress = basket?.shipments && basket?.shipments[0]?.shippingAddress const selectedBillingAddress = basket?.billingAddress @@ -165,7 +167,7 @@ const CheckoutOneClick = () => { } // For one-click checkout, billing same as shipping by default - const billingSameAsShipping = !isPickupOrder + const billingSameAsShipping = !isPickupOnly const billingAddress = billingSameAsShipping ? selectedShippingAddress : billingAddressForm.getValues() @@ -279,10 +281,6 @@ const CheckoutOneClick = () => { if (customerId && shipping) { // Whitelist fields and strip non-customer fields (e.g., id, _type) const { - addressId: _ignoreAddressId, - creationDate: _ignoreCreation, - lastModified: _ignoreModified, - preferred: _ignorePreferred, address1, address2, city, @@ -412,8 +410,9 @@ const CheckoutOneClick = () => { idps={idps} onRegisteredUserChoseGuest={setRegisteredUserChoseGuest} /> - {isPickupOrder ? : } - {!isPickupOrder && } + {hasPickupShipments && } + {hasDeliveryShipments && } + {hasDeliveryShipments && } { getConfig.mockImplementation(() => mockConfig) }) + test('renders pickup and shipping sections for mixed baskets', async () => { + const mixedBasket = JSON.parse(JSON.stringify(scapiBasketWithItem)) + if (!mixedBasket.productItems || mixedBasket.productItems.length === 0) { + mixedBasket.productItems = [ + { + itemId: 'item-delivery-1', + productId: '701643070725M', + quantity: 1, + price: 19.18, + shipmentId: 'me' + } + ] + } + mixedBasket.productItems.push({ + itemId: 'item-pickup-1', + productId: '701643070725M', + quantity: 1, + price: 19.18, + shipmentId: 'pickup1', + inventoryId: 'inventory_m_store_store1' + }) + mixedBasket.shipments = [ + { + shipmentId: 'me', + shippingAddress: null, + shippingMethod: null + }, + { + shipmentId: 'pickup1', + c_fromStoreId: 'store1', + shippingMethod: {id: 'PICKUP', c_storePickupEnabled: true}, + shippingAddress: { + firstName: 'Store 1', + lastName: 'Pickup', + address1: '1 Market St', + city: 'San Francisco', + postalCode: '94105', + stateCode: 'CA', + countryCode: 'US' + } + } + ] + + global.server.use( + rest.get('*/baskets', (req, res, ctx) => { + return res( + ctx.json({ + baskets: [mixedBasket], + total: 1 + }) + ) + }) + ) + + window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout')) + renderWithProviders(, { + wrapperProps: { + isGuest: true, + siteAlias: 'uk', + appConfig: mockConfig.app + } + }) + + await waitFor(() => { + expect(screen.getByText(/pickup address & information/i)).toBeInTheDocument() + }) + await waitFor(() => { + expect(screen.getByText(/shipping address/i)).toBeInTheDocument() + }) + await waitFor(() => { + expect(screen.getByText(/shipping & gift options/i)).toBeInTheDocument() + }) + }) + afterEach(() => { jest.resetModules() jest.clearAllMocks() diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-cc-radio-group.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-cc-radio-group.test.js index 266ddc62e3..9de0df0e80 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-cc-radio-group.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-cc-radio-group.test.js @@ -6,7 +6,7 @@ */ /* eslint-disable react/prop-types */ import React from 'react' -import {render, screen, waitFor} from '@testing-library/react' +import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import CCRadioGroup from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-cc-radio-group' diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js index 0470e54408..541f5f286f 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import React from 'react' -import {screen, waitFor, fireEvent, cleanup} from '@testing-library/react' +import {screen, waitFor, fireEvent} from '@testing-library/react' import ContactInfo from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import {rest} from 'msw' @@ -13,7 +13,6 @@ import {AuthHelpers} from '@salesforce/commerce-sdk-react' jest.setTimeout(60000) const validEmail = 'test@salesforce.com' -const invalidEmail = 'invalidEmail' const mockAuthHelperFunctions = { [AuthHelpers.LoginRegisteredUserB2C]: {mutateAsync: jest.fn()}, [AuthHelpers.Logout]: {mutateAsync: jest.fn()}, diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx index ba2f0e0a4c..a34a634971 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useState, useMemo, useEffect, useRef, useCallback} from 'react' +import React, {useState, useEffect, useRef, useCallback} from 'react' import PropTypes from 'prop-types' import {defineMessage, FormattedMessage, useIntl} from 'react-intl' import { @@ -46,7 +46,6 @@ const Payment = ({ enableUserRegistration, setEnableUserRegistration, registeredUserChoseGuest = false, - onPaymentMethodSaved, onSavePreferenceChange, onPaymentSubmitted, selectedPaymentMethod, @@ -104,36 +103,6 @@ const Payment = ({ } } - // Detect new payment instruments that aren't in the customer's saved list - const newPaymentInstruments = useMemo(() => { - // Use currentFormPayment if available, otherwise fall back to appliedPayment - const paymentToCheck = currentFormPayment || appliedPayment - - if (!isGuest && paymentToCheck) { - // If customer has no saved payment instruments, any new payment is considered new - if (!customer?.paymentInstruments || customer.paymentInstruments.length === 0) { - return [paymentToCheck] - } - - // Check if current payment instrument is not in saved list - const isNewPayment = !customer.paymentInstruments.some((saved) => { - // Compare the entire payment instrument structure - return ( - saved.paymentCard?.cardType === paymentToCheck.paymentCard?.cardType && - saved.paymentCard?.numberLastDigits === - paymentToCheck.paymentCard?.numberLastDigits && - saved.paymentCard?.holder === paymentToCheck.paymentCard?.holder && - saved.paymentCard?.expirationMonth === - paymentToCheck.paymentCard?.expirationMonth && - saved.paymentCard?.expirationYear === paymentToCheck.paymentCard?.expirationYear - ) - }) - - return isNewPayment ? [paymentToCheck] : [] - } - return [] - }, [isGuest, customer, appliedPayment, currentFormPayment]) - // Watch form values in real-time to detect new payment instruments useEffect(() => { if (paymentMethodForm && !isGuest) { @@ -275,8 +244,27 @@ const Payment = ({ customerPaymentInstrumentId: preferred.paymentInstrumentId } }) - // After auto-apply, if we already have a shipping address, submit billing so we can advance - if (selectedShippingAddress) { + if (isPickupOrder) { + try { + const saved = customer?.paymentInstruments?.find( + (pi) => pi.paymentInstrumentId === preferred.paymentInstrumentId + ) + const addr = saved?.billingAddress + if (addr) { + const cleaned = {...addr} + delete cleaned.addressId + delete cleaned.creationDate + delete cleaned.lastModified + delete cleaned.preferred + await updateBillingAddressForBasket({ + body: cleaned, + parameters: {basketId: activeBasketIdRef.current || basket.basketId} + }) + } + } catch { + // ignore; user can enter billing manually + } + } else if (selectedShippingAddress) { await onBillingSubmit() // Ensure basket is refreshed with payment & billing await currentBasketQuery.refetch() @@ -324,6 +312,27 @@ const Payment = ({ } }) await currentBasketQuery.refetch() + if (isPickupOrder) { + try { + const saved = customer?.paymentInstruments?.find( + (pi) => pi.paymentInstrumentId === paymentInstrumentId + ) + const addr = saved?.billingAddress + if (addr) { + const cleaned = {...addr} + delete cleaned.addressId + delete cleaned.creationDate + delete cleaned.lastModified + delete cleaned.preferred + await updateBillingAddressForBasket({ + body: cleaned, + parameters: {basketId: activeBasketIdRef.current || basket.basketId} + }) + await currentBasketQuery.refetch() + } + } catch { + } + } setIsApplyingSavedPayment(false) onSelectedPaymentMethodChange?.(paymentInstrumentId) } @@ -583,8 +592,6 @@ Payment.propTypes = { setEnableUserRegistration: PropTypes.func, /** Whether a registered user has chosen guest checkout */ registeredUserChoseGuest: PropTypes.bool, - /** Callback when payment method is successfully saved */ - onPaymentMethodSaved: PropTypes.func, /** Callback when save preference changes */ onSavePreferenceChange: PropTypes.func, /** Callback when payment is submitted with full card details */ diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-save-payment-method.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-save-payment-method.jsx index e7885e7e01..28760acd53 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-save-payment-method.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-save-payment-method.jsx @@ -10,7 +10,7 @@ import {Checkbox, Text} from '@salesforce/retail-react-app/app/components/shared import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {FormattedMessage} from 'react-intl' -export default function SavePaymentMethod({paymentInstrument, onSaved, checked}) { +export default function SavePaymentMethod({onSaved, checked}) { const [shouldSave, setShouldSave] = useState(false) const {data: customer} = useCurrentCustomer() @@ -46,8 +46,6 @@ export default function SavePaymentMethod({paymentInstrument, onSaved, checked}) } SavePaymentMethod.propTypes = { - /** The payment instrument to potentially save */ - paymentInstrument: PropTypes.object, /** Callback when checkbox state changes - receives boolean value */ onSaved: PropTypes.func, /** Controlled checked prop to preselect visually */ 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 f89aeef17e..d1f7f922cd 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 @@ -23,9 +23,13 @@ import { import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' -import {Text} from '@salesforce/retail-react-app/app/components/shared/ui' +import {Text, Button, Box} from '@salesforce/retail-react-app/app/components/shared/ui' import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {useSelectedStore} from '@salesforce/retail-react-app/app/hooks/use-selected-store' +import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' +import usePickupShipment from '@salesforce/retail-react-app/app/hooks/use-pickup-shipment' +import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants' const submitButtonMessage = defineMessage({ defaultMessage: 'Continue to Shipping Method', @@ -63,6 +67,34 @@ export default function ShippingAddress() { basket?.shipments?.filter((shipment) => !isPickupShipment(shipment)) || [] const hasMultipleDeliveryShipments = deliveryShipments.length > 1 + const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED + const {selectedStore} = useSelectedStore() + const {navigate} = useNavigation() + const {updatePickupShipment} = usePickupShipment(basket) + + const switchToPickup = async () => { + try { + if (!selectedStore?.inventoryId) { + navigate('/store-locator') + return + } + const refreshed = await currentBasketQuery.refetch() + const latestBasketId = refreshed?.data?.basketId || basket.basketId + await updatePickupShipment(latestBasketId, selectedStore) + await currentBasketQuery.refetch() + goToStep(STEPS.PICKUP_ADDRESS) + } catch (_e) { + toast({ + title: formatMessage({ + defaultMessage: + 'We could not switch to Store Pickup. Please try again or choose a different store.', + id: 'shipping_address.error.switch_to_pickup_failed' + }), + status: 'error' + }) + } + } + const submitAndContinue = async (address) => { setIsLoading(true) try { @@ -248,6 +280,16 @@ export default function ShippingAddress() { /> ) : ( <> + {storeLocatorEnabled && ( + + + + )}