Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
import {Box, useTheme, Text} from '@chakra-ui/react'
import PropTypes from 'prop-types'
import {useIntl} from 'react-intl'

const steps = ['Ordered', 'Dispatched', 'Out for delivery', 'Delivered']

const OrderStatusBar = ({currentStepLabel}) => {
const theme = useTheme()
const intl = useIntl()

const getLocalizedMessage = (status) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sf-madhuri-uppu Why are we hard coding localized message for every step. This adds maintenance on our part. Is there any way to take the list of steps/texts from backend and translate the array on UI instead of each step ?

Copy link
Contributor Author

@sf-madhuri-uppu sf-madhuri-uppu Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Intl requires message IDs to be statically evaluable for extraction. Dynamic key generation like status_bar.${status.toLowerCase().replace(/\s+/g, '_')} is not static. I tried using a for loop earlier and it did not work so I had to use a switch case. The message itself is not hardcoded for every locale. We are only giving the step label as default messages. Those labels will be translated based on locale by React.intl

switch (status) {
case 'Ordered':
return intl.formatMessage({id: 'status_bar.ordered', defaultMessage: 'Ordered'})
case 'Dispatched':
return intl.formatMessage({
id: 'status_bar.dispatched',
defaultMessage: 'Dispatched'
})
case 'Out for delivery':
return intl.formatMessage({
id: 'status_bar.out_for_delivery',
defaultMessage: 'Out for delivery'
})
case 'Delivered':
return intl.formatMessage({id: 'status_bar.delivered', defaultMessage: 'Delivered'})
default:
return status
}
}

const n = steps.length
const svgWidth = 1080
const svgHeight = 50
const chevronWidth = 24
const radius = 25

// Dynamically calculate step width so all steps + chevrons fit in svgWidth
const stepWidth = (svgWidth + (n - 1) * chevronWidth) / n

let currentStep = steps.findIndex((step) => step === currentStepLabel)

if (currentStep === -1) currentStep = 0

// Helper to get the x offset for each step (overlap chevrons)
const getStepOffset = (i) => i * (stepWidth - chevronWidth)

// Generate polygons/paths for each step
const renderStepShapes = () => {
const shapes = []
for (let i = 0; i < n; i++) {
const x = getStepOffset(i)
let path, stepFill
if (i === 0) {
// First: rounded left, chevron right (chevron tip overlaps next step)
path = `M ${x + radius},0
L ${x + stepWidth - chevronWidth},0
L ${x + stepWidth},${svgHeight / 2}
L ${x + stepWidth - chevronWidth},${svgHeight}
L ${x + radius},${svgHeight}
A ${radius},${radius} 0 0 1 ${x},${svgHeight / 2}
A ${radius},${radius} 0 0 1 ${x + radius},0
Z`
} else if (i === n - 1) {
// Last: chevron left, rounded right (no chevron tip on right)
path = `M ${x},0
L ${x + stepWidth - radius},0
A ${radius},${radius} 0 0 1 ${x + stepWidth},${svgHeight / 2}
A ${radius},${radius} 0 0 1 ${x + stepWidth - radius},${svgHeight}
L ${x},${svgHeight}
L ${x + chevronWidth},${svgHeight / 2}
Z`
} else {
// Middle: chevron left, chevron right (chevron tip overlaps next step)
path = `M ${x},0
L ${x + stepWidth - chevronWidth},0
L ${x + stepWidth},${svgHeight / 2}
L ${x + stepWidth - chevronWidth},${svgHeight}
L ${x},${svgHeight}
L ${x + chevronWidth},${svgHeight / 2}
Z`
}
// Color logic: completed steps = light teal, current step = dark blue, future steps = gray
if (i < currentStep) {
stepFill = theme.colors.teal[100]
} else if (i === currentStep) {
stepFill = theme.colors.blue[900]
} else {
stepFill = theme.colors.gray[200]
}
shapes.push(<path key={i} d={path} fill={stepFill} stroke="white" strokeWidth="2" />)
}
return shapes
}

