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