@salesforce/retail-react-app 7.0.0-preview.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +9 -8
  2. package/app/components/dynamic-image/index.jsx +91 -16
  3. package/app/components/dynamic-image/index.test.js +214 -30
  4. package/app/components/image/index.jsx +5 -13
  5. package/app/components/image/index.test.js +6 -3
  6. package/app/components/island/README.md +15 -10
  7. package/app/components/island/index.jsx +12 -5
  8. package/app/components/island/index.test.js +35 -0
  9. package/app/components/passwordless-login/index.jsx +4 -5
  10. package/app/components/passwordless-login/index.test.js +2 -4
  11. package/app/components/product-tile/index.jsx +1 -1
  12. package/app/components/product-view-modal/bundle.jsx +12 -2
  13. package/app/components/social-login/index.jsx +1 -0
  14. package/app/components/standard-login/index.jsx +4 -1
  15. package/app/constants.js +3 -0
  16. package/app/hooks/use-auth-modal.js +68 -67
  17. package/app/hooks/use-auth-modal.test.js +93 -23
  18. package/app/hooks/use-datacloud.js +169 -192
  19. package/app/hooks/use-datacloud.test.js +273 -17
  20. package/app/pages/cart/index.jsx +2 -1
  21. package/app/pages/cart/partials/cart-secondary-button-group.jsx +8 -10
  22. package/app/pages/cart/partials/cart-secondary-button-group.test.js +2 -3
  23. package/app/pages/checkout/partials/contact-info.jsx +9 -8
  24. package/app/pages/checkout/partials/contact-info.test.js +41 -4
  25. package/app/pages/checkout/partials/login-state.jsx +3 -3
  26. package/app/pages/home/index.test.js +2 -1
  27. package/app/pages/login/index.jsx +37 -37
  28. package/app/pages/login/index.test.js +42 -0
  29. package/app/pages/product-detail/index.jsx +64 -73
  30. package/app/pages/product-list/index.jsx +19 -9
  31. package/app/pages/product-list/index.test.js +153 -19
  32. package/app/utils/image.js +29 -0
  33. package/app/utils/image.test.js +141 -1
  34. package/app/utils/responsive-image.js +197 -115
  35. package/app/utils/responsive-image.test.js +483 -133
  36. package/config/default.js +2 -2
  37. package/config/mocks/default.js +2 -2
  38. package/package.json +7 -7
@@ -6,7 +6,10 @@
6
6
  */
7
7
  /* eslint-disable jest/no-conditional-expect */
8
8
  import {version} from 'react'
9
- import {getImageAttributes} from '@salesforce/retail-react-app/app/utils/image'
9
+ import {
10
+ getImageAttributes,
11
+ getImageLinkAttributes
12
+ } from '@salesforce/retail-react-app/app/utils/image'
10
13
 
11
14
  const [majorStr] = version.split('.', 1)
12
15
  const major = parseInt(majorStr, 10)
@@ -124,3 +127,140 @@ describe('getImageAttributes()', () => {
124
127
  })
125
128
  })
126
129
  })
