44 * SPDX-License-Identifier: BSD-3-Clause
55 * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66 */
7- import React , { useEffect , useState , useMemo } from 'react'
7+ import React , { useCallback , useEffect , useState , useMemo } from 'react'
88import { FormattedMessage , FormattedNumber , useIntl } from 'react-intl'
99import {
1010 Box ,
@@ -51,6 +51,7 @@ export default function ShippingOptions() {
5151 const updateShippingMethod = useShopperBasketsMutation ( 'updateShippingMethodForShipment' )
5252 const showToast = useToast ( )
5353 const [ noMethodsToastShown , setNoMethodsToastShown ] = useState ( false )
54+ const [ shipmentIdsWithNoMethods , setShipmentIdsWithNoMethods ] = useState ( ( ) => new Set ( ) )
5455 // Identify delivery shipments (exclude pickup and those without shipping addresses)
5556 const deliveryShipments =
5657 basket ?. shipments ?. filter ( ( s ) => s . shippingAddress && ! isPickupShipment ( s ) ) || [ ]
@@ -68,42 +69,79 @@ export default function ShippingOptions() {
6869 enabled :
6970 Boolean ( basket ?. basketId ) &&
7071 step === STEPS . SHIPPING_OPTIONS &&
71- ! hasMultipleDeliveryShipments ,
72- onSuccess : ( data ) => {
73- const deliveryOnly = getDeliveryShippingMethods ( data ?. applicableShippingMethods )
74- const noDeliveryMethods = ! deliveryOnly || deliveryOnly . length === 0
75- if (
76- step === STEPS . SHIPPING_OPTIONS &&
77- ! hasMultipleDeliveryShipments &&
78- noDeliveryMethods &&
79- ! noMethodsToastShown
80- ) {
81- showToast ( {
82- title : formatMessage ( {
83- defaultMessage :
84- 'Unfortunately, we are unable to ship to this address at this time. Please reach out to customer support for further assistance.' ,
85- id : 'shipping_options.error.no_shipping_methods'
86- } ) ,
87- status : 'error'
88- } )
89- setNoMethodsToastShown ( true )
90- }
91- }
72+ ! hasMultipleDeliveryShipments
73+ // Single-shipment "no methods" toast is handled in useEffect when data is available
9274 }
9375 )
9476
9577 const selectedShippingMethod = targetDeliveryShipment ?. shippingMethod
9678 const selectedShippingAddress = targetDeliveryShipment ?. shippingAddress
9779
98- // Reset error toast when state changes
80+ // Reset error toast when address state changes (affecting applicable shipping methods)
81+ const deliveryAddressStateKey = hasMultipleDeliveryShipments
82+ ? deliveryShipments . map ( ( s ) => s . shippingAddress ?. stateCode ?? '' ) . join ( ',' )
83+ : selectedShippingAddress ?. stateCode
9984 useEffect ( ( ) => {
10085 setNoMethodsToastShown ( false )
101- } , [ selectedShippingAddress ?. stateCode ] )
86+ } , [ deliveryAddressStateKey ] )
10287
10388 // Filter out pickup methods for delivery shipment
10489 const deliveryMethods = getDeliveryShippingMethods (
10590 shippingMethods ?. applicableShippingMethods || [ ]
10691 )
92+ const noShippingMethodsToast = useMemo (
93+ ( ) => ( {
94+ title : formatMessage ( {
95+ defaultMessage :
96+ 'Unfortunately, we are unable to ship to this address at this time. Please reach out to customer support for further assistance.' ,
97+ id : 'shipping_options.error.no_shipping_methods'
98+ } ) ,
99+ status : 'error'
100+ } ) ,
101+ [ formatMessage ]
102+ )
103+
104+ // For multi-shipment, report whether this shipment has no applicable delivery methods
105+ const handleShipmentMethodsResolved = useCallback (
106+ ( shipmentId , hasNoApplicableMethods ) => {
107+ setShipmentIdsWithNoMethods ( ( prev ) => {
108+ const next = new Set ( prev )
109+ if ( hasNoApplicableMethods ) next . add ( shipmentId )
110+ else next . delete ( shipmentId )
111+ return next
112+ } )
113+ if (
114+ hasNoApplicableMethods &&
115+ step === STEPS . SHIPPING_OPTIONS &&
116+ hasMultipleDeliveryShipments
117+ ) {
118+ setNoMethodsToastShown ( ( prev ) => {
119+ if ( ! prev ) showToast ( noShippingMethodsToast )
120+ return true
121+ } )
122+ }
123+ } ,
124+ [
125+ step ,
126+ STEPS . SHIPPING_OPTIONS ,
127+ hasMultipleDeliveryShipments ,
128+ showToast ,
129+ noShippingMethodsToast
130+ ]
131+ )
132+ const hasAnyShipmentWithNoMethods = shipmentIdsWithNoMethods . size > 0
133+
134+ // Single shipment: show toast when methods data is available and has no delivery methods
135+ const singleShipmentNoMethods =
136+ ! hasMultipleDeliveryShipments &&
137+ step === STEPS . SHIPPING_OPTIONS &&
138+ shippingMethods != null &&
139+ deliveryMethods . length === 0
140+ useEffect ( ( ) => {
141+ if ( ! singleShipmentNoMethods || noMethodsToastShown ) return
142+ showToast ( noShippingMethodsToast )
143+ setNoMethodsToastShown ( true )
144+ } , [ singleShipmentNoMethods , noMethodsToastShown , showToast , noShippingMethodsToast ] )
107145
108146 const { isLoading : isAutoSelectLoading } = useCheckoutAutoSelect ( {
109147 currentStep : step ,
@@ -229,10 +267,6 @@ export default function ShippingOptions() {
229267 shippingItem ?. priceAfterItemDiscount || 0
230268 )
231269
232- const filteredShippingMethods = getDeliveryShippingMethods (
233- shippingMethods ?. applicableShippingMethods
234- )
235-
236270 const freeLabel = formatMessage ( {
237271 defaultMessage : 'Free' ,
238272 id : 'checkout_confirmation.label.free'
@@ -280,26 +314,29 @@ export default function ShippingOptions() {
280314 index = { idx + 1 }
281315 shipment = { shipment }
282316 currency = { currency }
317+ onShipmentMethodsResolved = { handleShipmentMethodsResolved }
283318 />
284319 ) ) }
285- < Box >
286- < Container variant = "form" >
287- < Button w = "full" onClick = { ( ) => goToNextStep ( ) } >
288- < FormattedMessage
289- defaultMessage = "Continue to Payment"
290- id = "shipping_options.button.continue_to_payment"
291- />
292- </ Button >
293- </ Container >
294- </ Box >
320+ { ! hasAnyShipmentWithNoMethods && (
321+ < Box >
322+ < Container variant = "form" >
323+ < Button w = "full" onClick = { ( ) => goToNextStep ( ) } >
324+ < FormattedMessage
325+ defaultMessage = "Continue to Payment"
326+ id = "shipping_options.button.continue_to_payment"
327+ />
328+ </ Button >
329+ </ Container >
330+ </ Box >
331+ ) }
295332 </ Stack >
296333 ) : (
297334 < form
298335 onSubmit = { form . handleSubmit ( submitForm ) }
299336 data-testid = "sf-checkout-shipping-options-form"
300337 >
301338 < Stack spacing = { 6 } >
302- { filteredShippingMethods . length > 0 && (
339+ { deliveryMethods . length > 0 && (
303340 < Controller
304341 name = "shippingMethodId"
305342 control = { form . control }
@@ -311,7 +348,7 @@ export default function ShippingOptions() {
311348 onChange = { onChange }
312349 >
313350 < Stack spacing = { 5 } >
314- { filteredShippingMethods . map ( ( opt ) => (
351+ { deliveryMethods . map ( ( opt ) => (
315352 < Radio value = { opt . id } key = { opt . id } >
316353 < Flex justify = "space-between" w = "full" >
317354 < Box >
@@ -347,7 +384,7 @@ export default function ShippingOptions() {
347384 ) }
348385 />
349386 ) }
350- { filteredShippingMethods . length > 0 && (
387+ { deliveryMethods . length > 0 && (
351388 < Box >
352389 < Container variant = "form" >
353390 < Button w = "full" type = "submit" >
@@ -370,13 +407,15 @@ export default function ShippingOptions() {
370407 totalShippingCost = { totalShippingCost }
371408 currency = { currency }
372409 freeLabel = { freeLabel }
410+ shipmentIdsWithNoMethods = { shipmentIdsWithNoMethods }
373411 />
374412 ) }
375413
376414 { ! hasMultipleDeliveryShipments &&
377415 ! effectiveIsLoading &&
378416 selectedShippingMethod &&
379- selectedShippingAddress && (
417+ selectedShippingAddress &&
418+ deliveryMethods . length > 0 && (
380419 < SingleShipmentSummary
381420 selectedShippingMethod = { selectedShippingMethod }
382421 selectedMethodDisplayPrice = { selectedMethodDisplayPrice }
@@ -390,7 +429,13 @@ export default function ShippingOptions() {
390429}
391430
392431// Child component for multi-shipment summary
393- const MultiShipmentSummary = ( { deliveryShipments, totalShippingCost, currency, freeLabel} ) => {
432+ const MultiShipmentSummary = ( {
433+ deliveryShipments,
434+ totalShippingCost,
435+ currency,
436+ freeLabel,
437+ shipmentIdsWithNoMethods = new Set ( )
438+ } ) => {
394439 const { formatMessage} = useIntl ( )
395440
396441 return (
@@ -399,11 +444,12 @@ const MultiShipmentSummary = ({deliveryShipments, totalShippingCost, currency, f
399444 { deliveryShipments . map ( ( shipment ) => {
400445 // Use shipment.shippingTotal to include all costs (base + promotions + surcharges + other fees)
401446 const itemCost = shipment . shippingTotal || 0
447+ const hasNoApplicableMethods = shipmentIdsWithNoMethods . has ( shipment . shipmentId )
402448 return (
403449 < Box key = { shipment . shipmentId } >
404450 < Flex justify = "space-between" w = "full" >
405451 < Box flex = "1" >
406- { shipment . shippingMethod ? (
452+ { shipment . shippingMethod && ! hasNoApplicableMethods ? (
407453 < >
408454 < Text mt = { 2 } > { shipment . shippingMethod . name } </ Text >
409455 < Text fontSize = "sm" color = "gray.700" >
@@ -471,7 +517,8 @@ MultiShipmentSummary.propTypes = {
471517 ) . isRequired ,
472518 totalShippingCost : PropTypes . number . isRequired ,
473519 currency : PropTypes . string . isRequired ,
474- freeLabel : PropTypes . string . isRequired
520+ freeLabel : PropTypes . string . isRequired ,
521+ shipmentIdsWithNoMethods : PropTypes . instanceOf ( Set )
475522}
476523
477524// Child component for single-shipment summary
@@ -516,7 +563,7 @@ SingleShipmentSummary.propTypes = {
516563 freeLabel : PropTypes . string . isRequired
517564}
518565
519- const ShipmentMethods = ( { shipment, index, currency} ) => {
566+ const ShipmentMethods = ( { shipment, index, currency, onShipmentMethodsResolved } ) => {
520567 const { formatMessage} = useIntl ( )
521568 const { data : basket } = useCurrentBasket ( )
522569 const updateShippingMethod = useShopperBasketsMutation ( 'updateShippingMethodForShipment' )
@@ -527,11 +574,26 @@ const ShipmentMethods = ({shipment, index, currency}) => {
527574 shipmentId : shipment . shipmentId
528575 }
529576 } ,
530- { enabled : Boolean ( basket ?. basketId && shipment ?. shipmentId ) }
577+ {
578+ enabled : Boolean ( basket ?. basketId && shipment ?. shipmentId ) ,
579+ onSuccess : ( data ) => {
580+ if ( ! onShipmentMethodsResolved ) return
581+ const deliveryOnly = getDeliveryShippingMethods ( data ?. applicableShippingMethods )
582+ const noDeliveryMethods = ! deliveryOnly || deliveryOnly . length === 0
583+ onShipmentMethodsResolved ( shipment . shipmentId , noDeliveryMethods )
584+ }
585+ }
531586 )
532587 const [ selected , setSelected ] = useState ( shipment ?. shippingMethod ?. id || undefined )
533588 const [ hasAutoSelected , setHasAutoSelected ] = useState ( false )
534589
590+ // Sync to parent when methods are already available (from cache)
591+ useEffect ( ( ) => {
592+ if ( ! onShipmentMethodsResolved || methods === undefined ) return
593+ const deliveryMethods = getDeliveryShippingMethods ( methods ?. applicableShippingMethods )
594+ onShipmentMethodsResolved ( shipment . shipmentId , deliveryMethods . length === 0 )
595+ } , [ methods , shipment . shipmentId , onShipmentMethodsResolved ] )
596+
535597 useEffect ( ( ) => {
536598 // Only attempt auto-select when there are applicable methods available and we haven't already auto-selected
537599 const applicableMethods = getDeliveryShippingMethods ( methods ?. applicableShippingMethods )
@@ -674,5 +736,6 @@ const ShipmentMethods = ({shipment, index, currency}) => {
674736ShipmentMethods . propTypes = {
675737 shipment : PropTypes . object . isRequired ,
676738 index : PropTypes . number . isRequired ,
677- currency : PropTypes . string . isRequired
739+ currency : PropTypes . string . isRequired ,
740+ onShipmentMethodsResolved : PropTypes . func
678741}
0 commit comments