@salesforce/retail-react-app 9.1.0-preview.0 → 9.1.0-preview.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.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,7 @@
1
- ## v9.1.0-preview.0 (Mar 06, 2026)
1
+ ## v9.1.0-preview.1 (Mar 12, 2026)
2
+ - [Bugfix] Fix edirect payment methods status value to pascal [#3734](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3734)
3
+ - [Bugfix] Fix in checkout and cart page: LoadingSpinner to have full screen overlay [#3730](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3730)
4
+ - [Bugfix] Fix adding to cart from a master product in the wishlist [#3732](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3732)
2
5
  - Add Page Designer Support [#3727](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3727)
3
6
  - [Feature] Add Salesforce Payments support in checkout [#3725](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3725)
4
7
  - One Click Checkout removed from Developer Preview. When shoppers use passwordless OTP login with one-click checkout, the system saves their shipping and payment information for faster checkout in the future. Security safeguards required: (1) Captcha - Protects the passwordless login from bots. (2) OTP for Email Changes - Verifies identity before an email update, prevents accidental account lockouts from typos, and prevents unauthorized access to saved payment methods.
@@ -8,6 +11,7 @@
8
11
  - [Feature] Subscribe to marketing communications. Email capture component updated in footer section to use Shopper Consents API. [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674)
9
12
  - [Bugfix] Fix for custom billing address as returning shoppers in 1CC [#3693](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3693)
10
13
  - [Feature] Add translations for text in 1CC [#3703](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3703)
14
+ - [Bugfix] Fix lost custom billing address after OTP registration [#3741](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3741)
11
15
 
12
16
  ## v9.0.0 (Feb 12, 2026)
13
17
  - [Feature] One Click Checkout (in Developer Preview) [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
@@ -181,7 +181,10 @@ const WishlistPrimaryAction = () => {
181
181
  onOpen={onOpen}
182
182
  onClose={onClose}
183
183
  product={variant}
184
- addToCart={(variant, quantity) => handleAddToCart(variant, quantity)}
184
+ addToCart={(items) => {
185
+ const {product, variant, quantity} = items[0]
186
+ return handleAddToCart(variant || product, quantity)
187
+ }}
185
188
  />
186
189
  )}
187
190
  </>
@@ -1505,7 +1505,7 @@ const Cart = () => {
1505
1505
  )}
1506
1506
 
1507
1507
  {/* Loading overlay during express payment confirmation */}
1508
- {confirmingBasket && <LoadingSpinner wrapperStyles={{height: '100vh'}} />}
1508
+ {confirmingBasket && <LoadingSpinner wrapperStyles={{position: 'fixed'}} />}
1509
1509
  </Box>
1510
1510
  )
1511
1511
  }
@@ -287,9 +287,8 @@ const Checkout = () => {
287
287
  </Container>
288
288
  </Box>
289
289
  )}
290
-
291
290
  {/* Loading overlay during express payment confirmation */}
292
- {confirmingBasket && <LoadingSpinner wrapperStyles={{height: '100vh'}} />}
291
+ {confirmingBasket && <LoadingSpinner wrapperStyles={{position: 'fixed'}} />}
293
292
  </Box>
294
293
  )
295
294
  }
@@ -340,7 +339,7 @@ const CheckoutContainer = () => {
340
339
 
341
340
  return (
342
341
  <CheckoutProvider>
343
- {isDeletingUnavailableItem && <LoadingSpinner wrapperStyles={{height: '100vh'}} />}
342
+ {isDeletingUnavailableItem && <LoadingSpinner wrapperStyles={{position: 'fixed'}} />}
344
343
  <GoogleAPIProvider>
345
344
  <Checkout />
346
345
  </GoogleAPIProvider>
@@ -771,7 +771,7 @@ describe('SFPaymentsSheet', () => {
771
771
  adyen: {
772
772
  adyenPaymentIntent: {
773
773
  id: 'PI123',
774
- resultCode: 'AUTHORISED',
774
+ resultCode: 'Authorised',
775
775
  adyenPaymentAction: 'action'
776
776
  }
777
777
  }
@@ -1310,7 +1310,7 @@ describe('SFPaymentsSheet', () => {
1310
1310
  adyen: {
1311
1311
  adyenPaymentIntent: {
1312
1312
  id: 'PI123',
1313
- resultCode: 'AUTHORISED',
1313
+ resultCode: 'Authorised',
1314
1314
  adyenPaymentAction: 'action'
1315
1315
  }
1316
1316
  }
@@ -1417,7 +1417,7 @@ describe('SFPaymentsSheet', () => {
1417
1417
  adyen: {
1418
1418
  adyenPaymentIntent: {
1419
1419
  id: 'PI123',
1420
- resultCode: 'AUTHORISED',
1420
+ resultCode: 'Authorised',
1421
1421
  adyenPaymentAction: 'action'
1422
1422
  }
1423
1423
  }
@@ -1521,7 +1521,7 @@ describe('SFPaymentsSheet', () => {
1521
1521
  adyen: {
1522
1522
  adyenPaymentIntent: {
1523
1523
  id: 'PI123',
1524
- resultCode: 'AUTHORISED',
1524
+ resultCode: 'Authorised',
1525
1525
  adyenPaymentAction: 'action'
1526
1526
  }
1527
1527
  }
@@ -22,19 +22,12 @@ import {getSFPaymentsInstrument} from '@salesforce/retail-react-app/app/utils/sf
22
22
  import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
23
23
  import {PAYMENT_GATEWAYS} from '@salesforce/retail-react-app/app/constants'
24
24
 
25
- // const ADYEN_SUCCESS_RESULT_CODES = [
26
- // 'Authorised',
27
- // 'PartiallyAuthorised',
28
- // 'Received',
29
- // 'Pending',
30
- // 'PresentToShopper'
31
- // ]
32
25
  const ADYEN_SUCCESS_RESULT_CODES = [
33
- 'AUTHORISED',
34
- 'PARTIALLYAUTHORISED',
35
- 'RECEIVED',
36
- 'PENDING',
37
- 'PRESENTTOSHOPPER'
26
+ 'authorised',
27
+ 'partiallyauthorised',
28
+ 'received',
29
+ 'pending',
30
+ 'presenttoshopper'
38
31
  ]
39
32
 
40
33
  const PaymentProcessing = () => {
@@ -114,8 +107,7 @@ const PaymentProcessing = () => {
114
107
 
115
108
  // Check if Adyen result code indicates redirect payment was successful
116
109
  return ADYEN_SUCCESS_RESULT_CODES.includes(
117
- updatedOrderPaymentInstrument?.paymentReference?.gatewayProperties?.adyen
118
- ?.adyenPaymentIntent?.resultCode
110
+ updatedOrderPaymentInstrument?.paymentReference?.gatewayProperties?.adyen?.adyenPaymentIntent?.resultCode?.toLowerCase()
119
111
  )
120
112
  }
121
113
 
@@ -531,7 +531,7 @@ describe('PaymentProcessing', () => {
531
531
  gatewayProperties: {
532
532
  adyen: {
533
533
  adyenPaymentIntent: {
534
- resultCode: 'AUTHORISED'
534
+ resultCode: 'Authorised'
535
535
  }
536
536
  }
537
537
  }
@@ -4,7 +4,7 @@
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
- import React, {useEffect, useState} from 'react'
7
+ import React, {useEffect, useRef, useState} from 'react'
8
8
  import {
9
9
  Alert,
10
10
  AlertIcon,
@@ -102,6 +102,12 @@ const CheckoutOneClick = () => {
102
102
  const hasDeliveryShipments = deliveryShipments.length > 0
103
103
  const isPickupOnly = hasPickupShipments && !hasDeliveryShipments
104
104
  const [billingSameAsShipping, setBillingSameAsShipping] = useState(true)
105
+
106
+ const billingSameAsShippingRef = useRef(billingSameAsShipping)
107
+ useEffect(() => {
108
+ billingSameAsShippingRef.current = billingSameAsShipping
109
+ }, [billingSameAsShipping])
110
+
105
111
  const [isShipmentCleanupComplete, setIsShipmentCleanupComplete] = useState(false)
106
112
  // For billing=shipping, align with legacy: use the first delivery shipment's address
107
113
  const selectedShippingAddress =
@@ -221,9 +227,11 @@ const CheckoutOneClick = () => {
221
227
  }
222
228
  }, [isPickupOnly])
223
229
 
224
- const onBillingSubmit = async () => {
230
+ const onBillingSubmit = async (billingFormSnapshot) => {
225
231
  let billingAddress
226
- if (billingSameAsShipping && selectedShippingAddress) {
232
+ // Read from ref to avoid stale closures during async onPlaceOrder flow
233
+ const isSameAsShipping = billingSameAsShippingRef.current
234
+ if (isSameAsShipping && selectedShippingAddress) {
227
235
  billingAddress = selectedShippingAddress
228
236
  // Validate that shipping address has required address fields
229
237
  if (!billingAddress?.address1) {
@@ -236,6 +244,11 @@ const CheckoutOneClick = () => {
236
244
  return
237
245
  }
238
246
  } else {
247
+ // If a pre-captured snapshot was provided, restore it to the form.
248
+ if (billingFormSnapshot) {
249
+ billingAddressForm.reset(billingFormSnapshot, {keepDirty: true})
250
+ }
251
+
239
252
  // Validate all required address fields (excluding phone for billing)
240
253
  const fieldsToValidate = [
241
254
  'address1',
@@ -423,6 +436,11 @@ const CheckoutOneClick = () => {
423
436
  }
424
437
  }
425
438
 
439
+ // Snapshot billing form values BEFORE any payment mutations to preserve custom billing address during auth transitions
440
+ const billingFormSnapshot = !billingSameAsShippingRef.current
441
+ ? {...billingAddressForm.getValues()}
442
+ : null
443
+
426
444
  // PCI: Cardholder data (CHD) - use only for single submission to API. Do not log, persist, or expose.
427
445
  let fullCardDetails = null
428
446
  if (hasFormValues) {
@@ -482,7 +500,7 @@ const CheckoutOneClick = () => {
482
500
 
483
501
  // If successful `onBillingSubmit` returns the updated basket. If the form was invalid on
484
502
  // submit, `undefined` is returned.
485
- const updatedBasket = await onBillingSubmit()
503
+ const updatedBasket = await onBillingSubmit(billingFormSnapshot)
486
504
 
487
505
  if (updatedBasket) {
488
506
  await submitOrder(fullCardDetails)
@@ -621,6 +639,13 @@ const CheckoutContainer = () => {
621
639
  const toast = useToast()
622
640
  const [isDeletingUnavailableItem, setIsDeletingUnavailableItem] = useState(false)
623
641
 
642
+ // Track whether the checkout has rendered at least once to persist data during auth transitions
643
+ const hasRenderedCheckoutRef = useRef(false)
644
+ const canRender = !!customer?.customerId && !!basket?.basketId
645
+ if (canRender) {
646
+ hasRenderedCheckoutRef.current = true
647
+ }
648
+
624
649
  const handleRemoveItem = async (product) => {
625
650
  await removeItemFromBasketMutation.mutateAsync(
626
651
  {
@@ -653,7 +678,8 @@ const CheckoutContainer = () => {
653
678
  setIsDeletingUnavailableItem(false)
654
679
  }
655
680
 
656
- if (!customer || !customer.customerId || !basket || !basket.basketId) {
681
+ // Show skeleton only on the initial load
682
+ if (!canRender && !hasRenderedCheckoutRef.current) {
657
683
  return <CheckoutSkeleton />
658
684
  }
659
685
 
@@ -2701,4 +2701,225 @@ describe('Checkout One Click', () => {
2701
2701
  // Verify order was placed successfully
2702
2702
  expect(screen.getByText(/success/i)).toBeInTheDocument()
2703
2703
  })
2704
+
2705
+ test('Place Order with custom billing address submits the correct billing data', async () => {
2706
+ const billingApiCalls = []
2707
+
2708
+ let currentBasket = JSON.parse(JSON.stringify(scapiBasketWithItem))
2709
+ const shippingAddress = {
2710
+ address1: '100 Shipping Rd',
2711
+ city: 'Tampa',
2712
+ countryCode: 'US',
2713
+ firstName: 'Ship',
2714
+ lastName: 'Tester',
2715
+ phone: '(727) 555-0000',
2716
+ postalCode: '33712',
2717
+ stateCode: 'FL'
2718
+ }
2719
+ currentBasket.customerInfo = {
2720
+ ...currentBasket.customerInfo,
2721
+ email: 'billing-custom@test.com',
2722
+ customerId: currentBasket.customerInfo?.customerId || 'guest-billing-id'
2723
+ }
2724
+ if (currentBasket.shipments && currentBasket.shipments.length > 0) {
2725
+ currentBasket.shipments[0].shippingAddress = shippingAddress
2726
+ currentBasket.shipments[0].shippingMethod = defaultShippingMethod
2727
+ }
2728
+ currentBasket.paymentInstruments = []
2729
+ currentBasket.billingAddress = null
2730
+
2731
+ global.server.use(
2732
+ rest.get('*/baskets', (req, res, ctx) => {
2733
+ return res(ctx.json({baskets: [currentBasket], total: 1}))
2734
+ }),
2735
+ rest.put('*/billing-address', (req, res, ctx) => {
2736
+ billingApiCalls.push({body: req.body})
2737
+ currentBasket.billingAddress = req.body
2738
+ return res(ctx.json(currentBasket))
2739
+ }),
2740
+ rest.post('*/baskets/:basketId/payment-instruments', (req, res, ctx) => {
2741
+ currentBasket.paymentInstruments = [
2742
+ {
2743
+ amount: req.body.amount || 100,
2744
+ paymentCard: {
2745
+ cardType: 'Visa',
2746
+ creditCardExpired: false,
2747
+ expirationMonth: 1,
2748
+ expirationYear: 2040,
2749
+ holder: 'Billing Custom',
2750
+ maskedNumber: '************1111',
2751
+ numberLastDigits: '1111'
2752
+ },
2753
+ paymentInstrumentId: 'billing-test-pi',
2754
+ paymentMethodId: 'CREDIT_CARD'
2755
+ }
2756
+ ]
2757
+ return res(ctx.json(currentBasket))
2758
+ }),
2759
+ rest.post('*/orders', (req, res, ctx) => {
2760
+ return res(
2761
+ ctx.json({
2762
+ ...currentBasket,
2763
+ ...scapiOrderResponse,
2764
+ customerInfo: {
2765
+ ...scapiOrderResponse.customerInfo,
2766
+ email: 'billing-custom@test.com'
2767
+ },
2768
+ status: 'created'
2769
+ })
2770
+ )
2771
+ })
2772
+ )
2773
+
2774
+ mockUseAuthHelper.mockRejectedValueOnce({response: {status: 404}})
2775
+
2776
+ window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout'))
2777
+ const {user} = renderWithProviders(<WrappedCheckout />, {
2778
+ wrapperProps: {
2779
+ isGuest: true,
2780
+ siteAlias: 'uk',
2781
+ appConfig: mockConfig.app
2782
+ }
2783
+ })
2784
+
2785
+ // Navigate through contact info
2786
+ try {
2787
+ await screen.findByText(/contact info/i)
2788
+ const emailInput = await screen.findByLabelText(/email/i)
2789
+ await user.type(emailInput, 'billing-custom@test.com')
2790
+ await user.tab()
2791
+ const contToShip = await screen.findByText(/continue to shipping address/i)
2792
+ await user.click(contToShip)
2793
+ } catch (_e) {
2794
+ return
2795
+ }
2796
+
2797
+ // Continue to payment
2798
+ const contToPayment = screen.queryByText(/continue to payment/i)
2799
+ if (contToPayment) {
2800
+ await user.click(contToPayment)
2801
+ }
2802
+
2803
+ let placeOrderBtn
2804
+ try {
2805
+ placeOrderBtn = await screen.findByTestId('place-order-button', undefined, {
2806
+ timeout: 5000
2807
+ })
2808
+ } catch (_e) {
2809
+ return
2810
+ }
2811
+
2812
+ // Uncheck "same as shipping address"
2813
+ const billingCheckbox = screen.queryByRole('checkbox', {
2814
+ name: /same as shipping address|checkout_payment\.label\.same_as_shipping/i
2815
+ })
2816
+ if (billingCheckbox && billingCheckbox.checked) {
2817
+ await user.click(billingCheckbox)
2818
+ }
2819
+
2820
+ // Fill custom billing address
2821
+ const billingForm = screen.getByTestId('sf-shipping-address-edit-form')
2822
+ const firstNameInput = within(billingForm).getByLabelText(
2823
+ /(First Name|use_address_fields\.label\.first_name)/i
2824
+ )
2825
+ const lastNameInput = within(billingForm).getByLabelText(
2826
+ /(Last Name|use_address_fields\.label\.last_name)/i
2827
+ )
2828
+ const addressInput = within(billingForm).getByLabelText(
2829
+ /(Address|use_address_fields\.label\.address)/i
2830
+ )
2831
+ const cityInput = within(billingForm).getByLabelText(
2832
+ /(City|use_address_fields\.label\.city)/i
2833
+ )
2834
+ const zipInput = within(billingForm).getByLabelText(
2835
+ /(Zip Code|Postal Code|use_address_fields\.label\.zipcode)/i
2836
+ )
2837
+
2838
+ await user.clear(firstNameInput)
2839
+ await user.type(firstNameInput, 'Billing')
2840
+ await user.clear(lastNameInput)
2841
+ await user.type(lastNameInput, 'Custom')
2842
+ await user.clear(addressInput)
2843
+ await user.type(addressInput, '999 Billing Ave')
2844
+ await user.clear(cityInput)
2845
+ await user.type(cityInput, 'Billington')
2846
+ await user.clear(zipInput)
2847
+ await user.type(zipInput, '90210')
2848
+
2849
+ // Select country and state if visible
2850
+ const countrySelect = within(billingForm).queryByLabelText(
2851
+ /(Country|use_address_fields\.label\.country)/i
2852
+ )
2853
+ if (countrySelect) {
2854
+ await user.selectOptions(countrySelect, 'US')
2855
+ }
2856
+ const stateSelect = within(billingForm).queryByLabelText(
2857
+ /(State|use_address_fields\.label\.state)/i
2858
+ )
2859
+ if (stateSelect) {
2860
+ await user.selectOptions(stateSelect, 'CA')
2861
+ }
2862
+
2863
+ // Fill payment info
2864
+ const number = screen.getByLabelText(
2865
+ /(Card Number|use_credit_card_fields\.label\.card_number)/i
2866
+ )
2867
+ const name = screen.getByLabelText(
2868
+ /(Name on Card|Cardholder Name|use_credit_card_fields\.label\.name)/i
2869
+ )
2870
+ const expiry = screen.getByLabelText(
2871
+ /(Expiration Date|Expiry Date|use_credit_card_fields\.label\.expiry)/i
2872
+ )
2873
+ const cvv = screen.getByLabelText(
2874
+ /(Security Code|CVV|use_credit_card_fields\.label\.security_code)/i
2875
+ )
2876
+ await user.type(number, '4111 1111 1111 1111')
2877
+ await user.type(name, 'Billing Custom')
2878
+ await user.type(expiry, '0129')
2879
+ await user.type(cvv, '123')
2880
+
2881
+ // Place order
2882
+ await user.click(placeOrderBtn)
2883
+
2884
+ // Wait for the billing API to be called with the custom address
2885
+ await waitFor(
2886
+ () => {
2887
+ expect(billingApiCalls.length).toBeGreaterThan(0)
2888
+ },
2889
+ {timeout: 10000}
2890
+ )
2891
+
2892
+ // Verify the billing API was called with the custom billing address, NOT the shipping address
2893
+ const lastBillingCall = billingApiCalls[billingApiCalls.length - 1]
2894
+ expect(lastBillingCall.body.firstName).toBe('Billing')
2895
+ expect(lastBillingCall.body.lastName).toBe('Custom')
2896
+ expect(lastBillingCall.body.address1).toBe('999 Billing Ave')
2897
+ expect(lastBillingCall.body.city).toBe('Billington')
2898
+ expect(lastBillingCall.body.address1).not.toBe('100 Shipping Rd')
2899
+ expect(lastBillingCall.body.firstName).not.toBe('Ship')
2900
+ })
2901
+
2902
+ test('CheckoutContainer does not show skeleton after checkout has rendered', async () => {
2903
+ window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout'))
2904
+ const {queryByTestId} = renderWithProviders(<WrappedCheckout />, {
2905
+ wrapperProps: {
2906
+ siteAlias: 'uk',
2907
+ appConfig: mockConfig.app
2908
+ }
2909
+ })
2910
+
2911
+ // Wait for the checkout container to load (customer and basket data fetched)
2912
+ await waitFor(
2913
+ () => {
2914
+ expect(queryByTestId('sf-checkout-container')).toBeInTheDocument()
2915
+ },
2916
+ {timeout: 10000}
2917
+ )
2918
+
2919
+ // Once the checkout container has rendered, the hasRenderedCheckoutRef should be true.
2920
+ // Even if we force a re-render (e.g., via data refresh), the skeleton should not reappear.
2921
+ // The checkout container should still be visible.
2922
+ expect(queryByTestId('sf-checkout-skeleton')).not.toBeInTheDocument()
2923
+ expect(queryByTestId('sf-checkout-container')).toBeInTheDocument()
2924
+ })
2704
2925
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/retail-react-app",
3
- "version": "9.1.0-preview.0",
3
+ "version": "9.1.0-preview.1",
4
4
  "license": "See license in LICENSE",
5
5
  "author": "cc-pwa-kit@salesforce.com",
6
6
  "ccExtensibility": {
@@ -46,10 +46,10 @@
46
46
  "@loadable/component": "^5.15.3",
47
47
  "@peculiar/webcrypto": "^1.4.2",
48
48
  "@salesforce/cc-datacloud-typescript": "1.1.2",
49
- "@salesforce/commerce-sdk-react": "5.1.0-preview.0",
50
- "@salesforce/pwa-kit-dev": "3.17.0-preview.0",
51
- "@salesforce/pwa-kit-react-sdk": "3.17.0-preview.0",
52
- "@salesforce/pwa-kit-runtime": "3.17.0-preview.0",
49
+ "@salesforce/commerce-sdk-react": "5.1.0-preview.1",
50
+ "@salesforce/pwa-kit-dev": "3.17.0-preview.1",
51
+ "@salesforce/pwa-kit-react-sdk": "3.17.0-preview.1",
52
+ "@salesforce/pwa-kit-runtime": "3.17.0-preview.1",
53
53
  "@salesforce/storefront-next-runtime": "0.1.1",
54
54
  "@tanstack/react-query": "^4.28.0",
55
55
  "@tanstack/react-query-devtools": "^4.29.1",
@@ -113,5 +113,5 @@
113
113
  "maxSize": "391 kB"
114
114
  }
115
115
  ],
116
- "gitHead": "4756ec4c88b0e7a24fd1c4f2f204ed5481326c0e"
116
+ "gitHead": "79450520a6ccff8ebf0ecfd30167425cf939ac38"
117
117
  }