@salesforce/retail-react-app 2.4.0-dev → 2.4.0-dev.1

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.
@@ -429,6 +429,163 @@ export const mockSearchResults = {
429
429
  total: 4
430
430
  }
431
431
 
432
+ export const mockNoSearchResults = {
433
+ limit: 0,
434
+ query: 'dsflksajfdklsafj',
435
+ refinements: [
436
+ {
437
+ attributeId: 'cgid',
438
+ label: 'Category'
439
+ },
440
+ {
441
+ attributeId: 'c_refinementColor',
442
+ label: 'Colour',
443
+ values: [
444
+ {
445
+ hitCount: 0,
446
+ label: 'Beige',
447
+ presentationId: 'beige',
448
+ value: 'Beige'
449
+ },
450
+ {
451
+ hitCount: 0,
452
+ label: 'Black',
453
+ presentationId: 'black',
454
+ value: 'Black'
455
+ },
456
+ {
457
+ hitCount: 0,
458
+ label: 'Blue',
459
+ presentationId: 'blue',
460
+ value: 'Blue'
461
+ },
462
+ {
463
+ hitCount: 0,
464
+ label: 'Navy',
465
+ presentationId: 'navy',
466
+ value: 'Navy'
467
+ },
468
+ {
469
+ hitCount: 0,
470
+ label: 'Brown',
471
+ presentationId: 'brown',
472
+ value: 'Brown'
473
+ },
474
+ {
475
+ hitCount: 0,
476
+ label: 'Green',
477
+ presentationId: 'green',
478
+ value: 'Green'
479
+ },
480
+ {
481
+ hitCount: 0,
482
+ label: 'Grey',
483
+ presentationId: 'grey',
484
+ value: 'Grey'
485
+ },
486
+ {
487
+ hitCount: 0,
488
+ label: 'Orange',
489
+ presentationId: 'orange',
490
+ value: 'Orange'
491
+ },
492
+ {
493
+ hitCount: 0,
494
+ label: 'Pink',
495
+ presentationId: 'pink',
496
+ value: 'Pink'
497
+ },
498
+ {
499
+ hitCount: 0,
500
+ label: 'Purple',
501
+ presentationId: 'purple',
502
+ value: 'Purple'
503
+ },
504
+ {
505
+ hitCount: 0,
506
+ label: 'Red',
507
+ presentationId: 'red',
508
+ value: 'Red'
509
+ },
510
+ {
511
+ hitCount: 0,
512
+ label: 'White',
513
+ presentationId: 'white',
514
+ value: 'White'
515
+ },
516
+ {
517
+ hitCount: 0,
518
+ label: 'Yellow',
519
+ presentationId: 'yellow',
520
+ value: 'Yellow'
521
+ },
522
+ {
523
+ hitCount: 0,
524
+ label: 'Miscellaneous',
525
+ presentationId: 'miscellaneous',
526
+ value: 'Miscellaneous'
527
+ }
528
+ ]
529
+ },
530
+ {
531
+ attributeId: 'price',
532
+ label: 'Price'
533
+ },
534
+ {
535
+ attributeId: 'c_isNew',
536
+ label: 'New Arrival'
537
+ },
538
+ {
539
+ attributeId: 'brand',
540
+ label: 'By brand'
541
+ }
542
+ ],
543
+ searchPhraseSuggestions: {
544
+ suggestedTerms: [
545
+ {
546
+ originalTerm: 'dsflksajfdklsafj'
547
+ }
548
+ ]
549
+ },
550
+ selectedSortingOption: 'best-matches',
551
+ sortingOptions: [
552
+ {
553
+ id: 'best-matches',
554
+ label: 'Best Matches'
555
+ },
556
+ {
557
+ id: 'price-low-to-high',
558
+ label: 'Price Low To High'
559
+ },
560
+ {
561
+ id: 'price-high-to-low',
562
+ label: 'Price High to Low'
563
+ },
564
+ {
565
+ id: 'product-name-ascending',
566
+ label: 'Product Name A - Z'
567
+ },
568
+ {
569
+ id: 'product-name-descending',
570
+ label: 'Product Name Z - A'
571
+ },
572
+ {
573
+ id: 'brand',
574
+ label: 'Brand'
575
+ },
576
+ {
577
+ id: 'most-popular',
578
+ label: 'Most Popular'
579
+ },
580
+ {
581
+ id: 'top-sellers',
582
+ label: 'Top Sellers'
583
+ }
584
+ ],
585
+ offset: 0,
586
+ total: 0
587
+ }
588
+
432
589
  export const mockBasket = {
433
590
  adjustedMerchandizeTotalTax: 1.5,
434
591
  adjustedShippingTotalTax: 0.3,
@@ -161,14 +161,13 @@ export class EinsteinAPI {
161
161
  const endpoint = `/activities/${this.siteId}/viewSearch`
162
162
  const method = 'POST'
163
163
 
164
- const products = searchResults?.hits?.map((product) =>
165
- this._constructEinsteinProduct(product)
166
- )
164
+ const products =
165
+ searchResults?.hits?.map((product) => this._constructEinsteinProduct(product)) ?? []
167
166
 
168
167
  const body = {
169
168
  searchText,
170
169
  products,
171
- showProducts: true, // Needed by Reports and Dashboards to differentiate searches with results vs no results
170
+ showProducts: Boolean(products.length), // Needed by Reports and Dashboards to differentiate searches with results vs no results
172
171
  ...args
173
172
  }
174
173
 
@@ -11,7 +11,8 @@ import {
11
11
  mockCategory,
12
12
  mockSearchResults,
13
13
  mockBasket,
14
- mockRecommenderDetails
14
+ mockRecommenderDetails,
15
+ mockNoSearchResults
15
16
  } from '@salesforce/retail-react-app/app/hooks/einstein-mock-data'
16
17
  import fetchMock from 'jest-fetch-mock'
17
18
 
@@ -65,6 +66,23 @@ describe('EinsteinAPI', () => {
65
66
  )
66
67
  })
67
68
 
69
+ test('viewSearch: no search results', async () => {
70
+ const searchTerm = 'dsflksajfdklsafj'
71
+ await einsteinApi.sendViewSearch(searchTerm, mockNoSearchResults, {cookieId: 'test-usid'})
72
+ expect(fetch).toHaveBeenCalledWith(
73
+ 'http://localhost/test-path/v3/activities/test-site-id/viewSearch',
74
+ {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Content-Type': 'application/json',
78
+ 'x-cq-client-id': 'test-id'
79
+ },
80
+ // Most importantly, the body should contain `products=[]` and `showProducts=false`
81
+ body: '{"searchText":"dsflksajfdklsafj","products":[],"showProducts":false,"cookieId":"test-usid","realm":"test","instanceType":"sbx"}'
82
+ }
83
+ )
84
+ })
85
+
68
86
  test('viewCategory sends expected api request', async () => {
69
87
  await einsteinApi.sendViewCategory(mockCategory, mockSearchResults, {cookieId: 'test-usid'})
70
88
  expect(fetch).toHaveBeenCalledWith(
@@ -35,28 +35,30 @@ import PropTypes from 'prop-types'
35
35
  const onClient = typeof window !== 'undefined'
36
36
 
37
37
  const OrderProducts = ({productItems, currency}) => {
38
- const productItemsMap = productItems.reduce(
39
- (map, item) => ({...map, [item.productId]: item}),
40
- {}
41
- )
42
- const ids = Object.keys(productItemsMap).join(',') ?? ''
43
- const {data: {data: products} = {}, isLoading} = useProducts(
38
+ const orderProductIds = productItems.map((product) => product.productId)
39
+ const {data: products, isLoading} = useProducts(
44
40
  {
45
41
  parameters: {
46
- ids: ids
42
+ ids: orderProductIds
47
43
  }
48
44
  },
49
45
  {
50
- enabled: !!ids && onClient
46
+ enabled: !!orderProductIds && onClient,
47
+ select: (result) => {
48
+ return result?.data?.reduce((result, item) => {
49
+ const key = item.id
50
+ result[key] = item
51
+ return result
52
+ }, {})
53
+ }
51
54
  }
52
55
  )
53
-
54
- const variants = products?.map((product) => {
55
- const productItem = productItemsMap[product.id]
56
+ const variants = productItems?.map((item) => {
57
+ const product = products?.[item.productId]
56
58
  return {
57
- ...productItem,
58
- ...product,
59
- price: productItem.price
59
+ ...(product ? product : {}),
60
+ isProductUnavailable: !product,
61
+ ...item
60
62
  }
61
63
  })
62
64
 
@@ -22,6 +22,7 @@ import WishlistSecondaryButtonGroup from '@salesforce/retail-react-app/app/pages
22
22
 
23
23
  import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants'
24
24
  import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
25
+ import UnavailableProductConfirmationModal from '@salesforce/retail-react-app/app/components/unavailable-product-confirmation-modal'
25
26
 
26
27
  const numberOfSkeletonItems = 3
27
28
 
@@ -60,6 +61,7 @@ const AccountWishlist = () => {
60
61
  const deleteCustomerProductListItem = useShopperCustomersMutation(
61
62
  'deleteCustomerProductListItem'
62
63
  )
64
+
63
65
  const {data: customer} = useCurrentCustomer()
64
66
 
65
67
  const handleSecondaryAction = async (itemId, promise) => {
@@ -75,6 +77,23 @@ const AccountWishlist = () => {
75
77
  }
76
78
  }
77
79
 
80
+ const handleUnavailableProducts = async (unavailableProductIds) => {
81
+ if (!unavailableProductIds.length) return
82
+ await Promise.all(
83
+ unavailableProductIds.map(async (id) => {
84
+ const item = wishListItems?.find((item) => {
85
+ return item.productId.toString() === id.toString()
86
+ })
87
+ const parameters = {
88
+ customerId: customer.customerId,
89
+ itemId: item?.id,
90
+ listId: wishListData?.id
91
+ }
92
+ await deleteCustomerProductListItem.mutateAsync({parameters})
93
+ })
94
+ )
95
+ }
96
+
78
97
  const handleItemQuantityChanged = async (quantity, item) => {
79
98
  let isValidChange = false
80
99
  setSelectedItem(item.productId)
@@ -197,6 +216,11 @@ const AccountWishlist = () => {
197
216
  }
198
217
  />
199
218
  ))}
219
+
220
+ <UnavailableProductConfirmationModal
221
+ productIds={productIds}
222
+ handleUnavailableProducts={handleUnavailableProducts}
223
+ />
200
224
  </Stack>
201
225
  )
202
226
  }
@@ -56,13 +56,13 @@ import {
56
56
  useShopperCustomersMutation
57
57
  } from '@salesforce/commerce-sdk-react'
58
58
  import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
59
+ import UnavailableProductConfirmationModal from '@salesforce/retail-react-app/app/components/unavailable-product-confirmation-modal'
59
60
 
60
61
  const DEBOUNCE_WAIT = 750
61
62
  const Cart = () => {
62
63
  const {data: basket, isLoading} = useCurrentBasket()
63
-
64
64
  const productIds = basket?.productItems?.map(({productId}) => productId).join(',') ?? ''
65
- const {data: products} = useProducts(
65
+ const {data: products, isLoading: isProductsLoading} = useProducts(
66
66
  {
67
67
  parameters: {
68
68
  ids: productIds,
@@ -72,7 +72,6 @@ const Cart = () => {
72
72
  {
73
73
  enabled: Boolean(productIds),
74
74
  select: (result) => {
75
- // Convert array into key/value object with key is the product id
76
75
  return result?.data?.reduce((result, item) => {
77
76
  const key = item.id
78
77
  result[key] = item
@@ -81,9 +80,9 @@ const Cart = () => {
81
80
  }
82
81
  }
83
82
  )
83
+
84
84
  const {data: customer} = useCurrentCustomer()
85
85
  const {customerId, isRegistered} = customer
86
-
87
86
  /*****************Basket Mutation************************/
88
87
  const updateItemInBasketMutation = useShopperBasketsMutation('updateItemInBasket')
89
88
  const removeItemFromBasketMutation = useShopperBasketsMutation('removeItemFromBasket')
@@ -298,6 +297,18 @@ const Cart = () => {
298
297
  setSelectedItem(undefined)
299
298
  }
300
299
  }
300
+
301
+ const handleUnavailableProducts = async (unavailableProductIds) => {
302
+ const productItems = basket?.productItems?.filter((item) =>
303
+ unavailableProductIds?.includes(item.productId)
304
+ )
305
+
306
+ await Promise.all(
307
+ productItems.map(async (item) => {
308
+ await handleRemoveItem(item)
309
+ })
310
+ )
311
+ }
301
312
  /***************************** Update Cart **************************/
302
313
 
303
314
  /***************************** Update quantity **************************/
@@ -448,6 +459,9 @@ const Cart = () => {
448
459
  ...productItem,
449
460
  ...(products &&
450
461
  products[productItem.productId]),
462
+ isProductUnavailable: !isProductsLoading
463
+ ? !products?.[productItem.productId]
464
+ : undefined,
451
465
  price: productItem.price,
452
466
  quantity: localQuantity[productItem.itemId]
453
467
  ? localQuantity[productItem.itemId]
@@ -536,7 +550,6 @@ const Cart = () => {
536
550
  >
537
551
  <CartCta />
538
552
  </Box>
539
-
540
553
  <ConfirmationModal
541
554
  {...REMOVE_CART_ITEM_CONFIRMATION_DIALOG_CONFIG}
542
555
  onPrimaryAction={() => {
@@ -545,6 +558,11 @@ const Cart = () => {
545
558
  onAlternateAction={() => {}}
546
559
  {...modalProps}
547
560
  />
561
+
562
+ <UnavailableProductConfirmationModal
563
+ productIds={productIds.split(',')}
564
+ handleUnavailableProducts={handleUnavailableProducts}
565
+ />
548
566
  </Box>
549
567
  )
550
568
  }
@@ -29,7 +29,18 @@ import OrderSummary from '@salesforce/retail-react-app/app/components/order-summ
29
29
  import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
30
30
  import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
31
31
  import CheckoutSkeleton from '@salesforce/retail-react-app/app/pages/checkout/partials/checkout-skeleton'
32
- import {useUsid, useShopperOrdersMutation} from '@salesforce/commerce-sdk-react'
32
+ import {
33
+ useUsid,
34
+ useShopperOrdersMutation,
35
+ useShopperBasketsMutation
36
+ } from '@salesforce/commerce-sdk-react'
37
+ import UnavailableProductConfirmationModal from '@salesforce/retail-react-app/app/components/unavailable-product-confirmation-modal'
38
+ import {
39
+ API_ERROR_MESSAGE,
40
+ TOAST_MESSAGE_REMOVED_ITEM_FROM_CART
41
+ } from '@salesforce/retail-react-app/app/constants'
42
+ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
43
+ import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner'
33
44
 
34
45
  const Checkout = () => {
35
46
  const {formatMessage} = useIntl()
@@ -51,11 +62,6 @@ const Checkout = () => {
51
62
  setIsLoading(true)
52
63
  try {
53
64
  const order = await createOrder({
54
- // We send the SLAS usid via this header. This is required by ECOM to map
55
- // Einstein events sent via the API with the finishOrder event fired by ECOM
56
- // when an Order transitions from Created to New status.
57
- // Without this, various order conversion metrics will not appear on reports and dashboards
58
- headers: {_sfdc_customer_id: usid},
59
65
  body: {basketId: basket.basketId}
60
66
  })
61
67
  navigate(`/checkout/confirmation/${order.orderNo}`)
@@ -163,6 +169,43 @@ const Checkout = () => {
163
169
  const CheckoutContainer = () => {
164
170
  const {data: customer} = useCurrentCustomer()
165
171
  const {data: basket} = useCurrentBasket()
172
+ const {formatMessage} = useIntl()
173
+ const productIds = basket?.productItems?.map(({productId}) => productId) ?? []
174
+ const removeItemFromBasketMutation = useShopperBasketsMutation('removeItemFromBasket')
175
+ const toast = useToast()
176
+ const [isDeletingUnavailableItem, setIsDeletingUnavailableItem] = useState(false)
177
+
178
+ const handleRemoveItem = async (product) => {
179
+ await removeItemFromBasketMutation.mutateAsync(
180
+ {
181
+ parameters: {basketId: basket.basketId, itemId: product.itemId}
182
+ },
183
+ {
184
+ onSuccess: () => {
185
+ toast({
186
+ title: formatMessage(TOAST_MESSAGE_REMOVED_ITEM_FROM_CART, {quantity: 1}),
187
+ status: 'success'
188
+ })
189
+ },
190
+ onError: () => {
191
+ toast({
192
+ title: formatMessage(API_ERROR_MESSAGE),
193
+ status: 'error'
194
+ })
195
+ }
196
+ }
197
+ )
198
+ }
199
+ const handleUnavailableProducts = async (unavailableProductIds) => {
200
+ setIsDeletingUnavailableItem(true)
201
+ const productItems = basket?.productItems?.filter((item) =>
202
+ unavailableProductIds?.includes(item.productId)
203
+ )
204
+ for (let item of productItems) {
205
+ await handleRemoveItem(item)
206
+ }
207
+ setIsDeletingUnavailableItem(false)
208
+ }
166
209
 
167
210
  if (!customer || !customer.customerId || !basket || !basket.basketId) {
168
211
  return <CheckoutSkeleton />
@@ -170,7 +213,13 @@ const CheckoutContainer = () => {
170
213
 
171
214
  return (
172
215
  <CheckoutProvider>
216
+ {isDeletingUnavailableItem && <LoadingSpinner wrapperStyles={{height: '100vh'}} />}
217
+
173
218
  <Checkout />
219
+ <UnavailableProductConfirmationModal
220
+ productIds={productIds}
221
+ handleUnavailableProducts={handleUnavailableProducts}
222
+ />
174
223
  </CheckoutProvider>
175
224
  )
176
225
  }
@@ -65,7 +65,19 @@ beforeEach(() => {
65
65
  global.server.use(
66
66
  // mock product details
67
67
  rest.get('*/products', (req, res, ctx) => {
68
- return res(ctx.json({data: [{id: '701642811398M'}]}))
68
+ return res(
69
+ ctx.json({
70
+ data: [
71
+ {
72
+ id: '701643070725M',
73
+ currency: 'GBP',
74
+ name: 'Long Sleeve Crew Neck',
75
+ pricePerUnit: 19.18,
76
+ price: 19.18
77
+ }
78
+ ]
79
+ })
80
+ )
69
81
  }),
70
82
  // mock the available shipping methods
71
83
  rest.get('*/shipments/me/shipping-methods', (req, res, ctx) => {
@@ -162,11 +174,13 @@ beforeEach(() => {
162
174
 
163
175
  // mock place order
164
176
  rest.post('*/orders', (req, res, ctx) => {
165
- currentBasket = {
177
+ const response = {
178
+ ...currentBasket,
166
179
  ...scapiOrderResponse,
167
- customerInfo: {...scapiOrderResponse.customerInfo, email: 'customer@test.com'}
180
+ customerInfo: {...scapiOrderResponse.customerInfo, email: 'customer@test.com'},
181
+ status: 'created'
168
182
  }
169
- return res(ctx.json(currentBasket))
183
+ return res(ctx.json(response))
170
184
  }),
171
185
 
172
186
  rest.get('*/baskets', (req, res, ctx) => {
@@ -272,11 +286,13 @@ test('Can proceed through checkout steps as guest', async () => {
272
286
 
273
287
  // mock place order
274
288
  rest.post('*/orders', (req, res, ctx) => {
275
- currentBasket = {
289
+ const response = {
290
+ ...currentBasket,
276
291
  ...scapiOrderResponse,
277
- customerInfo: {...scapiOrderResponse.customerInfo, email: 'test@test.com'}
292
+ customerInfo: {...scapiOrderResponse.customerInfo, email: 'customer@test.com'},
293
+ status: 'created'
278
294
  }
279
- return res(ctx.json(currentBasket))
295
+ return res(ctx.json(response))
280
296
  }),
281
297
 
282
298
  rest.get('*/baskets', (req, res, ctx) => {
@@ -148,7 +148,7 @@ const ProductList = (props) => {
148
148
  {
149
149
  parameters: {
150
150
  ...restOfParams,
151
- refine: searchParams._refine
151
+ refine: _refine
152
152
  }
153
153
  },
154
154
  {
package/app/ssr.js CHANGED
@@ -5,6 +5,15 @@
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
7
 
8
+ /*
9
+ * Developer note! When updating this file, make sure to also update the
10
+ * ssr.js template files in pwa-kit-create-app.
11
+ *
12
+ * In the pwa-kit-create-app, the templates are found under:
13
+ * - assets/bootstrap/js/overrides/app/ssr.js.hbs
14
+ * - assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs
15
+ */
16
+
8
17
  'use strict'
9
18
 
10
19
  import path from 'path'
@@ -29,7 +38,14 @@ const options = {
29
38
  // The protocol on which the development Express app listens.
30
39
  // Note that http://localhost is treated as a secure context for development,
31
40
  // except by Safari.
32
- protocol: 'http'
41
+ protocol: 'http',
42
+
43
+ // Option for whether to set up a special endpoint for handling
44
+ // private SLAS clients
45
+ // Set this to false if using a SLAS public client
46
+ // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET
47
+ // environment variable as this endpoint will return HTTP 501 if it is not set
48
+ useSLASPrivateClient: false
33
49
  }
34
50
 
35
51
  const runtime = getRuntime()
@@ -67,6 +83,7 @@ const {handler} = runtime.createHandler(options, (app) => {
67
83
  res.set('Cache-Control', `max-age=31536000`)
68
84
  res.send()
69
85
  })
86
+
70
87
  app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt'))
71
88
  app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico'))
72
89
 
@@ -879,12 +879,24 @@
879
879
  "value": "No, keep item"
880
880
  }
881
881
  ],
882
+ "confirmation_modal.remove_cart_item.action.remove": [
883
+ {
884
+ "type": 0,
885
+ "value": "Remove"
886
+ }
887
+ ],
882
888
  "confirmation_modal.remove_cart_item.action.yes": [
883
889
  {
884
890
  "type": 0,
885
891
  "value": "Yes, remove item"
886
892
  }
887
893
  ],
894
+ "confirmation_modal.remove_cart_item.message.need_to_remove_due_to_unavailability": [
895
+ {
896
+ "type": 0,
897
+ "value": "Some items are no longer available online and will be removed from your cart."
898
+ }
899
+ ],
888
900
  "confirmation_modal.remove_cart_item.message.sure_to_remove": [
889
901
  {
890
902
  "type": 0,
@@ -897,6 +909,12 @@
897
909
  "value": "Confirm Remove Item"
898
910
  }
899
911
  ],
912
+ "confirmation_modal.remove_cart_item.title.items_unavailable": [
913
+ {
914
+ "type": 0,
915
+ "value": "Items Unavailable"
916
+ }
917
+ ],
900
918
  "confirmation_modal.remove_wishlist_item.action.no": [
901
919
  {
902
920
  "type": 0,
@@ -1637,6 +1655,12 @@
1637
1655
  "value": "Sale"
1638
1656
  }
1639
1657
  ],
1658
+ "item_image.label.unavailable": [
1659
+ {
1660
+ "type": 0,
1661
+ "value": "Unavailable"
1662
+ }
1663
+ ],
1640
1664
  "item_price.label.starting_at": [
1641
1665
  {
1642
1666
  "type": 0,