diff --git a/.changeset/sync-shipping-fulfillment.md b/.changeset/sync-shipping-fulfillment.md new file mode 100644 index 00000000..4ddb0961 --- /dev/null +++ b/.changeset/sync-shipping-fulfillment.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Re-apply shipping methods when checkout line items change after shipping has already been selected. diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index f83a0591..33680d0c 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -6,7 +6,9 @@ import { useCheckoutContext, } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-method'; +import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { buildPickupPayload } from '@/components/checkout/pickup/utils/build-pickup-payload'; +import { getShippingFulfillmentSyncKey } from '@/components/checkout/shipping/utils/should-apply-shipping-method'; import { useGoDaddyContext } from '@/godaddy-provider'; import { confirmCheckout } from '@/lib/godaddy/godaddy'; import { eventIds } from '@/tracking/events'; @@ -72,6 +74,7 @@ export function useConfirmCheckout() { useCheckoutContext(); const { apiHost } = useGoDaddyContext(); const form = useFormContext(); + const { data: order } = useDraftOrder(); const isPendingRef = useRef(false); return useMutation({ @@ -87,9 +90,22 @@ export function useConfirmCheckout() { const { isExpress, ...confirmCheckoutInput } = input; - const isPickup = - form.getValues('deliveryMethod') === DeliveryMethods.PICKUP && - !isExpress; + const deliveryMethod = form.getValues('deliveryMethod'); + const isPickup = deliveryMethod === DeliveryMethods.PICKUP && !isExpress; + const isShipping = deliveryMethod === DeliveryMethods.SHIP && !isExpress; + + const hasShippingLines = (order?.shippingLines?.length ?? 0) > 0; + const hasLineItemsMissingShippingFulfillment = Boolean( + getShippingFulfillmentSyncKey(order?.lineItems) + ); + + if ( + isShipping && + (!hasShippingLines || hasLineItemsMissingShippingFulfillment) + ) { + setCheckoutErrors(['SHIPPING_METHOD_APPLICATION_FAILED']); + throw new Error('SHIPPING_METHOD_APPLICATION_FAILED'); + } const pickUpData = isPickup ? buildPickupPayload({ diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 8a698c94..cb4f712c 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; @@ -12,6 +13,10 @@ import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { ShippingMethodSkeleton } from '@/components/checkout/shipping/shipping-method-skeleton'; import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; +import { + getShippingFulfillmentSyncKey, + shouldApplyShippingMethod, +} from '@/components/checkout/shipping/utils/should-apply-shipping-method'; import { useApplyShippingMethod } from '@/components/checkout/shipping/utils/use-apply-shipping-method'; import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; @@ -45,8 +50,10 @@ export function ShippingMethodForm() { const formatCurrency = useFormatCurrency(); const form = useFormContext(); const { t } = useGoDaddyContext(); - const { session, isConfirmingCheckout } = useCheckoutContext(); + const { session, isConfirmingCheckout, setCheckoutErrors } = + useCheckoutContext(); const updateTaxes = useUpdateTaxes(); + const queryClient = useQueryClient(); const isPaymentDisabled = useIsPaymentDisabled(); const { data: shippingMethodsData, isLoading: isShippingMethodsLoading } = @@ -63,6 +70,8 @@ export function ShippingMethodForm() { lineItem => lineItem.fulfillmentMode === DeliveryMethods.PICKUP ) ); + const fulfillmentSyncKey = getShippingFulfillmentSyncKey(order?.lineItems); + const hasLineItemsMissingShippingFulfillment = Boolean(fulfillmentSyncKey); const orderSubTotal = totals?.subTotal?.value || 0; @@ -81,23 +90,39 @@ export function ShippingMethodForm() { hadShippingMethods: boolean; wasPickup: boolean; clearedShippingMethod: boolean; + blockedFulfillmentKey: string | null; }>({ serviceCode: null, cost: null, hadShippingMethods: false, wasPickup: false, clearedShippingMethod: false, + blockedFulfillmentKey: null, }); useEffect(() => { - if (isShippingMethodsLoading || isDraftOrderLoading || isConfirmingCheckout) + if ( + isShippingMethodsLoading || + isDraftOrderLoading || + isConfirmingCheckout || + applyShippingMethod.isPending + ) return; const hasShippingMethods = (shippingMethods?.length ?? 0) > 0; const currentServiceCode = shippingLines?.requestedService || null; - const currentCost = shippingLines?.amount?.value ?? null; const lastState = lastProcessedStateRef.current; + if ( + !hasLineItemsMissingShippingFulfillment && + lastState.blockedFulfillmentKey + ) { + lastProcessedStateRef.current = { + ...lastState, + blockedFulfillmentKey: null, + }; + } + // Case 1: No shipping methods available - clear shipping and set fulfillment to SHIP if (!hasShippingMethods && hasShippingAddress) { // Apply empty shipping method if: @@ -116,6 +141,7 @@ export function ShippingMethodForm() { hadShippingMethods: false, wasPickup: isPickup, clearedShippingMethod: true, + blockedFulfillmentKey: null, }; } return; @@ -141,25 +167,53 @@ export function ShippingMethodForm() { : null; const methodToApply = matchedMethod || firstMethod; - const methodCost = methodToApply.cost?.value ?? null; - - // Check if we've already processed this exact state - const alreadyProcessed = - methodToApply.serviceCode === lastState.serviceCode && - methodCost === lastState.cost; + // Check if we've already processed this exact state. If cart contents + // changed after a shipping method was selected, shippingLines can still + // match the selected rate while new line items are NONE. In that case we + // must re-apply the method so checkout-api recalculates shipping and sets + // line item fulfillmentMode to SHIP. + const { alreadyProcessed, needsMutation, methodCost } = + shouldApplyShippingMethod({ + methodToApply, + shippingLine: shippingLines, + lastState, + fulfillmentSyncKey, + }); if (!alreadyProcessed) { form.setValue('shippingMethod', methodToApply.serviceCode, { shouldDirty: false, }); - // Only mutate if the method or cost actually changed on the order - const needsMutation = - methodToApply.serviceCode !== currentServiceCode || - methodCost !== currentCost; - if (needsMutation) { - applyShippingMethod.mutate(buildShippingPayload(methodToApply)); + const isFulfillmentSync = Boolean( + hasLineItemsMissingShippingFulfillment && fulfillmentSyncKey + ); + + if (isFulfillmentSync) { + lastProcessedStateRef.current = { + ...lastProcessedStateRef.current, + blockedFulfillmentKey: fulfillmentSyncKey, + }; + } + + applyShippingMethod.mutate(buildShippingPayload(methodToApply), { + onSuccess: () => { + if (!isFulfillmentSync || !session?.id) return; + + queryClient.invalidateQueries({ + queryKey: ['draft-order', session.id], + }); + }, + onError: () => { + if (!isFulfillmentSync || !session?.id) return; + + setCheckoutErrors(['SHIPPING_METHOD_APPLICATION_FAILED']); + queryClient.invalidateQueries({ + queryKey: ['draft-order', session.id], + }); + }, + }); } else if (session?.enableTaxCollection) { updateTaxes.mutate(undefined); } @@ -170,6 +224,9 @@ export function ShippingMethodForm() { hadShippingMethods: true, wasPickup: false, clearedShippingMethod: false, + blockedFulfillmentKey: needsMutation + ? lastProcessedStateRef.current.blockedFulfillmentKey + : null, }; } } @@ -182,9 +239,15 @@ export function ShippingMethodForm() { form, applyShippingMethod, updateTaxes.mutate, + setCheckoutErrors, session?.enableTaxCollection, + queryClient, + session?.id, isPickup, isDraftOrderLoading, + hasLineItemsMissingShippingFulfillment, + applyShippingMethod.isPending, + order?.lineItems, ]); if (isShippingMethodsLoading || isShippingAddressLoading) { diff --git a/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts new file mode 100644 index 00000000..2d82f625 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import type { DraftOrder, ShippingLines, ShippingMethod } from '@/types'; +import { + getShippingFulfillmentSyncKey, + shouldApplyShippingMethod, +} from './should-apply-shipping-method'; + +const shippingMethod = { + serviceCode: 'ground', + cost: { + value: 100, + currencyCode: 'USD', + }, +} as ShippingMethod; + +const shippingLine = { + requestedService: 'ground', + amount: { + value: 100, + }, +} as ShippingLines; + +const processedState = { + serviceCode: 'ground', + cost: 100, + blockedFulfillmentKey: null, +}; + +describe('getShippingFulfillmentSyncKey', () => { + it('returns a sync key when a line item has NONE fulfillment', () => { + const key = getShippingFulfillmentSyncKey([ + { + id: 'line-item-1', + fulfillmentMode: 'SHIP', + }, + { + id: 'line-item-2', + fulfillmentMode: 'NONE', + }, + ] as DraftOrder['lineItems']); + + expect(key).toBe('line-item-1:SHIP|line-item-2:NONE'); + }); + + it('returns a sync key when a line item has null fulfillment', () => { + const key = getShippingFulfillmentSyncKey([ + { + id: 'line-item-1', + fulfillmentMode: null, + }, + ] as DraftOrder['lineItems']); + + expect(key).toBe('line-item-1:'); + }); + + it('returns null when all line items are already shipping fulfilled', () => { + const key = getShippingFulfillmentSyncKey([ + { + id: 'line-item-1', + fulfillmentMode: 'SHIP', + }, + ] as DraftOrder['lineItems']); + + expect(key).toBeNull(); + }); +}); + +describe('shouldApplyShippingMethod', () => { + it('applies when the shipping line matches but line items are missing SHIP fulfillment', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: 'line-item-1:NONE', + lastState: processedState, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('does not repeatedly apply while the same fulfillment sync key is blocked', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: 'line-item-1:NONE', + lastState: { + ...processedState, + blockedFulfillmentKey: 'line-item-1:NONE', + }, + }); + + expect(result.needsMutation).toBe(false); + expect(result.alreadyProcessed).toBe(true); + expect(result.isFulfillmentSyncBlocked).toBe(true); + }); + + it('allows retrying after the blocked fulfillment key changes', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: 'line-item-1:NONE', + lastState: processedState, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('applies when a later cart edit creates a different fulfillment sync key', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: 'line-item-2:NONE', + lastState: { + ...processedState, + blockedFulfillmentKey: 'line-item-1:NONE', + }, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + expect(result.isFulfillmentSyncBlocked).toBe(false); + }); + + it('does not apply when shipping line and method match and line items are fulfilled', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: null, + lastState: processedState, + }); + + expect(result.needsMutation).toBe(false); + expect(result.alreadyProcessed).toBe(true); + }); + + it('applies when the selected shipping method changed', () => { + const result = shouldApplyShippingMethod({ + methodToApply: { + ...shippingMethod, + serviceCode: 'express', + } as ShippingMethod, + shippingLine, + fulfillmentSyncKey: null, + lastState: processedState, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('applies when the existing shipping line service code differs', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine: { + ...shippingLine, + requestedService: 'express', + } as ShippingLines, + fulfillmentSyncKey: null, + lastState: { + serviceCode: 'express', + cost: 100, + blockedFulfillmentKey: null, + }, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('applies when the existing shipping line cost differs', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine: { + ...shippingLine, + amount: { + value: 200, + }, + } as ShippingLines, + fulfillmentSyncKey: null, + lastState: { + serviceCode: 'ground', + cost: 200, + blockedFulfillmentKey: null, + }, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('applies when there is no existing shipping line', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine: null, + fulfillmentSyncKey: null, + lastState: { + serviceCode: null, + cost: null, + blockedFulfillmentKey: null, + }, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('does not treat an unprocessed first render as already processed', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: null, + lastState: { + serviceCode: null, + cost: null, + blockedFulfillmentKey: null, + }, + }); + + expect(result.needsMutation).toBe(false); + expect(result.alreadyProcessed).toBe(false); + }); +}); diff --git a/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts new file mode 100644 index 00000000..d43f470b --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts @@ -0,0 +1,70 @@ +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-method'; +import type { DraftOrder, ShippingLines, ShippingMethod } from '@/types'; + +type LastProcessedShippingState = { + serviceCode: string | null; + cost: number | null; + blockedFulfillmentKey: string | null; +}; + +export function getShippingFulfillmentSyncKey( + lineItems?: DraftOrder['lineItems'] +): string | null { + const hasLineItemsMissingShippingFulfillment = lineItems?.some( + lineItem => + !lineItem.fulfillmentMode || + lineItem.fulfillmentMode === DeliveryMethods.NONE + ); + + if (!hasLineItemsMissingShippingFulfillment) return null; + + return ( + lineItems + ?.map(lineItem => `${lineItem.id}:${lineItem.fulfillmentMode ?? ''}`) + .join('|') || null + ); +} + +export function shouldApplyShippingMethod({ + methodToApply, + shippingLine, + lastState, + fulfillmentSyncKey, +}: { + methodToApply: ShippingMethod; + shippingLine?: ShippingLines | null; + lastState: LastProcessedShippingState; + fulfillmentSyncKey: string | null; +}) { + const methodCost = methodToApply.cost?.value ?? null; + const currentServiceCode = shippingLine?.requestedService || null; + const currentCost = shippingLine?.amount?.value ?? null; + const hasLineItemsMissingShippingFulfillment = Boolean(fulfillmentSyncKey); + const isFulfillmentSyncBlocked = + Boolean(fulfillmentSyncKey) && + fulfillmentSyncKey === lastState.blockedFulfillmentKey; + + const alreadyProcessed = + methodToApply.serviceCode === lastState.serviceCode && + methodCost === lastState.cost && + (!hasLineItemsMissingShippingFulfillment || isFulfillmentSyncBlocked); + + if (alreadyProcessed) { + return { + alreadyProcessed, + needsMutation: false, + isFulfillmentSyncBlocked, + methodCost, + }; + } + + return { + alreadyProcessed, + needsMutation: + methodToApply.serviceCode !== currentServiceCode || + methodCost !== currentCost || + (hasLineItemsMissingShippingFulfillment && !isFulfillmentSyncBlocked), + isFulfillmentSyncBlocked, + methodCost, + }; +}