Skip to content

Commit 09d383a

Browse files
authored
Merge pull request #2095 from SalesforceCommerceCloud/v5/template-retail-react-app
Merge / release `v5/template-retail-react-app` and `feature/retail-react-app-v5` branches previously held from `develop`
2 parents ca0bc0b + 936d88e commit 09d383a

File tree

19 files changed

+841
-52
lines changed

19 files changed

+841
-52
lines changed

packages/template-retail-react-app/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## v5.0.0 (TBD)
2+
3+
### New Features
4+
- Implement ability to set Shopper Context via search parameters in the Retail React App [#1986](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1986)
5+
- Display a promo banner from Page Designer in the PLP page of the Retail React App [#2016](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2016)
6+
7+
### Performance Improvements
8+
9+
- PLP: When products are being refetched, only the pricing and promotions sections will display a skeleton in the ProductTile [#2064](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2064)
10+
111
## v4.1.0-dev (Aug 8, 2024)
212

313
- [Server Affinity] - Attach dwsid to SCAPI request headers & remove OCAPI proxy [#2090](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2090)
@@ -34,6 +44,9 @@
3444
## v4.0.1 (Sept 4, 2024)
3545
- Updated @salesforce/commerce-sdk-react to 3.0.1 to fix an issue with the expires attribute of cookies, ensuring it uses seconds instead of days [#1994](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1994)
3646

47+
### Other Features
48+
- PWA Kit projects have Active Data tracking set to "true" by default.
49+
3750
## v4.0.0 (Aug 7, 2024)
3851

3952
### New Features

packages/template-retail-react-app/app/components/_app/index.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {AddToCartModalProvider} from '@salesforce/retail-react-app/app/hooks/use
5353
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
5454
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
5555
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
56+
import {useUpdateShopperContext} from '@salesforce/retail-react-app/app/hooks/use-update-shopper-context'
5657

5758
// HOCs
5859
import {withCommerceSdkReact} from '@salesforce/retail-react-app/app/components/with-commerce-sdk-react/with-commerce-sdk-react'
@@ -133,7 +134,6 @@ const App = (props) => {
133134

134135
const [isOnline, setIsOnline] = useState(true)
135136
const styles = useStyleConfig('App')
136-
137137
const {isOpen, onOpen, onClose} = useDisclosure()
138138
const {
139139
isOpen: isOpenStoreLocator,
@@ -228,6 +228,9 @@ const App = (props) => {
228228
})
229229
}, [])
230230

231+
// Handle updating the shopper context
232+
useUpdateShopperContext()
233+
231234
useEffect(() => {
232235
// Lets automatically close the mobile navigation when the
233236
// location path is changed.

packages/template-retail-react-app/app/components/_app/index.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
1919
import * as constants from '@salesforce/retail-react-app/app/constants'
2020

2121
jest.mock('../../hooks/use-multi-site', () => jest.fn())
22+
jest.mock('../../hooks/use-update-shopper-context', () => ({
23+
useUpdateShopperContext: jest.fn()
24+
}))
2225

2326
let windowSpy
2427
let originalValue

packages/template-retail-react-app/app/components/product-tile/index.jsx

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ import {PRODUCT_BADGE_DETAILS} from '@salesforce/retail-react-app/app/constants'
5151
const IconButtonWithRegistration = withRegistration(IconButton)
5252

5353
// Component Skeleton
54+
const PricingAndPromotionsSkeleton = () => {
55+
return (
56+
<Stack spacing={2} data-testid="sf-product-tile-pricing-and-promotions-skeleton">
57+
<ChakraSkeleton width="80px" height="20px" />
58+
<ChakraSkeleton width={{base: '120px', md: '220px'}} height="12px" />
59+
</Stack>
60+
)
61+
}
62+
5463
export const Skeleton = () => {
5564
const styles = useMultiStyleConfig('ProductTile')
5665
return (
@@ -61,8 +70,7 @@ export const Skeleton = () => {
6170
<ChakraSkeleton />
6271
</AspectRatio>
6372
</Box>
64-
<ChakraSkeleton width="80px" height="20px" />
65-
<ChakraSkeleton width={{base: '120px', md: '220px'}} height="12px" />
73+
<PricingAndPromotionsSkeleton />
6674
</Stack>
6775
</Box>
6876
)
@@ -83,6 +91,7 @@ const ProductTile = (props) => {
8391
product,
8492
selectableAttributeId = PRODUCT_TILE_SELECTABLE_ATTRIBUTE_ID,
8593
badgeDetails = PRODUCT_BADGE_DETAILS,
94+
isRefreshingData = false,
8695
...rest
8796
} = props
8897
const {imageGroups, productId, representedProduct, variants} = product
@@ -245,12 +254,18 @@ const ProductTile = (props) => {
245254
{/* Title */}
246255
<Text {...styles.title}>{localizedProductName}</Text>
247256

248-
{/* Price */}
249-
<DisplayPrice priceData={priceData} currency={currency} />
257+
{isRefreshingData ? (
258+
<PricingAndPromotionsSkeleton />
259+
) : (
260+
<>
261+
{/* Price */}
262+
<DisplayPrice priceData={priceData} currency={currency} />
250263

251-
{/* Promotion call-out message */}
252-
{shouldShowPromoCallout(productWithFilteredVariants) && (
253-
<PromoCallout product={productWithFilteredVariants} />
264+
{/* Promotion call-out message */}
265+
{shouldShowPromoCallout(productWithFilteredVariants) && (
266+
<PromoCallout product={productWithFilteredVariants} />
267+
)}
268+
</>
254269
)}
255270
</Link>
256271
{enableFavourite && (
@@ -376,7 +391,11 @@ ProductTile.propTypes = {
376391
/**
377392
* Details of badge labels and the corresponding product custom properties that enable badges.
378393
*/
379-
badgeDetails: PropTypes.array
394+
badgeDetails: PropTypes.array,
395+
/**
396+
* Determines whether to display a skeleton over personalizable data (e.g., pricing and promotions) during data refresh.
397+
*/
398+
isRefreshingData: PropTypes.bool
380399
}
381400

382401
export default ProductTile

packages/template-retail-react-app/app/components/product-tile/index.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ test('Renders Skeleton', () => {
4545
expect(skeleton).toBeDefined()
4646
})
4747

48+
test('Renders PricingAndPromotionsSkeleton when isRefetching is true', async () => {
49+
const {getAllByTestId, queryByTestId} = renderWithProviders(
50+
<ProductTile isRefreshingData={true} product={mockMasterProductHitWithMultipleVariants} />
51+
)
52+
53+
const skeleton = getAllByTestId('sf-product-tile-pricing-and-promotions-skeleton')
54+
55+
expect(skeleton).toBeDefined()
56+
expect(queryByTestId('sf-product-tile-skeleton')).not.toBeInTheDocument()
57+
})
58+
4859
test('Remove from wishlist cannot be muti-clicked', () => {
4960
const onClick = jest.fn()
5061

packages/template-retail-react-app/app/constants.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export const SHIPPING_COUNTRY_CODES = [
149149
]
150150

151151
// Constant to Enable Active Data
152-
export const ACTIVE_DATA_ENABLED = false
152+
export const ACTIVE_DATA_ENABLED = true
153153

154154
export const REMOVE_UNAVAILABLE_CART_ITEM_DIALOG_CONFIG = {
155155
dialogTitle: defineMessage({
@@ -201,3 +201,33 @@ export const STORE_LOCATOR_DISTANCE = 100
201201
export const STORE_LOCATOR_NUM_STORES_PER_LOAD = 10
202202
export const STORE_LOCATOR_DISTANCE_UNIT = 'km'
203203
export const STORE_LOCATOR_IS_ENABLED = true
204+
205+
// Constants for shopper context
206+
// Supported non-string field types used in SHOPPER_CONTEXT_SEARCH_PARAMS below.
207+
// Only non-string types need to be identified using the "type" field.
208+
// If no "type" field is present, the value will be parsed as a string by default.
209+
export const SHOPPER_CONTEXT_FIELD_TYPES = {
210+
INT: 'int',
211+
DOUBLE: 'double',
212+
ARRAY: 'array'
213+
}
214+
export const SHOPPER_CONTEXT_SEARCH_PARAMS = {
215+
sourceCode: {paramName: 'sourceCode'},
216+
geoLocation: {
217+
city: {paramName: 'city'},
218+
country: {paramName: 'country'},
219+
countryCode: {paramName: 'countryCode'},
220+
latitude: {paramName: 'latitude', type: SHOPPER_CONTEXT_FIELD_TYPES.DOUBLE},
221+
longitude: {paramName: 'longitude', type: SHOPPER_CONTEXT_FIELD_TYPES.DOUBLE},
222+
metroCode: {paramName: 'metroCode'},
223+
postalCode: {paramName: 'postalCode'},
224+
region: {paramName: 'region'},
225+
regionCode: {paramName: 'regionCode'}
226+
},
227+
customQualifiers: {
228+
// Add custom qualifiers here
229+
},
230+
assignmentQualifiers: {
231+
// Add assignment qualifiers here
232+
}
233+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright (c) 2024, salesforce.com, inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import {useMemo} from 'react'
9+
import {useLocation} from 'react-router-dom'
10+
11+
// Constants
12+
import {
13+
SHOPPER_CONTEXT_SEARCH_PARAMS,
14+
SHOPPER_CONTEXT_FIELD_TYPES
15+
} from '@salesforce/retail-react-app/app/constants'
16+
17+
/**
18+
* This hook will return a shopper context object when search params related
19+
* to shopper context are present.
20+
*
21+
* @returns {Object} A shopper context object that can be passed to the Shopper Context API
22+
*/
23+
export const useShopperContextSearchParams = () => {
24+
const {search} = useLocation()
25+
return useMemo(() => {
26+
const searchParamsObj = new URLSearchParams(search)
27+
const {
28+
geoLocation: geoLocationSearchParams,
29+
customQualifiers: customQualiferSearchParams,
30+
assignmentQualifiers: assignmentQualifierSearchParams,
31+
...rootQualifierSearchParams
32+
} = SHOPPER_CONTEXT_SEARCH_PARAMS
33+
34+
const rootQualifiers = getShopperContextFromSearchParams(
35+
searchParamsObj,
36+
rootQualifierSearchParams
37+
)
38+
const geoLocation = getShopperContextFromSearchParams(
39+
searchParamsObj,
40+
geoLocationSearchParams
41+
)
42+
const customQualifiers = getShopperContextFromSearchParams(
43+
searchParamsObj,
44+
customQualiferSearchParams
45+
)
46+
const assignmentQualifiers = getShopperContextFromSearchParams(
47+
searchParamsObj,
48+
assignmentQualifierSearchParams
49+
)
50+
51+
return {
52+
...rootQualifiers,
53+
...(Object.keys(geoLocation).length && {geoLocation}),
54+
...(Object.keys(customQualifiers).length && {customQualifiers}),
55+
...(Object.keys(assignmentQualifiers).length && {assignmentQualifiers})
56+
}
57+
}, [search])
58+
}
59+
60+
/**
61+
* Converts search parameters into a shopper context object based on a provided mapping.
62+
*
63+
* @private
64+
* @param {URLSearchParams} searchParamsObj - The search parameters object
65+
* @param {Object} searchParamToApiFieldMapping - An object mapping search parameter keys to API field names and types
66+
* @returns {Object} The shopper context object where keys are API field names and values are the converted search parameter values.
67+
*/
68+
export const getShopperContextFromSearchParams = (
69+
searchParamsObj,
70+
searchParamToApiFieldMapping
71+
) => {
72+
const shopperContextObj = {}
73+
for (const [searchParamKey, searchParamValue] of searchParamsObj.entries()) {
74+
// Find the mapping entry where paramName matches the searchParamKey
75+
const mappingEntry = Object.entries(searchParamToApiFieldMapping).find(
76+
([, entry]) => entry.paramName === searchParamKey
77+
)
78+
79+
if (mappingEntry) {
80+
const [apiFieldName, {type}] = mappingEntry
81+
switch (type) {
82+
case SHOPPER_CONTEXT_FIELD_TYPES.INT:
83+
case SHOPPER_CONTEXT_FIELD_TYPES.DOUBLE:
84+
shopperContextObj[apiFieldName] = Number(searchParamValue)
85+
break
86+
case SHOPPER_CONTEXT_FIELD_TYPES.ARRAY:
87+
shopperContextObj[apiFieldName] = searchParamsObj.getAll(searchParamKey)
88+
break
89+
default:
90+
// Default to string
91+
shopperContextObj[apiFieldName] = searchParamValue
92+
break
93+
}
94+
}
95+
}
96+
97+
return shopperContextObj
98+
}

0 commit comments

Comments
 (0)