130
+
131
+ describe('getImageLinkAttributes()', () => {
132
+ test('empty image properties', () => {
133
+ expect(getImageLinkAttributes({})).toBeUndefined()
134
+ })
135
+
136
+ test('image properties without fetch priority and loading strategy', () => {
137
+ expect(getImageLinkAttributes(imageProps)).toBeUndefined()
138
+ })
139
+
140
+ test('image properties with fetch priority, without loading strategy, sizes and srcSet', () => {
141
+ expect(getImageLinkAttributes({...imageProps, fetchPriority: 'high'})).toStrictEqual({
142
+ rel: 'preload',
143
+ as: 'image',
144
+ href: imageProps.src,
145
+ fetchPriority: 'high'
146
+ })
147
+ })
148
+
149
+ test('image properties with fetch priority, sizes and srcSet, without loading strategy', () => {
150
+ expect(
151
+ getImageLinkAttributes({
152
+ ...imageProps,
153
+ fetchPriority: 'high',
154
+ sizes: '100vw',
155
+ srcSet: `${imageProps.src} 240w`
156
+ })
157
+ ).toStrictEqual({
158
+ rel: 'preload',
159
+ as: 'image',
160
+ href: imageProps.src,
161
+ fetchPriority: 'high',
162
+ imageSizes: '100vw',
163
+ imageSrcSet: `${imageProps.src} 240w`
164
+ })
165
+ })
166
+
167
+ test('image properties with fetch priority, type and media, without loading strategy, sizes and srcSet', () => {
168
+ expect(
169
+ getImageLinkAttributes({
170
+ ...imageProps,
171
+ fetchPriority: 'high',
172
+ type: 'image/jpeg',
173
+ media: '(min-width: 80em)'
174
+ })
175
+ ).toStrictEqual({
176
+ rel: 'preload',
177
+ as: 'image',
178
+ href: imageProps.src,
179
+ fetchPriority: 'high',
180
+ type: 'image/jpeg',
181
+ media: '(min-width: 80em)'
182
+ })
183
+ })
184
+
185
+ test('image properties with fetch priority, type, media, sizes and srcSet, without loading strategy', () => {
186
+ expect(
187
+ getImageLinkAttributes({
188
+ ...imageProps,
189
+ fetchPriority: 'high',
190
+ type: 'image/jpeg',
191
+ media: '(min-width: 80em)',
192
+ sizes: '100vw',
193
+ srcSet: `${imageProps.src} 240w`
194
+ })
195
+ ).toStrictEqual({
196
+ rel: 'preload',
197
+ as: 'image',
198
+ href: imageProps.src,
199
+ fetchPriority: 'high',
200
+ type: 'image/jpeg',
201
+ media: '(min-width: 80em)',
202
+ imageSizes: '100vw',
203
+ imageSrcSet: `${imageProps.src} 240w`
204
+ })
205
+ })
206
+
207
+ describe('loading="eager"', () => {
208
+ test('image properties without fetch priority', () => {
209
+ expect(getImageLinkAttributes({...imageProps, loading: 'eager'})).toBeUndefined()
210
+ })
211
+
212
+ test('image properties with fetch priority, without loading strategy, sizes and srcSet', () => {
213
+ expect(
214
+ getImageLinkAttributes({...imageProps, loading: 'eager', fetchPriority: 'high'})
215
+ ).toStrictEqual({
216
+ rel: 'preload',
217
+ as: 'image',
218
+ href: imageProps.src,
219
+ fetchPriority: 'high'
220
+ })
221
+ })
222
+
223
+ test('image properties with fetch priority, sizes and srcSet', () => {
224
+ expect(
225
+ getImageLinkAttributes({
226
+ ...imageProps,
227
+ loading: 'eager',
228
+ fetchPriority: 'high',
229
+ sizes: '100vw',
230
+ srcSet: `${imageProps.src} 240w`
231
+ })
232
+ ).toStrictEqual({
233
+ rel: 'preload',
234
+ as: 'image',
235
+ href: imageProps.src,
236
+ fetchPriority: 'high',
237
+ imageSizes: '100vw',
238
+ imageSrcSet: `${imageProps.src} 240w`
239
+ })
240
+ })
241
+ })
242
+
243
+ describe('loading="lazy"', () => {
244
+ test('image properties without fetch priority', () => {
245
+ expect(getImageLinkAttributes({...imageProps, loading: 'lazy'})).toBeUndefined()
246
+ })
247
+
248
+ test('image properties with fetch priority', () => {
249
+ expect(
250
+ getImageLinkAttributes({...imageProps, loading: 'lazy', fetchPriority: 'high'})
251
+ ).toBeUndefined()
252
+ })
253
+
254
+ test('image properties with fetch priority, sizes and srcSet', () => {
255
+ expect(
256
+ getImageLinkAttributes({
257
+ ...imageProps,
258
+ loading: 'eager',
259
+ fetchPriority: 'lazy',
260
+ sizes: '100vw',
261
+ srcSet: `${imageProps.src} 240w`
262
+ })
263
+ ).toBeUndefined()
264
+ })
265
+ })
266
+ })
@@ -1,10 +1,9 @@
1
1
  /*
2
- * Copyright (c) 2021, salesforce.com, inc.
2
+ * Copyright (c) 2025, Salesforce, Inc.
3
3
  * All rights reserved.
4
4
  * SPDX-License-Identifier: BSD-3-Clause
5
5
  * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
6
  */
