diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index cc83cf034a..9542406b2e 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -1,5 +1,5 @@ -## v4.5.0-dev (Jan 19, 2026) -- Upgrade to commerce-sdk-isomorphic v4.2.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552) +## v5.0.0-dev (Jan 28, 2026) +- Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552) ## v4.4.0-dev (Dec 17, 2025) - [Bugfix]Ensure code_verifier can be optional in resetPassword call [#3567](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3567) diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index 031ccdbd0a..0ef1d77a0f 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -1,12 +1,12 @@ { "name": "@salesforce/commerce-sdk-react", - "version": "4.4.0-dev", + "version": "5.0.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@salesforce/commerce-sdk-react", - "version": "4.4.0-dev", + "version": "5.0.0-dev", "license": "See license in LICENSE", "dependencies": { "commerce-sdk-isomorphic": "5.0.0-preview.1", diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index d701e1b049..d6a4fb68b2 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -1,6 +1,6 @@ { "name": "@salesforce/commerce-sdk-react", - "version": "4.4.0-dev", + "version": "5.0.0-dev", "description": "A library that provides react hooks for fetching data from Commerce Cloud", "homepage": "https://github.com/SalesforceCommerceCloud/pwa-kit/tree/develop/packages/ecom-react-hooks#readme", "bugs": { diff --git a/packages/template-retail-react-app/app/hooks/use-checkout-auto-select.js b/packages/template-retail-react-app/app/hooks/use-checkout-auto-select.js new file mode 100644 index 0000000000..252ada8a60 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-checkout-auto-select.js @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * 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 {useEffect, useRef, useState} from 'react' + +/** + * Generic hook for auto-selecting and applying saved customer data during checkout + * @param {Object} config Configuration object + * @param {number} config.currentStep - Current checkout step + * @param {number} config.targetStep - Step this auto-selection should run on + * @param {boolean} config.isCustomerRegistered - Whether customer is registered + * @param {Array} config.items - List of items to select from (addresses, payments, etc.) + * @param {Function} config.getPreferredItem - Function to find preferred item from list + * @param {Function} config.shouldSkip - Optional function returning boolean if auto-select should be skipped + * @param {Function} config.isAlreadyApplied - Function checking if item is already applied + * @param {Function} config.applyItem - Async function to apply the selected item + * @param {Function} config.onSuccess - Optional callback after successful application + * @param {Function} config.onError - Optional callback after error + * @param {boolean} config.enabled - Whether auto-selection is enabled (default: true) + * @returns {Object} { isLoading, hasExecuted, reset } + */ +export const useCheckoutAutoSelect = ({ + currentStep, + targetStep, + isCustomerRegistered, + items = [], + getPreferredItem, + shouldSkip = () => false, + isAlreadyApplied = () => false, + applyItem, + onSuccess, + onError, + enabled = true +}) => { + const [isLoading, setIsLoading] = useState(false) + const hasExecutedRef = useRef(false) + + const reset = () => { + hasExecutedRef.current = false + setIsLoading(false) + } + + useEffect(() => { + const autoSelect = async () => { + // Early returns for conditions that prevent auto-selection + if (!enabled) return + if (currentStep !== targetStep) return + if (hasExecutedRef.current) return + if (isLoading) return + if (!isCustomerRegistered) return + if (!items || items.length === 0) return + if (shouldSkip()) return + if (isAlreadyApplied()) return + + // Find the preferred item + const preferredItem = getPreferredItem(items) + if (!preferredItem) return + + // Mark as executed before starting to prevent race conditions + hasExecutedRef.current = true + setIsLoading(true) + + try { + // Apply the item + await applyItem(preferredItem) + + // Call success callback if provided + if (onSuccess) { + await onSuccess(preferredItem) + } + } catch (error) { + // Reset on error to allow manual selection + hasExecutedRef.current = false + + // Call error callback if provided + if (onError) { + onError(error) + } + } finally { + setIsLoading(false) + } + } + + autoSelect() + }, [currentStep, targetStep, isCustomerRegistered, items, isLoading, enabled]) + + return { + isLoading, + hasExecuted: hasExecutedRef.current, + reset + } +} diff --git a/packages/template-retail-react-app/app/hooks/use-checkout-auto-select.test.js b/packages/template-retail-react-app/app/hooks/use-checkout-auto-select.test.js new file mode 100644 index 0000000000..8a7459c750 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-checkout-auto-select.test.js @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * 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 from 'react' +import PropTypes from 'prop-types' +import {render, screen, act, waitFor} from '@testing-library/react' +import {useCheckoutAutoSelect} from '@salesforce/retail-react-app/app/hooks/use-checkout-auto-select' + +const STEP_SHIPPING = 1 +const STEP_PAYMENT = 2 + +function TestWrapper({ + currentStep = STEP_SHIPPING, + targetStep = STEP_SHIPPING, + isCustomerRegistered = true, + items = [{id: 'addr-1', preferred: true}], + getPreferredItem = (list) => list.find((i) => i.preferred) || list[0], + shouldSkip = () => false, + isAlreadyApplied = () => false, + applyItem = jest.fn(() => Promise.resolve()), + onSuccess = jest.fn(), + onError = jest.fn(), + enabled = true +}) { + const result = useCheckoutAutoSelect({ + currentStep, + targetStep, + isCustomerRegistered, + items, + getPreferredItem, + shouldSkip, + isAlreadyApplied, + applyItem, + onSuccess, + onError, + enabled + }) + return ( +
+ {String(result.isLoading)} + {String(result.hasExecuted)} + +
+ ) +} + +TestWrapper.propTypes = { + currentStep: PropTypes.number, + targetStep: PropTypes.number, + isCustomerRegistered: PropTypes.bool, + items: PropTypes.array, + getPreferredItem: PropTypes.func, + shouldSkip: PropTypes.func, + isAlreadyApplied: PropTypes.func, + applyItem: PropTypes.func, + onSuccess: PropTypes.func, + onError: PropTypes.func, + enabled: PropTypes.bool +} + +describe('useCheckoutAutoSelect', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('early returns - does not call applyItem', () => { + test('does not run when enabled is false', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render() + await waitFor(() => { + expect(applyItem).not.toHaveBeenCalled() + }) + }) + + test('does not run when currentStep does not match targetStep', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render( + + ) + await waitFor(() => { + expect(applyItem).not.toHaveBeenCalled() + }) + }) + + test('does not run when isCustomerRegistered is false', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render() + await waitFor(() => { + expect(applyItem).not.toHaveBeenCalled() + }) + }) + + test('does not run when items is null or empty', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + const {rerender} = render() + await waitFor(() => { + expect(applyItem).not.toHaveBeenCalled() + }) + + rerender() + await waitFor(() => { + expect(applyItem).not.toHaveBeenCalled() + }) + }) + + test('does not run when shouldSkip returns true', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render( true} applyItem={applyItem} />) + await waitFor(() => { + expect(applyItem).not.toHaveBeenCalled() + }) + }) + + test('does not run when isAlreadyApplied returns true', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render( true} applyItem={applyItem} />) + await waitFor(() => { + expect(applyItem).not.toHaveBeenCalled() + }) + }) + + test('does not run when getPreferredItem returns null/undefined', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render( null} applyItem={applyItem} />) + await waitFor(() => { + expect(applyItem).not.toHaveBeenCalled() + }) + }) + }) + + describe('when conditions are met', () => { + test('calls applyItem with the preferred item', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + const items = [ + {id: 'addr-1', preferred: false}, + {id: 'addr-2', preferred: true} + ] + render() + + await waitFor(() => { + expect(applyItem).toHaveBeenCalledTimes(1) + expect(applyItem).toHaveBeenCalledWith({id: 'addr-2', preferred: true}) + }) + }) + + test('calls onSuccess with the preferred item after applyItem resolves', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + const onSuccess = jest.fn(() => Promise.resolve()) + const preferred = {id: 'addr-1', preferred: true} + render() + + await waitFor(() => { + expect(applyItem).toHaveBeenCalledWith(preferred) + expect(onSuccess).toHaveBeenCalledWith(preferred) + }) + }) + + test('does not call onSuccess when not provided', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render() + + await waitFor(() => { + expect(applyItem).toHaveBeenCalled() + }) + }) + + test('resets hasExecutedRef and calls onError when applyItem throws', async () => { + const error = new Error('Apply failed') + const applyItem = jest.fn(() => Promise.reject(error)) + const onError = jest.fn() + render() + + await waitFor(() => { + expect(applyItem).toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(error) + }) + // Effect may re-run after error (e.g. when isLoading changes), so onError can be called more than once + expect(onError).toHaveBeenCalled() + }) + + test('runs only once (hasExecutedRef prevents re-run)', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + const {rerender} = render() + + await waitFor(() => { + expect(applyItem).toHaveBeenCalledTimes(1) + }) + + rerender( + + ) + + await waitFor(() => { + expect(applyItem).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('reset', () => { + test('reset is a function that can be called without throwing', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render() + + await waitFor(() => { + expect(applyItem).toHaveBeenCalledTimes(1) + }) + + expect(() => { + act(() => { + screen.getByTestId('reset').click() + }) + }).not.toThrow() + }) + }) + + describe('return value', () => { + test('returns isLoading, hasExecuted, and reset', async () => { + const applyItem = jest.fn(() => Promise.resolve()) + render() + + expect(screen.getByTestId('isLoading')).toBeInTheDocument() + expect(screen.getByTestId('hasExecuted')).toBeInTheDocument() + expect(screen.getByTestId('reset')).toBeInTheDocument() + + await waitFor(() => { + expect(applyItem).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/packages/template-retail-react-app/app/pages/account/payments.jsx b/packages/template-retail-react-app/app/pages/account/payments.jsx index 99f0a22338..a820fac6a4 100644 --- a/packages/template-retail-react-app/app/pages/account/payments.jsx +++ b/packages/template-retail-react-app/app/pages/account/payments.jsx @@ -337,25 +337,12 @@ const AccountPayments = () => { return ( - - - - - - + + + {!isSalesforcePaymentsEnabled && ( diff --git a/packages/template-retail-react-app/app/pages/account/payments.test.js b/packages/template-retail-react-app/app/pages/account/payments.test.js index d7efac063b..143daf7c2d 100644 --- a/packages/template-retail-react-app/app/pages/account/payments.test.js +++ b/packages/template-retail-react-app/app/pages/account/payments.test.js @@ -262,36 +262,6 @@ describe('AccountPayments', () => { expect(screen.getByText(/no saved payment methods/i)).toBeInTheDocument() }) - - test('displays refresh button', () => { - mockUseCurrentCustomer.mockReturnValue({ - data: mockCustomer, - isLoading: false, - error: null - }) - - renderWithProviders() - - expect(screen.getByRole('button', {name: /refresh/i})).toBeInTheDocument() - }) - - test('calls refetch when refresh button is clicked', async () => { - const mockRefetch = jest.fn() - mockUseCurrentCustomer.mockReturnValue({ - data: mockCustomer, - isLoading: false, - error: null, - refetch: mockRefetch - }) - - const {user} = renderWithProviders() - - const refreshButton = screen.getByRole('button', {name: /refresh/i}) - await user.click(refreshButton) - - expect(mockRefetch).toHaveBeenCalledTimes(1) - }) - test('calls refetch when retry button is clicked', async () => { const mockRefetch = jest.fn() mockUseCurrentCustomer.mockReturnValue({ 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 63769b096b..d7faff8cf3 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 @@ -490,20 +490,19 @@ const CheckoutOneClick = () => { } } - // Prepare full card details for saving (only if we have form values for new cards) + // PCI: Cardholder data (CHD) - use only for single submission to API. Do not log, persist, or expose. let fullCardDetails = null if (hasFormValues) { const [expirationMonth, expirationYear] = paymentFormValues.expiry.split('/') fullCardDetails = { holder: paymentFormValues.holder, - number: paymentFormValues.number, // Full card number from form + number: paymentFormValues.number, cardType: getPaymentInstrumentCardType(paymentFormValues.cardType), expirationMonth: parseInt(expirationMonth), expirationYear: parseInt(`20${expirationYear}`) } } - // For saved payments (appliedPayment), we don't need fullCardDetails - // because we're not saving them again - they're already saved + // For saved payments (appliedPayment), we don't need fullCardDetails - they're already saved // Handle payment submission if (isEnteringNewCard && hasFormValues) { @@ -554,6 +553,7 @@ const CheckoutOneClick = () => { if (updatedBasket) { await submitOrder(fullCardDetails) + fullCardDetails = null // Clear reference to CHD after use (PCI: minimize retention) } else { // Billing validation failed, clear overlay setIsPlacingOrder(false) 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 73cdbefb1a..44d5c8ee11 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 @@ -177,6 +177,11 @@ describe('ContactInfo Component', () => { test('updates checkout contact phone when user types phone (guest)', async () => { const {user} = renderWithProviders() const phoneInput = screen.getByLabelText('Phone') + // Wait for ContactInfo's auto-focus on email (100ms) to run first so it doesn't + // steal focus during user.type() and send keystrokes to the email field (CI race). + await act(async () => { + await new Promise((r) => setTimeout(r, 150)) + }) // Type the phone number and wait for it to be formatted await user.type(phoneInput, '7275551234') // Wait for the phone input to have a value (formatted phone number) 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 a00283dcee..b188b571dc 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 @@ -19,6 +19,7 @@ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import {useShopperBasketsMutation, useCustomerType} from '@salesforce/commerce-sdk-react' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' +import {useCheckoutAutoSelect} from '@salesforce/retail-react-app/app/hooks/use-checkout-auto-select' import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' import { getPaymentInstrumentCardType, @@ -258,71 +259,56 @@ const Payment = ({ ) // Auto-select a saved payment instrument for registered customers (run at most once) - const autoAppliedRef = useRef(false) - useEffect(() => { - const autoSelectSavedPayment = async () => { - if (step !== STEPS.PAYMENT || isCustomerLoading) return - if (autoAppliedRef.current) return - // Don't auto-apply when in edit mode - user is manually entering/selecting payment - if (currentIsEditing) return - const isRegistered = customer?.isRegistered - const hasSaved = customer?.paymentInstruments?.length > 0 - const alreadyApplied = (basket?.paymentInstruments?.length || 0) > 0 - // If the shopper is currently typing a new card, skip auto-apply of saved + const {isLoading: isAutoSelectLoading} = useCheckoutAutoSelect({ + currentStep: step, + targetStep: STEPS.PAYMENT, + isCustomerRegistered: customer?.isRegistered, + items: customer?.paymentInstruments, + getPreferredItem: (instruments) => + instruments.find((pi) => pi.default === true) || instruments[0], + shouldSkip: () => { const entered = paymentMethodForm?.getValues?.() const hasEnteredCard = entered?.number && entered?.holder && entered?.expiry - if (!isRegistered || !hasSaved || alreadyApplied || hasEnteredCard) return - autoAppliedRef.current = true - const preferred = - customer.paymentInstruments.find((pi) => pi.default === true) || - customer.paymentInstruments[0] - try { - setIsApplyingSavedPayment(true) - await addPaymentInstrumentToBasket({ - parameters: {basketId: activeBasketIdRef.current || basket?.basketId}, - body: { - amount: basket?.orderTotal || 0, - paymentMethodId: 'CREDIT_CARD', - customerPaymentInstrumentId: preferred.paymentInstrumentId + return currentIsEditing || !!hasEnteredCard + }, + isAlreadyApplied: () => Boolean(appliedPayment), + applyItem: async (paymentInstrument) => { + await addPaymentInstrumentToBasket({ + parameters: { + basketId: activeBasketIdRef.current || basket?.basketId + }, + body: { + amount: basket?.orderTotal || 0, + paymentMethodId: 'CREDIT_CARD', + customerPaymentInstrumentId: paymentInstrument.paymentInstrumentId + } + }) + + if (isPickupOnly && paymentInstrument.billingAddress) { + const addr = {...paymentInstrument.billingAddress} + delete addr.addressId + delete addr.creationDate + delete addr.lastModified + delete addr.preferred + + await updateBillingAddressForBasket({ + body: addr, + parameters: { + basketId: activeBasketIdRef.current || basket.basketId } }) - if (isPickupOnly) { - 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() - // Stay on Payment; place-order button is rendered on Payment step in this flow - } - // Ensure basket is refreshed with payment & billing - await currentBasketQuery.refetch() - } catch (_e) { - // Ignore and allow manual selection - console.error(_e) - } finally { - setIsApplyingSavedPayment(false) + } else if (selectedShippingAddress) { + await onBillingSubmit() } - } - autoSelectSavedPayment() - }, [step, isCustomerLoading]) + }, + onSuccess: async () => await currentBasketQuery.refetch(), + onError: (error) => { + console.error('Failed to auto-select payment:', error) + }, + enabled: !isCustomerLoading + }) + + const effectiveIsApplyingSavedPayment = isAutoSelectLoading || isApplyingSavedPayment const onPaymentMethodChange = async (paymentInstrumentId) => { // Only try to remove payment if there's actually an applied payment @@ -469,7 +455,7 @@ const Payment = ({ isLoading={ paymentMethodForm.formState.isSubmitting || billingAddressForm.formState.isSubmitting || - isApplyingSavedPayment || + effectiveIsApplyingSavedPayment || (isCustomerLoading && !isGuest) } disabled={appliedPayment == null} @@ -480,7 +466,11 @@ const Payment = ({ })} > - {!(customer?.isRegistered && isApplyingSavedPayment && !appliedPayment) ? ( + {!( + customer?.isRegistered && + effectiveIsApplyingSavedPayment && + !appliedPayment + ) ? ( <> @@ -496,7 +486,7 @@ const Payment = ({ - {isApplyingSavedPayment ? null : ( + {effectiveIsApplyingSavedPayment ? null : ( { } ] } - render() - // Wait for form to appear (auto-apply may temporarily hide it) + // Use isEditing so auto-apply is skipped and the form stays visible (avoids CI race) + render( + + ) await screen.findByTestId('payment-form') await user.click(screen.getByText('Select Saved')) // If no error thrown, the path executed successfully @@ -1383,11 +1389,13 @@ describe('Payment Component', () => { } ] } + // Use isEditing so auto-apply is skipped and the form stays visible (avoids CI race) render( ) await screen.findByTestId('payment-form') 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 18ebf1cd34..bb51f56368 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 @@ -31,6 +31,7 @@ import { import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useItemShipmentManagement} from '@salesforce/retail-react-app/app/hooks/use-item-shipment-management' import {useMultiship} from '@salesforce/retail-react-app/app/hooks/use-multiship' +import {useCheckoutAutoSelect} from '@salesforce/retail-react-app/app/hooks/use-checkout-auto-select' import {DEFAULT_SHIPMENT_ID} from '@salesforce/retail-react-app/app/constants' import PropTypes from 'prop-types' @@ -46,8 +47,7 @@ const shippingAddressAriaLabel = defineMessage({ export default function ShippingAddress(props) { const {enableUserRegistration = false} = props const {formatMessage} = useIntl() - const [isLoading, setIsLoading] = useState() - const [hasAutoSelected, setHasAutoSelected] = useState(false) + const [isManualSubmitLoading, setIsManualSubmitLoading] = useState(false) const [isMultiShipping, setIsMultiShipping] = useState(false) const [openedByUser, setOpenedByUser] = useState(false) const {data: customer} = useCurrentCustomer() @@ -91,7 +91,7 @@ export default function ShippingAddress(props) { ) const submitAndContinue = async (address) => { - setIsLoading(true) + setIsManualSubmitLoading(true) try { const { addressId, @@ -195,60 +195,45 @@ export default function ShippingAddress(props) { console.error('Error submitting shipping address:', error) } } finally { - setIsLoading(false) + setIsManualSubmitLoading(false) } } - // Auto-select and apply preferred shipping address for registered users - useEffect(() => { - const autoSelectPreferredAddress = async () => { - // Only auto-select when on this step and haven't already auto-selected - if (step !== STEPS.SHIPPING_ADDRESS || hasAutoSelected || isLoading) { - return - } - - // If user explicitly opened this card, do not auto-advance - if (openedByUser) { - return - } - - // Only proceed if customer is registered and has addresses - if (!customer?.isRegistered || !customer?.addresses?.length) { - return - } - - // Skip to next step if basket already has a shipping address + const {isLoading: isAutoSelectLoading, reset} = useCheckoutAutoSelect({ + currentStep: step, + targetStep: STEPS.SHIPPING_ADDRESS, + isCustomerRegistered: customer?.isRegistered, + items: customer?.addresses, + getPreferredItem: (addresses) => + addresses.find((addr) => addr.preferred === true) || addresses[0], + shouldSkip: () => { + if (openedByUser) return true if (selectedShippingAddress?.address1) { - setHasAutoSelected(true) // Prevent further attempts if (typeof goToNextStep === 'function') { goToNextStep() } - return - } - - // Choose preferred address if set; otherwise fallback to first address - const preferredAddress = - customer.addresses.find((addr) => addr.preferred === true) || customer.addresses[0] - - //Auto-selecting preferred shipping address - // This works for both single and multi-shipment orders: - // - For single shipment: applies address directly - // - For multi-shipment: consolidates all items to one shipment with the preferred address - if (preferredAddress) { - setHasAutoSelected(true) - - try { - // Apply the preferred address and continue to next step - await submitAndContinue(preferredAddress) - } catch (error) { - // Reset on error so user can manually select - setHasAutoSelected(false) - } + return true } + return false + }, + isAlreadyApplied: () => Boolean(selectedShippingAddress?.address1), + applyItem: async (address) => { + await submitAndContinue(address) + }, + // Navigation is already handled inside submitAndContinue (goToStep/goToNextStep) + onSuccess: () => {}, + onError: (error) => { + console.error('Failed to auto-select address:', error) } + }) - autoSelectPreferredAddress() - }, [step, customer, selectedShippingAddress, hasAutoSelected, isLoading, openedByUser]) + const isLoading = isAutoSelectLoading || isManualSubmitLoading + + const handleEdit = () => { + setOpenedByUser(true) + reset() + goToStep(STEPS.SHIPPING_ADDRESS) + } // Reset manual-open flag when leaving this step useEffect(() => { @@ -267,10 +252,7 @@ export default function ShippingAddress(props) { editing={step === STEPS.SHIPPING_ADDRESS} isLoading={isLoading} disabled={step === STEPS.CONTACT_INFO && !selectedShippingAddress} - onEdit={() => { - setOpenedByUser(true) - goToStep(STEPS.SHIPPING_ADDRESS) - }} + onEdit={handleEdit} editLabel={formatMessage({ defaultMessage: 'Change', id: 'toggle_card.action.change' diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx index 15a4b9c194..a549ffa370 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx @@ -29,6 +29,7 @@ import { } from '@salesforce/commerce-sdk-react' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' +import {useCheckoutAutoSelect} from '@salesforce/retail-react-app/app/hooks/use-checkout-auto-select' import {useCurrency} from '@salesforce/retail-react-app/app/hooks' import { isPickupShipment, @@ -47,8 +48,6 @@ export default function ShippingOptions() { const {data: customer} = useCurrentCustomer() const {currency} = useCurrency() const updateShippingMethod = useShopperBasketsMutation('updateShippingMethodForShipment') - const [hasAutoSelected, setHasAutoSelected] = useState(false) - const [isLoading, setIsLoading] = useState(false) const showToast = useToast() const [noMethodsToastShown, setNoMethodsToastShown] = useState(false) // Identify delivery shipments (exclude pickup and those without shipping addresses) @@ -95,6 +94,48 @@ export default function ShippingOptions() { const selectedShippingMethod = targetDeliveryShipment?.shippingMethod const selectedShippingAddress = targetDeliveryShipment?.shippingAddress + // Filter out pickup methods for delivery shipment + const deliveryMethods = + (shippingMethods?.applicableShippingMethods || []).filter( + (method) => !isPickupMethod(method) + ) || [] + + const {isLoading: isAutoSelectLoading} = useCheckoutAutoSelect({ + currentStep: step, + targetStep: STEPS.SHIPPING_OPTIONS, + isCustomerRegistered: customer?.isRegistered, + items: deliveryMethods, + getPreferredItem: (methods) => { + const defaultMethodId = shippingMethods?.defaultShippingMethodId + return methods.find((m) => m.id === defaultMethodId) || methods[0] + }, + shouldSkip: () => { + if (selectedShippingMethod?.id && !isPickupMethod(selectedShippingMethod)) { + const stillValid = deliveryMethods.some((m) => m.id === selectedShippingMethod.id) + if (stillValid) { + goToNextStep() + return true + } + } + return false + }, + isAlreadyApplied: () => false, + applyItem: async (method) => { + await updateShippingMethod.mutateAsync({ + parameters: { + basketId: basket.basketId, + shipmentId: targetDeliveryShipment?.shipmentId || 'me' + }, + body: {id: method.id} + }) + }, + onSuccess: () => goToNextStep(), + onError: (error) => { + console.error('Failed to auto-select shipping method:', error) + }, + enabled: !hasMultipleDeliveryShipments + }) + // Calculate if we should show loading state immediately for auto-selection const shouldShowInitialLoading = useMemo(() => { const filteredMethods = @@ -110,7 +151,6 @@ export default function ShippingOptions() { return ( step === STEPS.SHIPPING_OPTIONS && - !hasAutoSelected && customer?.isRegistered && !selectedShippingMethod?.id && filteredMethods.length > 0 && @@ -118,10 +158,10 @@ export default function ShippingOptions() { defaultMethod && !isPickupMethod(defaultMethod) ) - }, [step, hasAutoSelected, customer, selectedShippingMethod, shippingMethods]) + }, [step, customer, selectedShippingMethod, shippingMethods, STEPS.SHIPPING_OPTIONS]) - // Use calculated loading state or manual loading state - const effectiveIsLoading = Boolean(isLoading) || Boolean(shouldShowInitialLoading) + // Use calculated loading state or auto-select loading state + const effectiveIsLoading = Boolean(isAutoSelectLoading) || Boolean(shouldShowInitialLoading) const form = useForm({ shouldUnregister: false, @@ -165,94 +205,6 @@ export default function ShippingOptions() { } }, [selectedShippingMethod, shippingMethods]) - // Validate existing shipping method for new address or auto-select default for authenticated users - useEffect(() => { - const handleShippingMethodForReturningShopper = async () => { - // Only auto-select when on this step and haven't already auto-selected - if (step !== STEPS.SHIPPING_OPTIONS || hasAutoSelected || isLoading) { - return - } - - // Wait for shipping methods to load and filter out pickup methods - const applicable = - shippingMethods?.applicableShippingMethods?.filter( - (method) => !isPickupMethod(method) - ) || [] - - if (!applicable.length) { - return - } - - // If we already have a shipping method on the basket, validate it against the new address' methods. - // Skip validation if the current method is a pickup method - if (selectedShippingMethod?.id && !isPickupMethod(selectedShippingMethod)) { - const stillValid = applicable.some((m) => m.id === selectedShippingMethod.id) - setHasAutoSelected(true) - if (stillValid) { - // Do not update the basket – keep existing method and proceed to payment - goToNextStep() - return - } - // If existing method is no longer valid, fall through to select/apply a default - } - - // Only proceed with auto-apply for authenticated users when no valid method is present - if (!customer?.isRegistered) { - return - } - - // Find default method, but skip if it's a pickup method - const defaultMethodId = shippingMethods.defaultShippingMethodId - const defaultMethod = - (defaultMethodId && - !isPickupMethod( - shippingMethods.applicableShippingMethods.find( - (m) => m.id === defaultMethodId - ) - ) && - applicable.find((method) => method.id === defaultMethodId)) || - applicable[0] - - if (defaultMethod) { - //Auto-selecting default shipping method - setHasAutoSelected(true) - setIsLoading(true) // Show loading state immediately - - try { - // Apply the default shipping method and continue to next step - await updateShippingMethod.mutateAsync({ - parameters: { - basketId: basket.basketId, - shipmentId: targetDeliveryShipment?.shipmentId || 'me' - }, - body: { - id: defaultMethodId - } - }) - //Default shipping method auto-applied successfully - setIsLoading(false) // Clear loading state before navigation - goToNextStep() - } catch (error) { - // Reset on error so user can manually select - setHasAutoSelected(false) - setIsLoading(false) // Hide loading state on error - } - } - } - - handleShippingMethodForReturningShopper() - }, [ - step, - selectedShippingMethod, - customer, - shippingMethods, - hasAutoSelected, - basket?.basketId, - isLoading, - goToNextStep, - updateShippingMethod - ]) - const submitForm = async ({shippingMethodId}) => { await updateShippingMethod.mutateAsync({ parameters: { @@ -278,16 +230,6 @@ export default function ShippingOptions() { shippingMethods?.applicableShippingMethods?.filter((method) => !isPickupMethod(method)) || [] - const hasApplicableMethods = Boolean(filteredShippingMethods.length > 0) - const isSelectedMethodValid = - hasApplicableMethods && - Boolean( - selectedShippingMethod?.id && - shippingMethods.applicableShippingMethods?.some( - (m) => m.id === selectedShippingMethod.id - ) - ) - const freeLabel = formatMessage({ defaultMessage: 'Free', id: 'checkout_confirmation.label.free' @@ -430,7 +372,7 @@ export default function ShippingOptions() { {!hasMultipleDeliveryShipments && !effectiveIsLoading && - isSelectedMethodValid && + selectedShippingMethod && selectedShippingAddress && ( { hasSpecialChar: value && /[!@#$%^&*(),.?":{}|<>]/.test(value) ? true : false } } - -/** - * Generates a random password that meets the password requirements - * @returns {string} - The generated password - */ -export const generatePassword = () => { - return ( - nanoid(8) + - customAlphabet('1234567890')() + - customAlphabet('!@#$%^&*(),.?":{}|<>')() + - nanoid() - ) -} diff --git a/packages/template-retail-react-app/package-lock.json b/packages/template-retail-react-app/package-lock.json index 2b4a1d9448..66e975c46c 100644 --- a/packages/template-retail-react-app/package-lock.json +++ b/packages/template-retail-react-app/package-lock.json @@ -1,12 +1,12 @@ { "name": "@salesforce/retail-react-app", - "version": "8.4.0-dev", + "version": "9.0.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@salesforce/retail-react-app", - "version": "8.4.0-dev", + "version": "9.0.0-dev", "license": "See license in LICENSE", "dependencies": { "@chakra-ui/icons": "^2.0.19", diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index acac64381c..237569beab 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -1,6 +1,6 @@ { "name": "@salesforce/retail-react-app", - "version": "8.4.0-dev", + "version": "9.0.0-dev", "license": "See license in LICENSE", "author": "cc-pwa-kit@salesforce.com", "ccExtensibility": { @@ -46,7 +46,7 @@ "@loadable/component": "^5.15.3", "@peculiar/webcrypto": "^1.4.2", "@salesforce/cc-datacloud-typescript": "1.1.2", - "@salesforce/commerce-sdk-react": "4.4.0-dev", + "@salesforce/commerce-sdk-react": "5.0.0-dev", "@salesforce/pwa-kit-dev": "3.16.0-dev", "@salesforce/pwa-kit-react-sdk": "3.16.0-dev", "@salesforce/pwa-kit-runtime": "3.16.0-dev", diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 52e1ef67f8..251a8fecda 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -8,9 +8,6 @@ "account.logout_button.button.log_out": { "defaultMessage": "Log Out" }, - "account.payments.action.refresh": { - "defaultMessage": "Refresh" - }, "account.payments.action.retry": { "defaultMessage": "Retry" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 52e1ef67f8..251a8fecda 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -8,9 +8,6 @@ "account.logout_button.button.log_out": { "defaultMessage": "Log Out" }, - "account.payments.action.refresh": { - "defaultMessage": "Refresh" - }, "account.payments.action.retry": { "defaultMessage": "Retry" }, diff --git a/packages/test-commerce-sdk-react/package.json b/packages/test-commerce-sdk-react/package.json index ab1d23df03..9d0be29c93 100644 --- a/packages/test-commerce-sdk-react/package.json +++ b/packages/test-commerce-sdk-react/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@loadable/component": "^5.15.3", - "@salesforce/commerce-sdk-react": "4.4.0-dev", + "@salesforce/commerce-sdk-react": "5.0.0-dev", "@salesforce/pwa-kit-dev": "3.16.0-dev", "@salesforce/pwa-kit-react-sdk": "3.16.0-dev", "@salesforce/pwa-kit-runtime": "3.16.0-dev",