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",