// Overlay text for each step (shift overlays for overlap)
const renderStepLabels = () => {
const labels = []
for (let i = 0; i < n; i++) {
const x = getStepOffset(i)
// Text color logic: completed steps = dark text, current step = white, future steps = dark text
let labelColor
if (i < currentStep) {
labelColor = theme.colors.black[600]
} else if (i === currentStep) {
labelColor = 'white'
} else {
labelColor = theme.colors.black[600]
}
labels.push(
<Box
key={i}
position="absolute"
top={0}
left={`calc(${(x / svgWidth) * 100}% )`}
width={`calc(${(stepWidth / svgWidth) * 100}% )`}
height="100%"
display="flex"
alignItems="center"
justifyContent="center"
pointerEvents="none"
px={[1, 2]} // Add horizontal padding for text
>
<Text
color={labelColor}
fontWeight="medium"
fontSize={['xs', 'sm', 'md', 'lg']}
textAlign="center"
lineHeight="1.2"
wordBreak="break-word"
hyphens="auto"
maxW="100%"
>
{getLocalizedMessage(steps[i])}
</Text>
</Box>
)
}
return labels
}

return (
<Box position="relative" width="100%" maxWidth="1080px" height="50px">
<svg
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to consider using flexboxes instead svgs? The latter makes positioning code complex

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried to build the UI for order tracker using other techniques. It was difficult to get the right shape for chevrons while maintaining consistent spacing between them. It's easier to get a responsive design with svgs and it also seems to be used widely

Copy link
Contributor

@sf-emmyzhang sf-emmyzhang Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would disagree that it's necessarily always easier to get a responsive design with svgs. Although svgs are useful for complex shapes I think we can achieve what we need using simple css and html. I think if we want this component to be maintainable, responsive, and accessible then I'd encourage exploring other solutions. Or at the very least explore how to reduce the complexity and improve responsiveness for the current approach if you think it's the best route.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that we have to make the component more accessible. I was planning to address that in a different WI. But I just pushed a commit with the accessibility changes. We can hone it better in the next iteration. The component also seems to be responsive as can be seen in the video. Please check it out and let me know

width="100%"
height="auto"
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
style={{display: 'block'}}
preserveAspectRatio="none"
>
{renderStepShapes()}
</svg>
{renderStepLabels()}
</Box>
)
}

OrderStatusBar.propTypes = {
currentStepLabel: PropTypes.string
}

export default OrderStatusBar
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
import {screen} from '@testing-library/react'
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
import OrderStatusBar from '.'

