Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/scripts/pageHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ export const searchProduct = async ({page, query, isMobile = false}) => {

let searchInput = isMobile ? searchInputs.nth(1) : searchInputs.nth(0)
await searchInput.fill(query)
await page.waitForTimeout(1000)
await searchInput.press('Enter')

await page.waitForLoadState()
Expand Down
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## v8.1.0-dev (Sep 04, 2025)
- Updated search UX - prices, images, suggestions new layout [#3271](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3271)
- Updated the UI for StoreDisplay component which displays pickup in-store information on different pages. [#3248](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3248)
- Added warning modal for guest users when toggling between multi ship and ship to one address. [3280] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3280) [3302] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3302)

Expand Down
48 changes: 35 additions & 13 deletions packages/template-retail-react-app/app/components/search/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import SearchSuggestions from '@salesforce/retail-react-app/app/components/searc
import {SearchIcon} from '@salesforce/retail-react-app/app/components/icons'
import {
capitalize,
boldString,
getSessionJSONItem,
setSessionJSONItem
} from '@salesforce/retail-react-app/app/utils/utils'
Expand All @@ -50,15 +49,17 @@ function isAskAgentOnSearchEnabled(enabled, askAgentOnSearch) {
return enabled === 'true' && askAgentOnSearch === 'true' && onClient
}

const formatSuggestions = (searchSuggestions, input) => {
const formatSuggestions = (searchSuggestions) => {
return {
categorySuggestions: searchSuggestions?.categorySuggestions?.categories?.map(
(suggestion) => {
return {
type: 'category',
id: suggestion.id,
link: categoryUrlBuilder({id: suggestion.id}),
name: boldString(suggestion.name, capitalize(input))
name: capitalize(suggestion.name),
image: suggestion.image?.disBaseLink,
parentCategoryName: suggestion.parentCategoryName
}
}
),
Expand All @@ -68,19 +69,30 @@ const formatSuggestions = (searchSuggestions, input) => {
currency: product.currency,
price: product.price,
productId: product.productId,
name: boldString(product.productName, capitalize(input)),
link: productUrlBuilder({id: product.productId})
name: capitalize(product.productName),
link: productUrlBuilder({id: product.productId}),
image: product.image?.disBaseLink
}
}),
phraseSuggestions: searchSuggestions?.categorySuggestions?.suggestedPhrases?.map(
brandSuggestions: searchSuggestions?.brandSuggestions?.suggestedPhrases?.map((brand) => {
// Init cap the brand name
return {
type: 'brand',
name: capitalize(brand.phrase),
link: searchUrlBuilder(brand.phrase)
}
}),
phraseSuggestions: searchSuggestions?.productSuggestions?.suggestedPhrases?.map(
(phrase) => {
return {
type: 'phrase',
name: boldString(phrase.phrase, capitalize(input)),
link: searchUrlBuilder(phrase.phrase)
name: phrase.phrase,
link: searchUrlBuilder(phrase.phrase),
exactMatch: phrase.exactMatch
}
}
)
),
searchPhrase: searchSuggestions?.searchPhrase
}
}