7
-
8
7
  import theme from '@salesforce/retail-react-app/app/components/shared/theme'
9
8
  import logger from '@salesforce/retail-react-app/app/utils/logger-instance'
10
9
 
@@ -17,119 +16,45 @@ const getBreakpointLabels = (breakpoints) =>
17
16
  .sort((a, b) => parseFloat(a[1]) - parseFloat(b[1]))
18
17
  .map(([key]) => key)
19
18
 
19
+ const vwValue = /^\d+vw$/
20
+ const pxValue = /^\d+px$/
21
+ const emValue = /^\d+em$/
22
+
20
23
  const {breakpoints: defaultBreakpoints} = theme
21
24
  let themeBreakpoints = defaultBreakpoints
22
25
  let breakpointLabels = getBreakpointLabels(themeBreakpoints)
23
26
 
24
27
  /**
25
- * @param {Object} props
26
- * @param {string} props.src - Dynamic src having an optional param that can vary with widths. For example: `image[_{width}].jpg` or `image.jpg[?sw={width}&q=60]`
27
- * @param {(number[]|string[]|Object)} [props.widths] - Image widths relative to the breakpoints, whose units can either be px or vw or unit-less. They will be mapped to the corresponding `sizes` and `srcSet`.
28
- * @param {Object} [props.breakpoints] - The current theme's breakpoints. If not given, Chakra's default breakpoints will be used.
29
- * @return {Object} src, sizes, and srcSet props for your image component
28
+ * Helper to create very specific `media` attributes for responsive preload purposes.
29
+ * @param {number} breakpointIndex
30
+ * @return {({min?: string, max?: string} | undefined)}
31
+ * @see {@link https://web.dev/articles/preload-responsive-images#picture}
30
32
  */
