diff --git a/packages/template-retail-react-app/app/components/return-items-modal/constants.js b/packages/template-retail-react-app/app/components/return-items-modal/constants.js
new file mode 100644
index 0000000000..eb7c8f840b
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/return-items-modal/constants.js
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2026, 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 {defineMessages} from 'react-intl'
+
+export const messages = defineMessages({
+ title: {
+ defaultMessage: 'Return items from order #{orderNo}',
+ id: 'return_items_modal.heading.return_items'
+ },
+ subhead: {
+ defaultMessage: 'Select the items you want to return and tell us why.',
+ id: 'return_items_modal.text.select_items_description'
+ },
+ availableToReturn: {
+ defaultMessage: 'Up to {count, plural, one {# unit} other {# units}} available to return',
+ id: 'return_items_modal.text.available_to_return'
+ },
+ quantityLabel: {
+ defaultMessage: 'Quantity',
+ id: 'return_items_modal.label.quantity'
+ },
+ reasonLabel: {
+ defaultMessage: 'Reason',
+ id: 'return_items_modal.label.reason'
+ },
+ reasonFor: {
+ defaultMessage: 'Reason for {name}',
+ id: 'return_items_modal.label.reason_for'
+ },
+ selectReasonPlaceholder: {
+ defaultMessage: 'Select a reason',
+ id: 'return_items_modal.placeholder.select_reason'
+ },
+ cancelButton: {
+ defaultMessage: 'Cancel',
+ id: 'return_items_modal.button.cancel'
+ },
+ reviewButton: {
+ defaultMessage: 'Review return',
+ id: 'return_items_modal.button.review_return'
+ },
+ reviewDisabledHint: {
+ defaultMessage: 'Select at least one item and choose a reason to continue.',
+ id: 'return_items_modal.hint.review_disabled'
+ },
+ loadingReasons: {
+ defaultMessage: 'Loading return reasons…',
+ id: 'return_items_modal.text.loading_reasons'
+ },
+ reasonsError: {
+ defaultMessage: 'We could not load the return reasons. Please try again.',
+ id: 'return_items_modal.text.reasons_error'
+ },
+ retryButton: {
+ defaultMessage: 'Retry',
+ id: 'return_items_modal.button.retry'
+ },
+ itemCheckboxLabel: {
+ defaultMessage: '{name}, {count, plural, one {# unit} other {# units}} available to return',
+ id: 'return_items_modal.label.item_checkbox'
+ }
+})
diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx
new file mode 100644
index 0000000000..66c1c405f0
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx
@@ -0,0 +1,451 @@
+/*
+ * Copyright (c) 2026, 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, {useCallback, useEffect, useId, useMemo, useRef} from 'react'
+import PropTypes from 'prop-types'
+import {FormattedMessage, useIntl} from 'react-intl'
+import {useOmsMetaData} from '@salesforce/commerce-sdk-react'
+import {
+ Alert,
+ AlertDescription,
+ AlertIcon,
+ Box,
+ Button,
+ Checkbox,
+ Drawer,
+ DrawerBody,
+ DrawerCloseButton,
+ DrawerContent,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerOverlay,
+ FormControl,
+ FormLabel,
+ HStack,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Select,
+ SimpleGrid,
+ Skeleton,
+ Stack,
+ Text,
+ VisuallyHidden,
+ useBreakpointValue
+} from '@salesforce/retail-react-app/app/components/shared/ui'
+import QuantityPicker from '@salesforce/retail-react-app/app/components/quantity-picker'
+import {getDisplayVariationValues} from '@salesforce/retail-react-app/app/utils/product-utils'
+import {buildReturnProductItems} from '@salesforce/retail-react-app/app/utils/return-utils'
+import {messages} from '@salesforce/retail-react-app/app/components/return-items-modal/constants'
+
+const onClient = typeof window !== 'undefined'
+
+/**
+ * Format the variant attributes a shopper sees inline next to the product name.
+ *
+ * `getDisplayVariationValues` returns a `{label: value}` object; the design
+ * shows the values joined with " / " (e.g. "Black / M"). Returns an empty
+ * string when product enrichment is not yet loaded so callers can fall back to
+ * plain `productName`.
+ */
+const formatVariationSummary = (item) => {
+ if (!item?.variationAttributes?.length) return ''
+ const values = getDisplayVariationValues(item.variationAttributes, item.variationValues)
+ return Object.values(values || {})
+ .filter(Boolean)
+ .join(' / ')
+}
+
+const ReturnableItemRow = React.memo(function ReturnableItemRow({
+ item,
+ row,
+ reasons,
+ onToggle,
+ onQuantityChange,
+ onReasonChange
+}) {
+ const intl = useIntl()
+ const checkboxId = useId()
+ const quantityId = useId()
+ const reasonId = useId()
+ const max = item.omsData?.quantityAvailableToReturn ?? 1
+ const variation = formatVariationSummary(item)
+ const displayName = variation ? `${item.productName} — ${variation}` : item.productName || ''
+ const itemId = item.itemId
+
+ const handleCheckboxChange = useCallback(
+ (e) => onToggle(item, e.target.checked),
+ [onToggle, item]
+ )
+ const handleQuantityPickerChange = useCallback(
+ (_str, num) => {
+ if (Number.isFinite(num)) onQuantityChange(itemId, num)
+ },
+ [onQuantityChange, itemId]
+ )
+ const handleReasonSelectChange = useCallback(
+ (e) => onReasonChange(itemId, e.target.value),
+ [onReasonChange, itemId]
+ )
+
+ return (
+
+
+
+
+
+ {displayName}
+
+
+
+
+ {row?.checked && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ )
+})
+ReturnableItemRow.propTypes = {
+ item: PropTypes.object.isRequired,
+ row: PropTypes.object,
+ reasons: PropTypes.array.isRequired,
+ onToggle: PropTypes.func.isRequired,
+ onQuantityChange: PropTypes.func.isRequired,
+ onReasonChange: PropTypes.func.isRequired
+}
+
+/**
+ * Returns the OMS reason code marked `default: true`, or `undefined` if no
+ * default exists (defensive — the API guarantees one).
+ */
+const findDefaultReasonCode = (reasons = []) => reasons.find((r) => r.default)?.reason
+
+const isSelectionValid = (selection, returnableItems) => {
+ const selectedRows = Object.entries(selection || {}).filter(([, row]) => row?.checked)
+ if (selectedRows.length === 0) return false
+ return selectedRows.every(([itemId, row]) => {
+ const item = returnableItems.find((i) => i.itemId === itemId)
+ const max = item?.omsData?.quantityAvailableToReturn ?? 0
+ const qty = Number(row.quantity)
+ return Number.isFinite(qty) && qty >= 1 && qty <= max && !!row.reasonCode
+ })
+}
+
+/**
+ * Step 1 of the return flow. Renders as a centered Modal at `md+` and a
+ * bottom-sheet Drawer on `base` (per design). Selection state lives in the
+ * parent (`order-detail.jsx`) so the wrapper swap on viewport resize doesn't
+ * reset what the shopper has chosen.
+ *
+ * The follow-up review step (W-22821838) is a sibling view inside the same
+ * modal stack. This component invokes `onReview(payload)` with the API-shaped
+ * `productItems` array when the shopper clicks **Review return**.
+ */
+const ReturnItemsModal = ({
+ isOpen,
+ onClose,
+ order,
+ returnableItems,
+ selection,
+ onSelectionChange,
+ onReview
+}) => {
+ const isMobile = useBreakpointValue({base: true, md: false})
+ const reviewDisabledHintId = useId()
+
+ const reviewQuery = useOmsMetaData(
+ {},
+ {
+ enabled: isOpen && onClient
+ }
+ )
+ const reasons = reviewQuery.data?.returnReasonCodes || []
+ const defaultReasonCode = useMemo(() => findDefaultReasonCode(reasons), [reasons])
+
+ // Functional updater so two toggles dispatched in the same React batch
+ // both observe the latest selection. Closing over `selection` would cause
+ // the second update to spread a stale object and clobber the first.
+ const updateRow = useCallback(
+ (itemId, patch) => {
+ onSelectionChange((prev) => ({
+ ...(prev || {}),
+ [itemId]: {...((prev || {})[itemId] || {}), ...patch}
+ }))
+ },
+ [onSelectionChange]
+ )
+
+ const handleToggle = useCallback(
+ (item, checked) => {
+ const itemId = item.itemId
+ onSelectionChange((prev) => {
+ const existing = (prev || {})[itemId]
+ return {
+ ...(prev || {}),
+ [itemId]: {
+ checked,
+ quantity: existing?.quantity ?? 1,
+ reasonCode:
+ existing?.reasonCode || (checked ? defaultReasonCode : undefined)
+ }
+ }
+ })
+ },
+ [onSelectionChange, defaultReasonCode]
+ )
+
+ const handleQuantityChange = useCallback(
+ (itemId, qty) => {
+ updateRow(itemId, {quantity: qty})
+ },
+ [updateRow]
+ )
+
+ const handleReasonChange = useCallback(
+ (itemId, reasonCode) => {
+ updateRow(itemId, {reasonCode})
+ },
+ [updateRow]
+ )
+
+ // Once reasons load, retro-fill default reason on already-checked rows
+ // that lacked a reasonCode (e.g. modal opened before metadata resolved).
+ const didBackfillRef = useRef(false)
+ useEffect(() => {
+ if (!isOpen) {
+ didBackfillRef.current = false
+ return
+ }
+ if (didBackfillRef.current || !defaultReasonCode || !selection) return
+ let next = selection
+ let changed = false
+ Object.entries(selection).forEach(([itemId, row]) => {
+ if (row?.checked && !row.reasonCode) {
+ if (!changed) {
+ next = {...selection}
+ changed = true
+ }
+ next[itemId] = {...row, reasonCode: defaultReasonCode}
+ }
+ })
+ if (changed) onSelectionChange(next)
+ didBackfillRef.current = true
+ }, [isOpen, defaultReasonCode, selection, onSelectionChange])
+
+ const reviewEnabled = useMemo(
+ () => isSelectionValid(selection, returnableItems),
+ [selection, returnableItems]
+ )
+
+ const handleReview = useCallback(() => {
+ // Guard the action: the button stays focusable while invalid (see the
+ // aria-disabled note on the Review button) so we must no-op the click
+ // ourselves rather than rely on the native disabled attribute.
+ if (!reviewEnabled) return
+ const payload = buildReturnProductItems(selection, defaultReasonCode)
+ onReview(payload)
+ }, [reviewEnabled, selection, defaultReasonCode, onReview])
+
+ const body = reviewQuery.isLoading ? (
+
+
+
+
+
+
+
+ ) : reviewQuery.isError ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ {returnableItems.map((item) => (
+
+ ))}
+
+ )
+
+ // Title sits in the header so Chakra wires it as the dialog's
+ // `aria-labelledby`. The subhead leads the body — Chakra auto-sets the
+ // dialog's `aria-describedby` to the ModalBody/DrawerBody id, so keeping
+ // the subhead first in the body is what actually makes it the accessible
+ // description (passing `aria-describedby` to ModalContent is silently
+ // overridden by Chakra's getDialogProps). Mirrors cancel-order-modal.
+ const header = (
+
+
+
+ )
+
+ const subhead = (
+
+
+
+ )
+
+ const footer = (
+
+
+
+
+
+
+
+ )
+
+ if (isMobile) {
+ return (
+
+
+
+ {header}
+
+
+ {subhead}
+ {body}
+
+ {footer}
+
+
+ )
+ }
+
+ return (
+
+
+
+ {header}
+
+
+ {subhead}
+ {body}
+
+ {footer}
+
+
+ )
+}
+
+ReturnItemsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ order: PropTypes.object,
+ returnableItems: PropTypes.array.isRequired,
+ selection: PropTypes.object,
+ onSelectionChange: PropTypes.func.isRequired,
+ onReview: PropTypes.func.isRequired
+}
+
+export default ReturnItemsModal
diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js
new file mode 100644
index 0000000000..ffe12c5bc3
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js
@@ -0,0 +1,303 @@
+/*
+ * Copyright (c) 2026, 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, {useState} from 'react'
+import PropTypes from 'prop-types'
+import {act, fireEvent, screen, waitFor, within} from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
+import {useBreakpointValue} from '@salesforce/retail-react-app/app/components/shared/ui'
+import ReturnItemsModal from '@salesforce/retail-react-app/app/components/return-items-modal'
+
+// Mock only useBreakpointValue so we can drive the desktop Modal vs. mobile
+// Drawer branch deterministically; everything else stays the real component.
+// Default (undefined) is falsy → desktop Modal, matching the suite's default.
+jest.mock('@salesforce/retail-react-app/app/components/shared/ui', () => {
+ const originalModule = jest.requireActual(
+ '@salesforce/retail-react-app/app/components/shared/ui'
+ )
+ return {
+ ...originalModule,
+ useBreakpointValue: jest.fn()
+ }
+})
+
+let mockOmsMetaData = {
+ data: {
+ cancelReasonCodes: [],
+ returnReasonCodes: [
+ {reason: 'Wrong size', default: true},
+ {reason: 'Defect', default: false},
+ {reason: 'Changed my mind', default: false}
+ ]
+ },
+ isLoading: false,
+ isError: false,
+ refetch: jest.fn()
+}
+jest.mock('@salesforce/commerce-sdk-react', () => {
+ const actual = jest.requireActual('@salesforce/commerce-sdk-react')
+ return {
+ ...actual,
+ useOmsMetaData: () => mockOmsMetaData
+ }
+})
+
+const baseOrder = {
+ orderNo: '00123456',
+ productItems: [
+ {
+ itemId: 'item-1',
+ productId: 'prod-1',
+ productName: 'Cotton Crew T-Shirt',
+ quantity: 2,
+ omsData: {quantityAvailableToReturn: 2},
+ variationAttributes: [
+ {id: 'color', name: 'Color', values: [{value: 'BLACK', name: 'Black'}]},
+ {id: 'size', name: 'Size', values: [{value: 'M', name: 'M'}]}
+ ],
+ variationValues: {color: 'BLACK', size: 'M'}
+ },
+ {
+ itemId: 'item-2',
+ productId: 'prod-2',
+ productName: 'Slim Fit Chino Pants',
+ quantity: 1,
+ omsData: {quantityAvailableToReturn: 1}
+ }
+ ]
+}
+
+const Harness = ({onReview = jest.fn(), onClose = jest.fn(), initialSelection = {}} = {}) => {
+ const [selection, setSelection] = useState(initialSelection)
+ return (
+
+ )
+}
+Harness.propTypes = {
+ onReview: PropTypes.func,
+ onClose: PropTypes.func,
+ initialSelection: PropTypes.object
+}
+
+afterEach(() => {
+ mockOmsMetaData = {
+ data: {
+ cancelReasonCodes: [],
+ returnReasonCodes: [
+ {reason: 'Wrong size', default: true},
+ {reason: 'Defect', default: false},
+ {reason: 'Changed my mind', default: false}
+ ]
+ },
+ isLoading: false,
+ isError: false,
+ refetch: jest.fn()
+ }
+ jest.clearAllMocks()
+ // clearAllMocks wipes the implementation too; restore the default
+ // (undefined → desktop Modal branch) so order-independent tests are stable.
+ useBreakpointValue.mockReturnValue(undefined)
+})
+
+test('renders header with order number and a row per returnable item', async () => {
+ renderWithProviders()
+ expect(await screen.findByText(/return items from order #00123456/i)).toBeInTheDocument()
+ expect(screen.getByText(/select the items you want to return/i)).toBeInTheDocument()
+ expect(screen.getAllByTestId('return-items-modal-item-row')).toHaveLength(2)
+ expect(screen.getByText(/cotton crew t-shirt/i)).toBeInTheDocument()
+ expect(screen.getByText(/slim fit chino pants/i)).toBeInTheDocument()
+})
+
+test('Review return is disabled until at least one valid row is selected', async () => {
+ const user = userEvent.setup()
+ const onReview = jest.fn()
+ renderWithProviders()
+ const reviewButton = screen.getByTestId('return-items-modal-review')
+ // Disabled via aria-disabled (not the native disabled attribute) so the
+ // button stays focusable and the hint stays announceable to SR users.
+ expect(reviewButton).toHaveAttribute('aria-disabled', 'true')
+ expect(reviewButton).not.toHaveAttribute('disabled')
+ // The disabled-reason hint must be reachable: aria-describedby points at a
+ // node that actually exists in the document and carries the explanation.
+ const hintId = reviewButton.getAttribute('aria-describedby')
+ expect(hintId).toBeTruthy()
+ expect(document.getElementById(hintId)).toHaveTextContent(/select at least one item/i)
+ // Clicking while invalid is a no-op (focusable means clickable).
+ await user.click(reviewButton)
+ expect(onReview).not.toHaveBeenCalled()
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ await user.click(checkboxes[0])
+ expect(reviewButton).toHaveAttribute('aria-disabled', 'false')
+ expect(reviewButton).not.toHaveAttribute('aria-describedby')
+})
+
+test('toggling a row expands it and pre-selects the OMS default reason', async () => {
+ const user = userEvent.setup()
+ renderWithProviders()
+ const checkboxes = screen.getAllByRole('checkbox')
+ await user.click(checkboxes[0])
+
+ const row = screen.getAllByTestId('return-items-modal-item-row')[0]
+ // Reason carries a per-row aria-label (so screen readers can distinguish
+ // the dropdowns). Quantity reuses the shared QuantityPicker which sets
+ // aria-label="Quantity"; we scope to the row + the input element to
+ // disambiguate from the +/- buttons.
+ expect(within(row).getByLabelText(/reason for /i, {selector: 'select'})).toHaveValue(
+ 'Wrong size'
+ )
+ expect(within(row).getByLabelText(/^quantity$/i, {selector: 'input'})).toHaveValue('1')
+})
+
+test('quantity field clamps to the available-to-return ceiling', async () => {
+ const user = userEvent.setup()
+ renderWithProviders()
+ const checkboxes = screen.getAllByRole('checkbox')
+ await user.click(checkboxes[0]) // item-1 has max 2
+
+ const row = screen.getAllByTestId('return-items-modal-item-row')[0]
+ const qty = within(row).getByLabelText(/^quantity$/i, {selector: 'input'})
+ // Set value directly then blur — Chakra's useNumberInput clamps on blur,
+ // and userEvent.clear() doesn't propagate to a controlled NumberInput
+ // because the hook rejects empty intermediate values.
+ fireEvent.change(qty, {target: {value: '99'}})
+ fireEvent.blur(qty)
+ expect(qty).toHaveValue('2')
+})
+
+test('Cancel calls onClose', async () => {
+ const user = userEvent.setup()
+ const onClose = jest.fn()
+ renderWithProviders()
+ await user.click(screen.getByTestId('return-items-modal-cancel'))
+ expect(onClose).toHaveBeenCalledTimes(1)
+})
+
+test('clicking Review return forwards a properly shaped payload', async () => {
+ const user = userEvent.setup()
+ const onReview = jest.fn()
+ renderWithProviders()
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ await user.click(checkboxes[1]) // item-2: max 1, should default-reason
+
+ // Change reason away from default so it gets serialized
+ const reason = within(screen.getAllByTestId('return-items-modal-item-row')[1]).getByLabelText(
+ /reason for /i,
+ {selector: 'select'}
+ )
+ await user.selectOptions(reason, 'Defect')
+
+ await user.click(screen.getByTestId('return-items-modal-review'))
+ expect(onReview).toHaveBeenCalledWith([{itemId: 'item-2', quantity: 1, reason: 'Defect'}])
+})
+
+test('renders skeleton placeholders while OMS metadata is loading', () => {
+ mockOmsMetaData = {data: undefined, isLoading: true, isError: false, refetch: jest.fn()}
+ renderWithProviders()
+ expect(screen.getByTestId('return-items-modal-loading')).toBeInTheDocument()
+})
+
+test('backfills the OMS default reason on already-checked rows when metadata is available', async () => {
+ // Models the case where the parent has a checked row whose reasonCode
+ // was never set (e.g. selection rehydrated from URL state in a future
+ // WI, or initial open where the user clicked the row before reasons
+ // resolved). The modal's mount-time backfill effect must apply the
+ // OMS default so the row is valid without forcing a re-pick.
+ const initial = {'item-1': {checked: true, quantity: 1, reasonCode: undefined}}
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByTestId('return-items-modal-review')).toBeEnabled())
+ const row = screen.getAllByTestId('return-items-modal-item-row')[0]
+ expect(within(row).getByLabelText(/reason for /i, {selector: 'select'})).toHaveValue(
+ 'Wrong size'
+ )
+})
+
+test('two toggles in the same React batch both stick (no stale closure)', async () => {
+ // Regression: updateRow used to close over `selection`, so two checkbox
+ // toggles dispatched in a single render cycle each spread the same stale
+ // object — only the second won, silently dropping the first row.
+ renderWithProviders()
+ const [first, second] = screen.getAllByRole('checkbox')
+ act(() => {
+ fireEvent.click(first)
+ fireEvent.click(second)
+ })
+ await waitFor(() => expect(first).toBeChecked())
+ expect(second).toBeChecked()
+})
+
+test('renders an error alert + Retry when OMS metadata fails', async () => {
+ const user = userEvent.setup()
+ const refetch = jest.fn()
+ mockOmsMetaData = {data: undefined, isLoading: false, isError: true, refetch}
+ renderWithProviders()
+ expect(screen.getByTestId('return-items-modal-error')).toBeInTheDocument()
+ await user.click(screen.getByTestId('return-items-modal-retry'))
+ expect(refetch).toHaveBeenCalledTimes(1)
+})
+
+// Harness with a real trigger button so we can assert focus returns to it on
+// close — Chakra's returnFocusOnClose restores focus to whatever was focused
+// when the modal opened.
+const FocusHarness = () => {
+ const [isOpen, setIsOpen] = useState(false)
+ const [selection, setSelection] = useState({})
+ return (
+ <>
+
+ setIsOpen(false)}
+ order={baseOrder}
+ returnableItems={baseOrder.productItems}
+ selection={selection}
+ onSelectionChange={setSelection}
+ onReview={jest.fn()}
+ />
+ >
+ )
+}
+
+test('returns focus to the trigger when the modal closes', async () => {
+ const user = userEvent.setup()
+ renderWithProviders()
+
+ const trigger = screen.getByTestId('return-items-trigger')
+ await user.click(trigger)
+ expect(await screen.findByTestId('return-items-modal')).toBeInTheDocument()
+
+ await user.click(screen.getByTestId('return-items-modal-cancel'))
+ await waitFor(() => expect(screen.queryByTestId('return-items-modal')).not.toBeInTheDocument())
+ await waitFor(() => expect(trigger).toHaveFocus())
+})
+
+test('renders the bottom-sheet Drawer branch on mobile', async () => {
+ // base breakpoint → useBreakpointValue returns true → Drawer, not Modal.
+ useBreakpointValue.mockReturnValue(true)
+ renderWithProviders()
+
+ expect(await screen.findByTestId('return-items-modal-drawer')).toBeInTheDocument()
+ expect(screen.queryByTestId('return-items-modal')).not.toBeInTheDocument()
+ // Same content + footer actions render inside the Drawer branch.
+ expect(screen.getByText(/return items from order #00123456/i)).toBeInTheDocument()
+ expect(screen.getByText(/select the items you want to return/i)).toBeInTheDocument()
+ expect(screen.getAllByTestId('return-items-modal-item-row')).toHaveLength(2)
+ expect(screen.getByTestId('return-items-modal-review')).toBeInTheDocument()
+})
diff --git a/packages/template-retail-react-app/app/pages/account/order-detail.jsx b/packages/template-retail-react-app/app/pages/account/order-detail.jsx
index 5841c83127..20ecb3c571 100644
--- a/packages/template-retail-react-app/app/pages/account/order-detail.jsx
+++ b/packages/template-retail-react-app/app/pages/account/order-detail.jsx
@@ -20,8 +20,7 @@ import {
Grid,
SimpleGrid,
Skeleton,
- useDisclosure,
- VisuallyHidden
+ useDisclosure
} from '@salesforce/retail-react-app/app/components/shared/ui'
import {getCreditCardIcon} from '@salesforce/retail-react-app/app/utils/cc-utils'
import {
@@ -51,6 +50,7 @@ import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constan
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {consolidateDuplicateBonusProducts} from '@salesforce/retail-react-app/app/utils/bonus-product/cart'
import CancelOrderModal from '@salesforce/retail-react-app/app/components/cancel-order-modal'
+import ReturnItemsModal from '@salesforce/retail-react-app/app/components/return-items-modal'
import PropTypes from 'prop-types'
const onClient = typeof window !== 'undefined'
@@ -138,10 +138,19 @@ const AccountOrderDetail = () => {
onOpen: openCancelModal,
onClose: closeCancelModal
} = useDisclosure()
+ const {
+ isOpen: isReturnModalOpen,
+ onOpen: openReturnModal,
+ onClose: closeReturnModal
+ } = useDisclosure()
const [cancelFeedback, setCancelFeedback] = useState(null)
// Terminal errors (404/409) mean retrying won't help — disable the button
const [cancelTerminal, setCancelTerminal] = useState(false)
const cancelMutation = useShopperOrdersMutation(ShopperOrdersMutations.CancelOmsOrder)
+ // Selection state lives on the parent so the Modal/Drawer wrapper swap on
+ // viewport resize doesn't lose the shopper's progress, and so W-22821838's
+ // review step can read the same payload without prop-drilling.
+ const [returnSelection, setReturnSelection] = useState({})
// expand: 'oms' returns order data from OMS if the order is successfully
// ingested to OMS, otherwise returns data from ECOM
@@ -184,10 +193,24 @@ const AccountOrderDetail = () => {
const showMultiShipmentsFromOmsOnly = isOmsOrder && hasOmsShipment && isMultiShipmentOrder
const returnableItems = useMemo(() => getReturnableItems(order), [order])
- const showStartReturn = isRegistered && returnableItems.length > 0
+ const ownsOrder = order?.customerInfo?.customerId === customerId
+ const showStartReturn = isRegistered && ownsOrder && returnableItems.length > 0
const {data: omsMetaData} = useOmsMetaData({parameters: {}}, {enabled: isOmsOrder && onClient})
+ const handleCloseReturnModal = useCallback(() => {
+ closeReturnModal()
+ setReturnSelection({})
+ }, [closeReturnModal])
+
+ const handleReviewReturn = useCallback(
+ (/* payload */) => {
+ // W-22821838 picks up here: swap the modal to its review view and use
+ // `returnSelection` (held above) to render the confirmation rows.
+ },
+ []
+ )
+
const canCancel = useMemo(() => {
if (!isRegistered || !order) return false
if (!order.omsData) return false
@@ -222,8 +245,7 @@ const AccountOrderDetail = () => {
let description
if (status === 404) {
description = formatMessage({
- defaultMessage:
- 'We could not find this order. Please refresh and try again.',
+ defaultMessage: 'We could not find this order. Please refresh and try again.',
id: 'account_order_detail.alert.cancellation_error_not_found'
})
} else if (status === 409) {
@@ -457,9 +479,7 @@ const AccountOrderDetail = () => {
openCancelModal()
}}
isDisabled={
- !canCancel ||
- cancelFeedback?.status === 'success' ||
- cancelTerminal
+ !canCancel || cancelFeedback?.status === 'success' || cancelTerminal
}
>
{
/>
{showStartReturn && (
- // Phase 1 placeholder: button is disabled and announces
- // "Returns coming soon" to assistive tech via an
- // `aria-describedby` association. The full flow lands in
- // a follow-up story.
- <>
-
-
-
-
- >
+
)}
@@ -778,6 +781,17 @@ const AccountOrderDetail = () => {
reasonCodes={omsMetaData?.cancelReasonCodes}
/>
)}
+ {isOmsOrder && (
+
+ )}
)
}
diff --git a/packages/template-retail-react-app/app/pages/account/orders.test.js b/packages/template-retail-react-app/app/pages/account/orders.test.js
index 5278bfa7e2..cf01f54f7a 100644
--- a/packages/template-retail-react-app/app/pages/account/orders.test.js
+++ b/packages/template-retail-react-app/app/pages/account/orders.test.js
@@ -468,13 +468,17 @@ describe('OMS/SOM Integration - Order Details', () => {
})
})
-describe('Start Return CTA (W-22821836)', () => {
+describe('Return Items CTA (W-22821836 / W-22821837)', () => {
// Eligibility is OMS-driven: the CTA renders when at least one productItem has
// omsData.quantityAvailableToReturn > 0. OMS computes that field per item; the
// server returns 409 if the order is no longer in a returnable state, so there
// is no client-side status allowlist.
const createReturnEligibleOmsOrder = (overrides = {}) =>
createMockOmsOrder({
+ // The return CTA gates on ownership: order.customerInfo.customerId
+ // must match the current shopper's id. `useCustomerId` is mocked
+ // at the top of this file to return `'testCustomerId'`.
+ customerInfo: {customerId: 'testCustomerId'},
productItems: [
{
productId: 'returnable-1',
@@ -490,18 +494,15 @@ describe('Start Return CTA (W-22821836)', () => {
...overrides
})
- test('renders the disabled Start Return CTA when an item has quantityAvailableToReturn > 0', async () => {
+ test('renders the enabled Return Items CTA when an item has quantityAvailableToReturn > 0', async () => {
setupOrderDetailsPage(createReturnEligibleOmsOrder())
const cta = await screen.findByTestId('account-order-detail-start-return')
expect(cta).toBeInTheDocument()
- expect(cta).toBeDisabled()
- // Accessible name preserves the visible "Start return" label so voice
- // control users can activate the button by saying its visible text
- // (WCAG 2.5.3 Label in Name). The "Returns coming soon" explanation is
- // exposed via aria-describedby + a visually-hidden node.
- expect(cta).toHaveAccessibleName('Start return')
- expect(cta).toHaveAccessibleDescription('Returns coming soon')
- expect(cta).toHaveAttribute('title', 'Returns coming soon')
+ expect(cta).toBeEnabled()
+ // The label was renamed from "Start return" to "Return Items" in
+ // W-22821837 to match the storefront-next designs; the underlying
+ // message id stays stable so downstream extenders aren't broken.
+ expect(cta).toHaveAccessibleName('Return Items')
})
test('does NOT render the CTA when no item has quantityAvailableToReturn > 0', async () => {
@@ -528,6 +529,27 @@ describe('Start Return CTA (W-22821836)', () => {
expect(await screen.findByTestId('account-order-details-page')).toBeInTheDocument()
expect(screen.queryByTestId('account-order-detail-start-return')).not.toBeInTheDocument()
})
+
+ test('clicking Return Items opens the return-items modal (W-22821837)', async () => {
+ const user = userEvent.setup()
+ setupOrderDetailsPage(createReturnEligibleOmsOrder())
+ const cta = await screen.findByTestId('account-order-detail-start-return')
+ await user.click(cta)
+ expect(await screen.findByText(/return items from order #/i)).toBeInTheDocument()
+ })
+
+ test('does NOT render the CTA when the order belongs to a different customer', async () => {
+ // Defense-in-depth: even if useOrder somehow returned an order owned
+ // by a different customer, the trigger must not render. Mirrors the
+ // ownership guard already in place on the cancel-order CTA.
+ setupOrderDetailsPage(
+ createReturnEligibleOmsOrder({
+ customerInfo: {customerId: 'someone-else'}
+ })
+ )
+ expect(await screen.findByTestId('account-order-details-page')).toBeInTheDocument()
+ expect(screen.queryByTestId('account-order-detail-start-return')).not.toBeInTheDocument()
+ })
})
describe('OMS/SOM Integration - Order History', () => {
diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json
index bdd473b452..2d6ef720be 100644
--- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json
+++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json
@@ -260,13 +260,7 @@
"account_order_detail.button.start_return": [
{
"type": 0,
- "value": "Start return"
- }
- ],
- "account_order_detail.button.start_return_disabled_explanation": [
- {
- "type": 0,
- "value": "Returns coming soon"
+ "value": "Return Items"
}
],
"account_order_detail.error.back_to_history": [
@@ -4305,6 +4299,170 @@
"value": "Reset Password"
}
],
+ "return_items_modal.button.cancel": [
+ {
+ "type": 0,
+ "value": "Cancel"
+ }
+ ],
+ "return_items_modal.button.retry": [
+ {
+ "type": 0,
+ "value": "Retry"
+ }
+ ],
+ "return_items_modal.button.review_return": [
+ {
+ "type": 0,
+ "value": "Review return"
+ }
+ ],
+ "return_items_modal.heading.return_items": [
+ {
+ "type": 0,
+ "value": "Return items from order #"
+ },
+ {
+ "type": 1,
+ "value": "orderNo"
+ }
+ ],
+ "return_items_modal.hint.review_disabled": [
+ {
+ "type": 0,
+ "value": "Select at least one item and choose a reason to continue."
+ }
+ ],
+ "return_items_modal.label.item_checkbox": [
+ {
+ "type": 1,
+ "value": "name"
+ },
+ {
+ "type": 0,
+ "value": ", "
+ },
+ {
+ "offset": 0,
+ "options": {
+ "one": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " unit"
+ }
+ ]
+ },
+ "other": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " units"
+ }
+ ]
+ }
+ },
+ "pluralType": "cardinal",
+ "type": 6,
+ "value": "count"
+ },
+ {
+ "type": 0,
+ "value": " available to return"
+ }
+ ],
+ "return_items_modal.label.quantity": [
+ {
+ "type": 0,
+ "value": "Quantity"
+ }
+ ],
+ "return_items_modal.label.reason": [
+ {
+ "type": 0,
+ "value": "Reason"
+ }
+ ],
+ "return_items_modal.label.reason_for": [
+ {
+ "type": 0,
+ "value": "Reason for "
+ },
+ {
+ "type": 1,
+ "value": "name"
+ }
+ ],
+ "return_items_modal.placeholder.select_reason": [
+ {
+ "type": 0,
+ "value": "Select a reason"
+ }
+ ],
+ "return_items_modal.text.available_to_return": [
+ {
+ "type": 0,
+ "value": "Up to "
+ },
+ {
+ "offset": 0,
+ "options": {
+ "one": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " unit"
+ }
+ ]
+ },
+ "other": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " units"
+ }
+ ]
+ }
+ },
+ "pluralType": "cardinal",
+ "type": 6,
+ "value": "count"
+ },
+ {
+ "type": 0,
+ "value": " available to return"
+ }
+ ],
+ "return_items_modal.text.loading_reasons": [
+ {
+ "type": 0,
+ "value": "Loading return reasons…"
+ }
+ ],
+ "return_items_modal.text.reasons_error": [
+ {
+ "type": 0,
+ "value": "We could not load the return reasons. Please try again."
+ }
+ ],
+ "return_items_modal.text.select_items_description": [
+ {
+ "type": 0,
+ "value": "Select the items you want to return and tell us why."
+ }
+ ],
"search.action.cancel": [
{
"type": 0,
diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json
index bdd473b452..2d6ef720be 100644
--- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json
+++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json
@@ -260,13 +260,7 @@
"account_order_detail.button.start_return": [
{
"type": 0,
- "value": "Start return"
- }
- ],
- "account_order_detail.button.start_return_disabled_explanation": [
- {
- "type": 0,
- "value": "Returns coming soon"
+ "value": "Return Items"
}
],
"account_order_detail.error.back_to_history": [
@@ -4305,6 +4299,170 @@
"value": "Reset Password"
}
],
+ "return_items_modal.button.cancel": [
+ {
+ "type": 0,
+ "value": "Cancel"
+ }
+ ],
+ "return_items_modal.button.retry": [
+ {
+ "type": 0,
+ "value": "Retry"
+ }
+ ],
+ "return_items_modal.button.review_return": [
+ {
+ "type": 0,
+ "value": "Review return"
+ }
+ ],
+ "return_items_modal.heading.return_items": [
+ {
+ "type": 0,
+ "value": "Return items from order #"
+ },
+ {
+ "type": 1,
+ "value": "orderNo"
+ }
+ ],
+ "return_items_modal.hint.review_disabled": [
+ {
+ "type": 0,
+ "value": "Select at least one item and choose a reason to continue."
+ }
+ ],
+ "return_items_modal.label.item_checkbox": [
+ {
+ "type": 1,
+ "value": "name"
+ },
+ {
+ "type": 0,
+ "value": ", "
+ },
+ {
+ "offset": 0,
+ "options": {
+ "one": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " unit"
+ }
+ ]
+ },
+ "other": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " units"
+ }
+ ]
+ }
+ },
+ "pluralType": "cardinal",
+ "type": 6,
+ "value": "count"
+ },
+ {
+ "type": 0,
+ "value": " available to return"
+ }
+ ],
+ "return_items_modal.label.quantity": [
+ {
+ "type": 0,
+ "value": "Quantity"
+ }
+ ],
+ "return_items_modal.label.reason": [
+ {
+ "type": 0,
+ "value": "Reason"
+ }
+ ],
+ "return_items_modal.label.reason_for": [
+ {
+ "type": 0,
+ "value": "Reason for "
+ },
+ {
+ "type": 1,
+ "value": "name"
+ }
+ ],
+ "return_items_modal.placeholder.select_reason": [
+ {
+ "type": 0,
+ "value": "Select a reason"
+ }
+ ],
+ "return_items_modal.text.available_to_return": [
+ {
+ "type": 0,
+ "value": "Up to "
+ },
+ {
+ "offset": 0,
+ "options": {
+ "one": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " unit"
+ }
+ ]
+ },
+ "other": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " units"
+ }
+ ]
+ }
+ },
+ "pluralType": "cardinal",
+ "type": 6,
+ "value": "count"
+ },
+ {
+ "type": 0,
+ "value": " available to return"
+ }
+ ],
+ "return_items_modal.text.loading_reasons": [
+ {
+ "type": 0,
+ "value": "Loading return reasons…"
+ }
+ ],
+ "return_items_modal.text.reasons_error": [
+ {
+ "type": 0,
+ "value": "We could not load the return reasons. Please try again."
+ }
+ ],
+ "return_items_modal.text.select_items_description": [
+ {
+ "type": 0,
+ "value": "Select the items you want to return and tell us why."
+ }
+ ],
"search.action.cancel": [
{
"type": 0,
diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json
index 80c09290da..e67658ac55 100644
--- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json
+++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json
@@ -592,21 +592,7 @@
},
{
"type": 0,
- "value": "Şŧȧȧřŧ řḗḗŧŭŭřƞ"
- },
- {
- "type": 0,
- "value": "]"
- }
- ],
- "account_order_detail.button.start_return_disabled_explanation": [
- {
- "type": 0,
- "value": "["
- },
- {
- "type": 0,
- "value": "Řḗḗŧŭŭřƞş ƈǿǿḿīƞɠ şǿǿǿǿƞ"
+ "value": "Řḗḗŧŭŭřƞ Īŧḗḗḿş"
},
{
"type": 0,
@@ -9089,6 +9075,282 @@
"value": "]"
}
],
+ "return_items_modal.button.cancel": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Ƈȧȧƞƈḗḗŀ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.button.retry": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Řḗḗŧřẏ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.button.review_return": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Řḗḗṽīḗḗẇ řḗḗŧŭŭřƞ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.heading.return_items": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Řḗḗŧŭŭřƞ īŧḗḗḿş ƒřǿǿḿ ǿǿřḓḗḗř #"
+ },
+ {
+ "type": 1,
+ "value": "orderNo"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.hint.review_disabled": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Şḗḗŀḗḗƈŧ ȧȧŧ ŀḗḗȧȧşŧ ǿǿƞḗḗ īŧḗḗḿ ȧȧƞḓ ƈħǿǿǿǿşḗḗ ȧȧ řḗḗȧȧşǿǿƞ ŧǿǿ ƈǿǿƞŧīƞŭŭḗḗ."
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.label.item_checkbox": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 1,
+ "value": "name"
+ },
+ {
+ "type": 0,
+ "value": ", "
+ },
+ {
+ "offset": 0,
+ "options": {
+ "one": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " ŭŭƞīŧ"
+ }
+ ]
+ },
+ "other": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " ŭŭƞīŧş"
+ }
+ ]
+ }
+ },
+ "pluralType": "cardinal",
+ "type": 6,
+ "value": "count"
+ },
+ {
+ "type": 0,
+ "value": " ȧȧṽȧȧīŀȧȧƀŀḗḗ ŧǿǿ řḗḗŧŭŭřƞ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.label.quantity": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Ɋŭŭȧȧƞŧīŧẏ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.label.reason": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Řḗḗȧȧşǿǿƞ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.label.reason_for": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Řḗḗȧȧşǿǿƞ ƒǿǿř "
+ },
+ {
+ "type": 1,
+ "value": "name"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.placeholder.select_reason": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Şḗḗŀḗḗƈŧ ȧȧ řḗḗȧȧşǿǿƞ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.text.available_to_return": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Ŭƥ ŧǿǿ "
+ },
+ {
+ "offset": 0,
+ "options": {
+ "one": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " ŭŭƞīŧ"
+ }
+ ]
+ },
+ "other": {
+ "value": [
+ {
+ "type": 7
+ },
+ {
+ "type": 0,
+ "value": " ŭŭƞīŧş"
+ }
+ ]
+ }
+ },
+ "pluralType": "cardinal",
+ "type": 6,
+ "value": "count"
+ },
+ {
+ "type": 0,
+ "value": " ȧȧṽȧȧīŀȧȧƀŀḗḗ ŧǿǿ řḗḗŧŭŭřƞ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.text.loading_reasons": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Ŀǿǿȧȧḓīƞɠ řḗḗŧŭŭřƞ řḗḗȧȧşǿǿƞş…"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.text.reasons_error": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Ẇḗḗ ƈǿǿŭŭŀḓ ƞǿǿŧ ŀǿǿȧȧḓ ŧħḗḗ řḗḗŧŭŭřƞ řḗḗȧȧşǿǿƞş. Ƥŀḗḗȧȧşḗḗ ŧřẏ ȧȧɠȧȧīƞ."
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "return_items_modal.text.select_items_description": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Şḗḗŀḗḗƈŧ ŧħḗḗ īŧḗḗḿş ẏǿǿŭŭ ẇȧȧƞŧ ŧǿǿ řḗḗŧŭŭřƞ ȧȧƞḓ ŧḗḗŀŀ ŭŭş ẇħẏ."
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
"search.action.cancel": [
{
"type": 0,
diff --git a/packages/template-retail-react-app/app/utils/return-utils.js b/packages/template-retail-react-app/app/utils/return-utils.js
index 875a03b0b2..440513d9aa 100644
--- a/packages/template-retail-react-app/app/utils/return-utils.js
+++ b/packages/template-retail-react-app/app/utils/return-utils.js
@@ -27,3 +27,30 @@ export const getReturnableItems = (order) => {
return Number.isFinite(qty) && qty > 0
})
}
+
+/**
+ * Build the `productItems` array for an `OmsReturnOrderRequest`. Caller wraps
+ * it: `body: {productItems: buildReturnProductItems(selection, defaultReasonCode)}`.
+ *
+ * Quantity is `number` / `format: double` per oms.yaml. UX is integer-valued
+ * but we serialize as a JS Number, not a string. Reason is omitted when the
+ * shopper kept the OMS-default code so the server applies the default per the
+ * API contract.
+ *
+ * Rows without a positive numeric quantity are dropped — the upstream UI is
+ * already gated by `isSelectionValid`, but this hardens reuse from elsewhere
+ * (e.g. step 2's review modal in W-22821838) against malformed state.
+ */
+export const buildReturnProductItems = (selection, defaultReasonCode) =>
+ Object.entries(selection || {})
+ .filter(([, row]) => row?.checked)
+ .reduce((items, [itemId, row]) => {
+ const quantity = Number(row.quantity)
+ if (!Number.isFinite(quantity) || quantity <= 0) return items
+ const item = {itemId, quantity}
+ if (row.reasonCode && row.reasonCode !== defaultReasonCode) {
+ item.reason = row.reasonCode
+ }
+ items.push(item)
+ return items
+ }, [])
diff --git a/packages/template-retail-react-app/app/utils/return-utils.test.js b/packages/template-retail-react-app/app/utils/return-utils.test.js
index 8d61f5ff8c..570dfe65c9 100644
--- a/packages/template-retail-react-app/app/utils/return-utils.test.js
+++ b/packages/template-retail-react-app/app/utils/return-utils.test.js
@@ -5,7 +5,10 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
-import {getReturnableItems} from '@salesforce/retail-react-app/app/utils/return-utils'
+import {
+ buildReturnProductItems,
+ getReturnableItems
+} from '@salesforce/retail-react-app/app/utils/return-utils'
const item = (id, qtyAvailableToReturn) => ({
productId: id,
@@ -56,3 +59,48 @@ describe('getReturnableItems', () => {
expect(getReturnableItems(order).map((i) => i.productId)).toEqual(['ok'])
})
})
+
+describe('buildReturnProductItems', () => {
+ test('omits reason from payload when shopper kept the OMS default', () => {
+ const selection = {'item-2': {checked: true, quantity: 1, reasonCode: 'Wrong size'}}
+ expect(buildReturnProductItems(selection, 'Wrong size')).toEqual([
+ {itemId: 'item-2', quantity: 1}
+ ])
+ })
+
+ test('serializes quantity as a JS Number (not a string)', () => {
+ const selection = {'item-2': {checked: true, quantity: '3', reasonCode: 'Defect'}}
+ const [row] = buildReturnProductItems(selection, 'Wrong size')
+ expect(typeof row.quantity).toBe('number')
+ expect(row.quantity).toBe(3)
+ })
+
+ test('drops malformed rows (non-numeric or zero quantity) from the payload', () => {
+ expect(
+ buildReturnProductItems(
+ {
+ 'item-a': {checked: true, quantity: '', reasonCode: 'Defect'},
+ 'item-b': {checked: true, quantity: 'abc', reasonCode: 'Defect'},
+ 'item-c': {checked: true, quantity: 0, reasonCode: 'Defect'},
+ 'item-d': {checked: true, quantity: 2, reasonCode: 'Defect'}
+ },
+ 'Wrong size'
+ )
+ ).toEqual([{itemId: 'item-d', quantity: 2, reason: 'Defect'}])
+ })
+
+ test('skips unchecked rows', () => {
+ const selection = {
+ 'item-1': {checked: false, quantity: 2, reasonCode: 'Defect'},
+ 'item-2': {checked: true, quantity: 1, reasonCode: 'Defect'}
+ }
+ expect(buildReturnProductItems(selection, 'Wrong size')).toEqual([
+ {itemId: 'item-2', quantity: 1, reason: 'Defect'}
+ ])
+ })
+
+ test('returns [] for null/undefined selection', () => {
+ expect(buildReturnProductItems(null, 'Wrong size')).toEqual([])
+ expect(buildReturnProductItems(undefined, 'Wrong size')).toEqual([])
+ })
+})
diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json
index 70a8f954d0..436e10ce6b 100644
--- a/packages/template-retail-react-app/translations/en-GB.json
+++ b/packages/template-retail-react-app/translations/en-GB.json
@@ -123,10 +123,7 @@
"defaultMessage": "Cancel order"
},
"account_order_detail.button.start_return": {
- "defaultMessage": "Start return"
- },
- "account_order_detail.button.start_return_disabled_explanation": {
- "defaultMessage": "Returns coming soon"
+ "defaultMessage": "Return Items"
},
"account_order_detail.error.back_to_history": {
"defaultMessage": "Back to Order History"
@@ -1808,6 +1805,48 @@
"reset_password_form.title.reset_password": {
"defaultMessage": "Reset Password"
},
+ "return_items_modal.button.cancel": {
+ "defaultMessage": "Cancel"
+ },
+ "return_items_modal.button.retry": {
+ "defaultMessage": "Retry"
+ },
+ "return_items_modal.button.review_return": {
+ "defaultMessage": "Review return"
+ },
+ "return_items_modal.heading.return_items": {
+ "defaultMessage": "Return items from order #{orderNo}"
+ },
+ "return_items_modal.hint.review_disabled": {
+ "defaultMessage": "Select at least one item and choose a reason to continue."
+ },
+ "return_items_modal.label.item_checkbox": {
+ "defaultMessage": "{name}, {count, plural, one {# unit} other {# units}} available to return"
+ },
+ "return_items_modal.label.quantity": {
+ "defaultMessage": "Quantity"
+ },
+ "return_items_modal.label.reason": {
+ "defaultMessage": "Reason"
+ },
+ "return_items_modal.label.reason_for": {
+ "defaultMessage": "Reason for {name}"
+ },
+ "return_items_modal.placeholder.select_reason": {
+ "defaultMessage": "Select a reason"
+ },
+ "return_items_modal.text.available_to_return": {
+ "defaultMessage": "Up to {count, plural, one {# unit} other {# units}} available to return"
+ },
+ "return_items_modal.text.loading_reasons": {
+ "defaultMessage": "Loading return reasons…"
+ },
+ "return_items_modal.text.reasons_error": {
+ "defaultMessage": "We could not load the return reasons. Please try again."
+ },
+ "return_items_modal.text.select_items_description": {
+ "defaultMessage": "Select the items you want to return and tell us why."
+ },
"search.action.cancel": {
"defaultMessage": "Cancel"
},
diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json
index 70a8f954d0..436e10ce6b 100644
--- a/packages/template-retail-react-app/translations/en-US.json
+++ b/packages/template-retail-react-app/translations/en-US.json
@@ -123,10 +123,7 @@
"defaultMessage": "Cancel order"
},
"account_order_detail.button.start_return": {
- "defaultMessage": "Start return"
- },
- "account_order_detail.button.start_return_disabled_explanation": {
- "defaultMessage": "Returns coming soon"
+ "defaultMessage": "Return Items"
},
"account_order_detail.error.back_to_history": {
"defaultMessage": "Back to Order History"
@@ -1808,6 +1805,48 @@
"reset_password_form.title.reset_password": {
"defaultMessage": "Reset Password"
},
+ "return_items_modal.button.cancel": {
+ "defaultMessage": "Cancel"
+ },
+ "return_items_modal.button.retry": {
+ "defaultMessage": "Retry"
+ },
+ "return_items_modal.button.review_return": {
+ "defaultMessage": "Review return"
+ },
+ "return_items_modal.heading.return_items": {
+ "defaultMessage": "Return items from order #{orderNo}"
+ },
+ "return_items_modal.hint.review_disabled": {
+ "defaultMessage": "Select at least one item and choose a reason to continue."
+ },
+ "return_items_modal.label.item_checkbox": {
+ "defaultMessage": "{name}, {count, plural, one {# unit} other {# units}} available to return"
+ },
+ "return_items_modal.label.quantity": {
+ "defaultMessage": "Quantity"
+ },
+ "return_items_modal.label.reason": {
+ "defaultMessage": "Reason"
+ },
+ "return_items_modal.label.reason_for": {
+ "defaultMessage": "Reason for {name}"
+ },
+ "return_items_modal.placeholder.select_reason": {
+ "defaultMessage": "Select a reason"
+ },
+ "return_items_modal.text.available_to_return": {
+ "defaultMessage": "Up to {count, plural, one {# unit} other {# units}} available to return"
+ },
+ "return_items_modal.text.loading_reasons": {
+ "defaultMessage": "Loading return reasons…"
+ },
+ "return_items_modal.text.reasons_error": {
+ "defaultMessage": "We could not load the return reasons. Please try again."
+ },
+ "return_items_modal.text.select_items_description": {
+ "defaultMessage": "Select the items you want to return and tell us why."
+ },
"search.action.cancel": {
"defaultMessage": "Cancel"
},