Expand All @@ -102,10 +114,12 @@ const Search = (props) => {
const [isOpen, setIsOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const navigate = useNavigation()

const searchSuggestion = useSearchSuggestions(
{
parameters: {
q: searchQuery
q: searchQuery,
expand: 'images,prices'
}
},
{
Expand All @@ -119,7 +133,7 @@ const Search = (props) => {
})
const recentSearches = getSessionJSONItem(RECENT_SEARCH_KEY)
const searchSuggestions = useMemo(
() => formatSuggestions(searchSuggestion.data, searchInputRef?.current?.value),
() => formatSuggestions(searchSuggestion.data),
[searchSuggestion]
)

Expand Down Expand Up @@ -257,7 +271,7 @@ const Search = (props) => {
// or we have search suggestions available and have inputed some text (empty text in this scenario should show recent searches)
if (
(document.activeElement.id === 'search-input' && recentSearches?.length > 0) ||
(searchSuggestionsAvailable && searchInputRef.current.value.length > 0)
(searchSuggestionsAvailable && searchInputRef.current?.value?.length > 0)
) {
setIsOpen(true)
} else {
Expand Down Expand Up @@ -310,7 +324,15 @@ const Search = (props) => {
</PopoverTrigger>

<HideOnMobile>
<PopoverContent data-testid="sf-suggestion-popover">
<PopoverContent
data-testid="sf-suggestion-popover"
width="100vw"
maxWidth="100vw"
left={0}
right={0}
marginLeft={0}
marginRight={0}
>
<SearchSuggestions
closeAndNavigate={closeAndNavigate}
recentSearches={recentSearches}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,12 @@ test('suggestions render when there are some', async () => {
const suggestionPopoverEl = await screen.getByTestId('sf-suggestion-popover')

await waitFor(() => {
const suggestionsEl = within(suggestionPopoverEl).getByTestId('sf-suggestion')
expect(suggestionsEl.querySelector('button').textContent).toBe('Dresses')
const suggestionsEls = within(suggestionPopoverEl).getAllByTestId('sf-suggestion')
expect(suggestionsEls.length).toBeGreaterThan(0)
const hasDressesSuggestion = suggestionsEls.some((el) =>
el.querySelector('button')?.textContent?.includes('Dresses')
)
expect(hasDressesSuggestion).toBe(true)
})
})

Expand Down Expand Up @@ -423,3 +427,96 @@ test('when sendTextMessage and launchChat both fail, no additional send text is
// Verify sendTextMessage was only called once
expect(sendTextMessageSpy).toHaveBeenCalledTimes(1)
})

test('handles search phrase in formatSuggestions', async () => {
const user = setupUserEvent()

const mockResultsWithPhrase = {
...mockSearchResults,
searchPhrase: 'test search phrase'
}

global.server.use(
rest.get('*/search-suggestions', (req, res, ctx) => {
return res(ctx.delay(0), ctx.status(200), ctx.json(mockResultsWithPhrase))
})
)

renderWithProviders(<SearchInput />)
const searchInput = document.querySelector('input[type="search"]')

await user.type(searchInput, 'test')

// Wait for suggestions to load with search phrase
await waitFor(() => {
expect(screen.getByTestId('sf-suggestion-popover')).toBeInTheDocument()
})
})

test('handles phrase suggestions in formatSuggestions', async () => {
const user = setupUserEvent()

// Mock search results with phrase suggestions
const mockResultsWithPhrases = {
...mockSearchResults,
productSuggestions: {
...mockSearchResults.productSuggestions,
suggestedPhrases: [
{phrase: 'running shoes', exactMatch: true},
{phrase: 'athletic wear', exactMatch: false}
]
}
}

global.server.use(
rest.get('*/search-suggestions', (req, res, ctx) => {
return res(ctx.delay(0), ctx.status(200), ctx.json(mockResultsWithPhrases))
})
)

renderWithProviders(<SearchInput />)
const searchInput = document.querySelector('input[type="search"]')

await user.type(searchInput, 'running')

// Wait for suggestions to load with phrase suggestions
await waitFor(() => {
expect(screen.getByTestId('sf-suggestion-popover')).toBeInTheDocument()
})
})

test('handles product suggestions with images', async () => {
const user = setupUserEvent()

// Mock search results with product suggestions that have images
const mockResultsWithProductImages = {
...mockSearchResults,
productSuggestions: {
...mockSearchResults.productSuggestions,
products: [
{
...mockSearchResults.productSuggestions.products[0],
image: {
disBaseLink: 'https://example.com/product-image.jpg'
}
}
]
}
}

global.server.use(
rest.get('*/search-suggestions', (req, res, ctx) => {
return res(ctx.delay(0), ctx.status(200), ctx.json(mockResultsWithProductImages))
})
)

renderWithProviders(<SearchInput />)
const searchInput = document.querySelector('input[type="search"]')

await user.type(searchInput, 'Dress')

// Wait for suggestions to load with product images
await waitFor(() => {
expect(screen.getByTestId('sf-suggestion-popover')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2021, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
import PropTypes from 'prop-types'
import {Text, Box, Flex, AspectRatio} from '@salesforce/retail-react-app/app/components/shared/ui'
import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image'
import Link from '@salesforce/retail-react-app/app/components/link'
import {useStyleConfig} from '@chakra-ui/react'

const HorizontalSuggestions = ({suggestions, closeAndNavigate, dynamicImageProps}) => {
const styles = useStyleConfig('HorizontalSuggestions')

if (!suggestions) {
return null
}

return (
<Box data-testid="sf-horizontal-product-suggestions" sx={styles.container}>
<Flex sx={styles.flexContainer}>
{suggestions.map((suggestion, idx) => (
<Link
data-testid="product-tile"
to={suggestion.link}
key={idx}
onClick={() => closeAndNavigate(suggestion.link)}
>
<Box sx={styles.suggestionItem}>
{/* Product Image */}
<Box sx={styles.imageContainer}>
{suggestion.image ? (
<AspectRatio sx={styles.aspectRatio}>
<DynamicImage
src={`${suggestion.image}[?sw={width}&q=60]`}
widths={dynamicImageProps?.widths}
sx={styles.dynamicImage}
imageProps={{
alt: '',
loading: 'eager'
}}
/>
</AspectRatio>
) : null}
</Box>

<Text sx={styles.productName}>{suggestion.name}</Text>

{suggestion.price && (
<Text sx={styles.productPrice}>${suggestion.price}</Text>
)}
</Box>
</Link>
))}
</Flex>
</Box>
)
}

HorizontalSuggestions.propTypes = {
suggestions: PropTypes.array,
closeAndNavigate: PropTypes.func,
dynamicImageProps: PropTypes.object
}

export default HorizontalSuggestions
Loading
Loading