31
- export const getResponsiveImageAttributes = ({src, widths, breakpoints = defaultBreakpoints}) => {
32
- if (!widths) {
33
- return {
34
- src: getSrcWithoutOptionalParams(src)
33
+ const obtainImageLinkMedia = (breakpointIndex) => {
34
+ const toMediaValue = (bp, type) => {
35
+ const val = themeBreakpoints[bp]
36
+ if (emValue.test(val)) {
37
+ // em value
38
+ const parsed = parseFloat(val)
39
+ return {[type]: type === 'max' ? `${parsed - 0.01}em` : `${parsed}em`}
35
40
  }
36
- }
37
-
38
- themeBreakpoints = breakpoints
39
- breakpointLabels = getBreakpointLabels(themeBreakpoints)
40
41
 
41
- // Order of these attributes matter! If src is not last, Safari will refetch images
42
- // multiple times (once when it processes src and again when it processes sizes / srcSet)
43
- // See https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2223
44
- return {
45
- sizes: mapWidthsToSizes(widths),
46
- srcSet: mapWidthsToSrcSet(widths, src),
47
- src: getSrcWithoutOptionalParams(src)
42
+ const parsed = parseInt(val, 10)
43
+ return {[type]: type === 'max' ? `${parsed - 1}px` : `${parsed}px`}
48
44
  }
49
- }
50
-
51
- /**
52
- * @param {(number[]|string[]|Object)} widths
53
- * @return {string}
54
- */
55
- const mapWidthsToSizes = (widths) => {
56
- const _widths = withUnit(Array.isArray(widths) ? widths : widthsAsArray(widths))
57
45
 
58
- return breakpointLabels
59
- .slice(0, _widths.length)
60
- .map((bp, i) => {
61
- return i === 0 ? _widths[i] : `(min-width: ${themeBreakpoints[bp]}) ${_widths[i]}`
62
- })
63
- .reverse()
64
- .join(', ')
65
- }
66
-
67
- /**
68
- * @param {(number[]|string[]|Object)} widths
69
- * @return {string}
70
- */
71
- const mapWidthsToSrcSet = (widths, dynamicSrc) => {
72
- let _widths = isObject(widths) ? widthsAsArray(widths) : widths.slice(0)
73
-
74
- if (_widths.length < breakpointLabels.length) {
75
- const lastWidth = _widths[_widths.length - 1]
76
- const amountToPad = breakpointLabels.length - _widths.length
77
-
78
- _widths = [..._widths, ...Array(amountToPad).fill(lastWidth)]
46
+ const nextBp = breakpointLabels.at(breakpointIndex + 1)
47
+ const currentBp = breakpointLabels.at(breakpointIndex)
48
+ if (breakpointIndex === 0) {
49
+ // first
50
+ return toMediaValue(nextBp, 'max')
51
+ } else if (breakpointIndex === breakpointLabels.length - 1) {
52
+ // last
53
+ return toMediaValue(currentBp, 'min')
79
54
  }
80
-
81
- _widths = uniqueArray(convertToPxNumbers(_widths)).sort()
82
-
83
- const srcSet = []
84
- _widths.forEach((width) => {
85
- srcSet.push(width)
86
- srcSet.push(width * 2) // for devices with higher pixel density
87
- })
88
-
89
- return srcSet.map((imageWidth) => `${getSrc(dynamicSrc, imageWidth)} ${imageWidth}w`).join(', ')
90
- }
91
-
92
- const vwValue = /^\d+vw$/
93
- const pxValue = /^\d+px$/
94
-
95
- /**
96
- * @param {string[]|number[]} widths
97
- * @return {number[]}
98
- */
99
- const convertToPxNumbers = (widths) => {
100
- return widths
101
- .map((width, i) => {
102
- if (typeof width === 'number') {
103
- return width
104
- }
105
-
106
- if (vwValue.test(width)) {
107
- const vw = parseFloat(width)
108
- const currentBp = breakpointLabels[i]
109
- // We imagine the biggest image for the current breakpoint
110
- // to be when the viewport is closely approaching the _next breakpoint_.
111
- const nextBp = breakpointLabels[i + 1]
112
-
113
- if (nextBp) {
114
- return vwToPx(vw, nextBp)
115
- } else {
116
- // We're already at the last breakpoint
117
- return widths[i] !== widths[i - 1] ? vwToPx(vw, currentBp) : undefined
118
- }
119
- } else if (pxValue.test(width)) {
120
- return parseInt(width)
121
- } else {
122
- logger.error('Expecting to see values with vw or px unit only', {
123
- namespace: 'utils.convertToPxNumbers'
124
- })
125
- return 0
126
- }
127
- })
128
- .filter((width) => width !== undefined)
55
+ return {...toMediaValue(currentBp, 'min'), ...toMediaValue(nextBp, 'max')}
129
56
  }
130
57
 
131
- const uniqueArray = (array) => [...new Set(array)]
132
-
133
58
  /**
134
59
  * @param {(number[]|string[])} widths
135
60
  */
@@ -147,24 +72,28 @@ const isObject = (o) => o?.constructor === Object
147
72
  */
148
73
  const widthsAsArray = (widths) => {
149
74
  const biggestBreakpoint = breakpointLabels.filter((bp) => Boolean(widths[bp])).pop()
150
-
151
75
  let mostRecent
152
76
  return breakpointLabels.slice(0, breakpointLabels.indexOf(biggestBreakpoint) + 1).map((bp) => {
153
77
  if (widths[bp]) {
154
78
  mostRecent = widths[bp]
155
79
  return widths[bp]
156
- } else {
157
- return mostRecent
158
80
  }
81
+ return mostRecent
159
82
  })
160
83
  }
161
84
 
85
+ /**
86
+ * @param {number} em
87
+ * @param {number} [browserDefaultFontSize]
88
+ */
89
+ const emToPx = (em, browserDefaultFontSize = 16) => Math.round(em * browserDefaultFontSize)
90
+
162
91
  /**
163
92
  * @param {number} vw
164
93
  * @param {string} breakpoint
165
94
  */
166
95
  const vwToPx = (vw, breakpoint) => {
167
- let result = (vw / 100) * parseFloat(themeBreakpoints[breakpoint])
96
+ const result = (vw / 100) * parseFloat(themeBreakpoints[breakpoint])
168
97
  const breakpointsDefinedInPx = Object.values(themeBreakpoints).some((val) => pxValue.test(val))
169
98
 
170
99
  // Assumes theme's breakpoints are defined in either em or px
@@ -172,12 +101,6 @@ const vwToPx = (vw, breakpoint) => {
172
101
  return breakpointsDefinedInPx ? result : emToPx(result)
173
102
  }
174
103
 
175
- /**
176
- * @param {number} em
177
- * @param {number} [browserDefaultFontSize]
178
- */
179
- const emToPx = (em, browserDefaultFontSize = 16) => Math.round(em * browserDefaultFontSize)
180
-
181
104
  /**
182
105
  * @param {string} dynamicSrc
183
106
  * @param {number} imageWidth
@@ -198,7 +121,166 @@ export const getSrc = (dynamicSrc, imageWidth) => {
198
121
  * // Returns 'https://example.com/image.jpg'
199
122
  * getSrcWithoutOptionalParams('https://example.com/image.jpg[?sw={width}]')
200
123
  */
201
- const getSrcWithoutOptionalParams = (dynamicSrc) => {
202
- const optionalParams = /\[[^\]]+\]/g
203
- return dynamicSrc.replace(optionalParams, '')
124
+ const getSrcWithoutOptionalParams = (dynamicSrc) => dynamicSrc.replace(/\[[^\]]+\]/g, '')
125
+
126
+ const padArray = (arr) => {
127
+ const l1 = arr.length
128
+ const l2 = breakpointLabels.length
129
+ if (l1 < l2) {
130
+ const lastEntry = arr.at(-1)
131
+ const amountToPad = l2 - l1
132
+ return [...arr, ...Array(amountToPad).fill(lastEntry)]
133
+ }
134
+ return arr
135
+ }
136
+
137
+ /**
138
+ * @param {string[]|number[]} widths
139
+ * @return {number[]}
140
+ */
141
+ const convertToPxNumbers = (widths) =>
142
+ widths
143
+ .map((width, i) => {
144
+ if (typeof width === 'number') {
145
+ return width
146
+ }
147
+
148
+ if (vwValue.test(width)) {
149
+ const vw = parseFloat(width)
150
+ const currentBp = breakpointLabels[i]
151
+ // We imagine the biggest image for the current breakpoint
152
+ // to be when the viewport is closely approaching the _next breakpoint_.
153
+ const nextBp = breakpointLabels[i + 1]
154
+
155
+ if (nextBp) {
156
+ return vwToPx(vw, nextBp)
157
+ }
158
+ // We're already at the last breakpoint
159
+ return widths[i] !== widths[i - 1] ? vwToPx(vw, currentBp) : undefined
160
+ } else if (pxValue.test(width)) {
161
+ return parseInt(width, 10)
162
+ } else {
163
+ logger.error('Expecting to see values with vw or px unit only', {
164
+ namespace: 'utils.convertToPxNumbers'
165
+ })
166
+ return 0
167
+ }
168
+ })
169
+ .filter((width) => width !== undefined)
170
+
171
+ /**
172
+ * Transforms an array of preload link objects by converting the raw `media`
173
+ * property of each entry (with `min` and/or `max` values) into actual media
174
+ * queries using `(min-width)` and/or `(max-width)`.
175
+ * @param {{srcSet: string, sizes: string, media: {min?: string, max?: string}}[]} links
176
+ * @return {{srcSet: string, sizes: string, media: string}[]}
177
+ */
178
+ const convertImageLinksMedia = (links) =>
179
+ links.map((link) => {
180
+ const {
181
+ media: {min, max}
182
+ } = link
183
+ const acc = []
184
+ if (min) {
185
+ acc.push(`(min-width: ${min})`)
186
+ }
187
+ if (max) {
188
+ acc.push(`(max-width: ${max})`)
189
+ }
190
+ return {...link, media: acc.join(' and ')}
191
+ })
192
+
193
+ /**
194
+ * Determines the data required for the responsive `<source>` and `<link rel="preload">
195
+ * portions/elements.
196
+ * @param {string} src
197
+ * @param {(number[]|string[])} widths
198
+ * @returns {{sources: {srcSet: string, sizes: string, media: string}[], links: {srcSet: string, sizes: string, media: string}[]}}
199
+ */
200
+ const getResponsiveSourcesAndLinks = (src, widths) => {
201
+ const sizesWidths = withUnit(widths)
202
+ const l = sizesWidths.length
203
+ const _sizes = breakpointLabels.map((bp, i) => {
204
+ return i === 0
205
+ ? {
206
+ media: undefined,
207
+ mediaLink: obtainImageLinkMedia(i),
208
+ sizes: sizesWidths[i]
209
+ }
210
+ : {
211
+ media: `(min-width: ${themeBreakpoints[bp]})`,
212
+ mediaLink: obtainImageLinkMedia(i),
213
+ sizes: sizesWidths.at(i >= l ? l - 1 : i)
214
+ }
215
+ })
216
+
217
+ const sourcesWidths = convertToPxNumbers(padArray(widths))
218
+ const sourcesLength = sourcesWidths.length
219
+ const {sources, links} = breakpointLabels.reduce(
220
+ (acc, bp, idx) => {
221
+ // To support higher-density devices, request all images in 1x and 2x widths
222
+ const width = sourcesWidths.at(idx >= sourcesLength ? sourcesLength - 1 : idx)
223
+ const {sizes, media, mediaLink} = _sizes.at(idx)
224
+ const lastSource = acc.sources.at(-1)
225
+ const lastLink = acc.links.at(-1)
226
+ const srcSet = [1, 2]
227
+ .map((factor) => {
228
+ const effectiveWidth = Math.round(width * factor)
229
+ const effectiveSize = Math.round(width * factor)
230
+ return `${getSrc(src, effectiveSize)} ${effectiveWidth}w`
231
+ })
232
+ .join(', ')
233
+
234
+ if (
235
+ idx < sourcesLength &&
236
+ (lastSource?.sizes !== sizes || srcSet !== lastSource?.srcSet)
237
+ ) {
238
+ // Only store new `<source>` if we haven't already stored those values
239
+ acc.sources.push({srcSet, sizes, media})
240
+ }
241
+
242
+ if (lastLink?.sizes !== sizes || srcSet !== lastLink?.srcSet) {
243
+ // Only store new `<link>` if we haven't already stored those values
244
+ acc.links.push({srcSet, sizes, media: mediaLink})
245
+ } else {
246
+ // If we have already stored those values, update the `max` portion of the related `<link>` data
247
+ lastLink.media.max = mediaLink.max
248
+ }
249
+ return acc
250
+ },
251
+ {sources: [], links: []}
252
+ )
253
+ return {sources: sources.reverse(), links: convertImageLinksMedia(links)}
254
+ }
255
+
256
+ /**
257
+ * Resolve the attributes required to create a DIS-optimized `<picture>` component.
258
+ * @param {Object} props
259
+ * @param {string} props.src - Dynamic src having an optional param that can vary with widths. For example: `image[_{width}].jpg` or `image.jpg[?sw={width}&q=60]`
260
+ * @param {(number[] |string[] |Object)} [props.widths] - Image widths relative to the breakpoints, whose units can either be px or vw or unit-less. They will be mapped to the corresponding `sizes` and `srcSet`.
261
+ * @param {Object} [props.breakpoints] - The current theme's breakpoints. If not given, Chakra's default breakpoints will be used.
262
+ * @return {Object} src, sizes, srcSet, media props for your image component
263
+ * @see {@link DynamicImage}
264
+ */
265
+ export const getResponsivePictureAttributes = ({src, widths, breakpoints = defaultBreakpoints}) => {
266
+ if (!widths) {
267
+ return {
268
+ sources: [],
269
+ links: [],
270
+ src: getSrcWithoutOptionalParams(src)
271
+ }
272
+ }
273
+
274
+ if (breakpoints !== themeBreakpoints) {
275
+ themeBreakpoints = breakpoints
276
+ breakpointLabels = getBreakpointLabels(themeBreakpoints)
277
+ }
278
+
279
+ const _widths = isObject(widths) ? widthsAsArray(widths) : widths.slice(0)
280
+ const {sources, links} = getResponsiveSourcesAndLinks(src, _widths)
281
+ return {
282
+ sources,
283
+ links,
284
+ src: getSrcWithoutOptionalParams(src)
285
+ }
204
286
  }