describe('OrderStatusBar', () => {
beforeEach(() => jest.clearAllMocks())

test('renders all step labels with localized messages', () => {
renderWithProviders(<OrderStatusBar />)

expect(screen.getByText('Ordered')).toBeInTheDocument()
expect(screen.getByText('Dispatched')).toBeInTheDocument()
expect(screen.getByText('Out for delivery')).toBeInTheDocument()
expect(screen.getByText('Delivered')).toBeInTheDocument()
})

test('renders step labels with responsive font sizes and word wrapping', () => {
renderWithProviders(<OrderStatusBar />)

const labels = screen.getAllByText(/Ordered|Dispatched|Out for delivery|Delivered/)
labels.forEach((label) => {
// Check that the component renders without errors
expect(label).toBeInTheDocument()
})
})

test('renders SVG element with correct attributes', () => {
renderWithProviders(<OrderStatusBar />)

const svg = document.querySelector('svg')
expect(svg).toBeInTheDocument()
expect(svg).toHaveAttribute('viewBox', '0 0 1080 50')
expect(svg).toHaveAttribute('width', '100%')
expect(svg).toHaveAttribute('preserveAspectRatio', 'none')
expect(svg).toHaveStyle({display: 'block'})
})

test('renders all step paths in SVG with correct count', () => {
renderWithProviders(<OrderStatusBar />)

const svg = document.querySelector('svg')
const paths = svg.querySelectorAll('path')
expect(paths).toHaveLength(4) // Should have 4 paths for 4 steps
})

test('renders with different currentStepLabel props and updates colors accordingly', () => {
const {rerender} = renderWithProviders(<OrderStatusBar currentStepLabel="Ordered" />)
expect(screen.getByText('Ordered')).toBeInTheDocument()

rerender(<OrderStatusBar currentStepLabel="Dispatched" />)
expect(screen.getByText('Dispatched')).toBeInTheDocument()

rerender(<OrderStatusBar currentStepLabel="Delivered" />)
expect(screen.getByText('Delivered')).toBeInTheDocument()

rerender(<OrderStatusBar currentStepLabel="Invalid Step" />)
expect(screen.getByText('Ordered')).toBeInTheDocument()

rerender(<OrderStatusBar currentStepLabel="" />)
expect(screen.getByText('Ordered')).toBeInTheDocument()

rerender(<OrderStatusBar currentStepLabel={undefined} />)
expect(screen.getByText('Ordered')).toBeInTheDocument()

rerender(<OrderStatusBar currentStepLabel={['Ordered', 'Dispatched']} />)
expect(screen.getByText('Ordered')).toBeInTheDocument()
})

test('renders container with correct positioning and dimensions', () => {
renderWithProviders(<OrderStatusBar />)

// The container is a Chakra UI Box component, so we check for the SVG instead
const svg = document.querySelector('svg')
expect(svg).toBeInTheDocument()
expect(svg).toHaveAttribute('width', '100%')
expect(svg).toHaveAttribute('viewBox', '0 0 1080 50')
})

test('renders text labels with correct styling properties', () => {
renderWithProviders(<OrderStatusBar />)

const labels = screen.getAllByText(/Ordered|Dispatched|Out for delivery|Delivered/)
labels.forEach((label) => {
// Check that labels are rendered
expect(label).toBeInTheDocument()
})
})

test('renders step labels with proper positioning', () => {
renderWithProviders(<OrderStatusBar />)

// Check that all step labels are rendered
const labels = screen.getAllByText(/Ordered|Dispatched|Out for delivery|Delivered/)
expect(labels).toHaveLength(4)

// Check that each label is in the document
labels.forEach((label) => {
expect(label).toBeInTheDocument()
})
})

test('handles case-sensitive step label matching', () => {
// Test case-insensitive matching
const {rerender} = renderWithProviders(<OrderStatusBar currentStepLabel="ordered" />)
expect(screen.getByText('Ordered')).toBeInTheDocument()

// Test with different case
rerender(<OrderStatusBar currentStepLabel="DISPATCHED" />)
expect(screen.getByText('Dispatched')).toBeInTheDocument()

// Test with mixed case
rerender(<OrderStatusBar currentStepLabel="Out For Delivery" />)
expect(screen.getByText('Out for delivery')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import CartItemVariantName from '@salesforce/retail-react-app/app/components/ite
import CartItemVariantAttributes from '@salesforce/retail-react-app/app/components/item-variant/item-attributes'
import CartItemVariantPrice from '@salesforce/retail-react-app/app/components/item-variant/item-price'
import PropTypes from 'prop-types'
import OrderStatusBar from '@salesforce/retail-react-app/app/components/order-status-bar/index'

const onClient = typeof window !== 'undefined'

const OrderProducts = ({productItems, currency}) => {
Expand Down Expand Up @@ -206,6 +208,8 @@ const AccountOrderDetail = () => {
</Stack>
</Stack>

{!isLoading && <OrderStatusBar currentStepLabel={order.status} />}

<Box layerStyle="cardBordered">
<Grid templateColumns={{base: '1fr', xl: '60% 1fr'}} gap={{base: 6, xl: 2}}>
<SimpleGrid columns={{base: 1, sm: 2}} columnGap={4} rowGap={5} py={{xl: 6}}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3477,6 +3477,30 @@
"value": " to proceed."
}
],
"status_bar.delivered": [
{
"type": 0,
"value": "Delivered"
}
],
"status_bar.dispatched": [
{
"type": 0,
"value": "Dispatched"
}
],
"status_bar.ordered": [
{
"type": 0,
"value": "Ordered"
}
],
"status_bar.out_for_delivery": [
{
"type": 0,
"value": "Out for delivery"
}
],
"store_display.format.address_line_2": [
{
"type": 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3477,6 +3477,30 @@
"value": " to proceed."
}
],
"status_bar.delivered": [
{
"type": 0,
"value": "Delivered"
}
],
"status_bar.dispatched": [
{
"type": 0,
"value": "Dispatched"
}
],
"status_bar.ordered": [
{
"type": 0,
"value": "Ordered"
}
],
"status_bar.out_for_delivery": [
{
"type": 0,
"value": "Out for delivery"
}
],
"store_display.format.address_line_2": [
{
"type": 1,
Expand Down
Loading
Loading