diff --git a/.changeset/calm-coupons-ship.md b/.changeset/calm-coupons-ship.md new file mode 100644 index 00000000..5adcce0d --- /dev/null +++ b/.changeset/calm-coupons-ship.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Keep shipping rates, discounts, taxes, and express checkout in sync when coupons change. diff --git a/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx index 970fa5cc..c22d9165 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx @@ -342,7 +342,7 @@ describe('Digital fulfillment checkout', () => { ).toBeVisible(); }); - it('hides express for mixed digital and pickup orders', async () => { + it('shows express for mixed digital and pickup orders when shipping is enabled', async () => { renderCheckout({ draftOrderOverrides: { lineItems: [ @@ -362,8 +362,8 @@ describe('Digital fulfillment checkout', () => { await waitForCheckoutReady(); expect( - screen.queryByTestId('mock-godaddy-express-button') - ).not.toBeInTheDocument(); + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); }); it('does not let digital NONE lines trigger shipping fulfillment sync', async () => { diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx index b49196d8..1efa1c16 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -4,11 +4,13 @@ import { describe, expect, it } from 'vitest'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { buildBillingAddress, + buildShippingRates, clearOperations, flushPromises, getOperations, renderCheckout, setApiError, + setShippingMethods, waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; @@ -148,6 +150,483 @@ describe('Checkout discounts', () => { expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); }); + it('refetches shipping methods when a coupon is applied', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableTaxCollection: false }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('DraftOrderShippingRates'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + }); + + it('calculates taxes once after a discount changes the selected shipping cost', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 1000, currencyCode: 'USD' }, + total: { value: 3500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + setShippingMethods( + buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]) + ); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + await flushPromises(); + + const operations = getOperations(); + const shippingIndex = operations.findIndex( + operation => operation.op === 'ApplyCheckoutSessionShippingMethod' + ); + const taxIndex = operations.findIndex( + operation => operation.op === 'CalculateCheckoutSessionTaxes' + ); + const lastDiscountIndex = operations + .map(operation => operation.op) + .lastIndexOf('ApplyCheckoutSessionDiscount'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect(taxIndex).toBeGreaterThan(shippingIndex); + expect(taxIndex).toBeGreaterThan(lastDiscountIndex); + }); + + it.each(['empty', 'error'] as const)( + 'clears applied shipping once when the discount rate refresh result is %s', + async result => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + if (result === 'error') { + setApiError('getDraftOrderShippingMethods', 'rates failed'); + } else { + setShippingMethods([]); + } + + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + await flushPromises(); + await flushPromises(); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect(screen.queryByText('Standard')).not.toBeInTheDocument(); + expect(document.body).toHaveTextContent(/no shipping methods found/i); + } + ); + + it('keeps the previous shipping selection when discount reconciliation fails', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError('applyShippingMethod', 'apply failed'); + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 2 + ); + }); + await flushPromises(); + await flushPromises(); + + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(2); + expect(screen.getByRole('radio', { name: /standard/i })).toBeChecked(); + expect(screen.getByRole('radio', { name: /free/i })).not.toBeChecked(); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + + it.each(['empty', 'replacement'] as const)( + 'does not display a failed %s automatic shipping selection', + async result => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError('applyShippingMethod', 'apply failed'); + setShippingMethods( + result === 'empty' + ? [] + : buildShippingRates([ + { + serviceCode: 'express', + carrierCode: 'carrier', + displayName: 'Express', + cost: { value: 1500, currencyCode: 'USD' }, + }, + { + serviceCode: 'overnight', + carrierCode: 'carrier', + displayName: 'Overnight', + cost: { value: 2000, currencyCode: 'USD' }, + }, + ]) + ); + + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect( + getOperations('ApplyCheckoutSessionShippingMethod') + ).toHaveLength(2); + }); + await flushPromises(); + await flushPromises(); + + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + + if (result === 'empty') { + expect(document.body).toHaveTextContent(/no shipping methods found/i); + } else { + expect( + screen.getByRole('radio', { name: /express/i }) + ).not.toBeChecked(); + expect( + screen.getByRole('radio', { name: /overnight/i }) + ).not.toBeChecked(); + } + } + ); + + it('applies a newly available free method before calculating taxes', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 1000, currencyCode: 'USD' }, + total: { value: 3500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toContainEqual( + expect.objectContaining({ + requestedService: 'free', + subTotal: { value: 0, currencyCode: 'USD' }, + }) + ); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + it('calculates taxes once without applying shipping when refreshed shipping is unchanged', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 1000, currencyCode: 'USD' }, + total: { value: 3500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + await flushPromises(); + + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + it('reapplies a shipping discount before taxes when the shipping method changes', async () => { + const shippingMethods = buildShippingRates([ + { + serviceCode: 'flat-rate', + carrierCode: 'carrier', + displayName: 'Flat Rate', + cost: { value: 10, currencyCode: 'USD' }, + }, + { + serviceCode: 'premium-rate', + carrierCode: 'carrier', + displayName: 'Premium Rate', + cost: { value: 100, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'flat-rate', + requestedProvider: 'carrier', + name: 'Flat Rate', + amount: { value: 10, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 10, currencyCode: 'USD' }, + total: { value: 2510, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'freeship'); + + await waitFor(() => { + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect( + screen.getAllByRole('button', { name: /remove freeship/i }).length + ).toBeGreaterThan(0); + + await flushPromises(); + clearOperations(); + await user.click(screen.getByRole('radio', { name: /premium rate/i })); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + const operationNames = getOperations().map(operation => operation.op); + expect( + operationNames.indexOf('ApplyCheckoutSessionDiscount') + ).toBeGreaterThan( + operationNames.indexOf('ApplyCheckoutSessionShippingMethod') + ); + expect( + operationNames.indexOf('CalculateCheckoutSessionTaxes') + ).toBeGreaterThan(operationNames.indexOf('ApplyCheckoutSessionDiscount')); + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: ['freeship'], + }); + + await flushPromises(); + clearOperations(); + await user.click( + screen + .getAllByRole('button', { name: /remove freeship/i }) + .at(-1) as HTMLButtonElement + ); + + await waitFor(() => { + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: [], + }); + }); + + it('does not fetch shipping or taxes when a coupon is applied without a shipping address', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: null, + billing: null, + shippingLines: null, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: true, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'freeship'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await waitForOperation('DraftOrder'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + it('refetches the draft order when taxes cannot be recalculated without a billing address', async () => { const { user } = renderCheckout({ draftOrderOverrides: { diff --git a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx index 4a847df2..09236f2c 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx @@ -72,6 +72,90 @@ describe('Express checkout section visibility', () => { expect(screen.getByRole('button', { name: /pay now/i })).toBeVisible(); }); + it('renders express checkout for a shipping-enabled session with purchase fulfillment', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: true, + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); + }); + + it('does not render express checkout for a purchase-only session', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument(); + }); + + it('renders express checkout for pickup fulfillment when shipping is enabled', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: true, + enableLocalPickup: true, + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); + }); + + it('does not render express checkout for a digital-only order', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: true, + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + draftOrderOverrides: { + lineItems: [{ type: 'DIGITAL', fulfillmentMode: 'DIGITAL' }], + }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument(); + }); + it('renders the Stripe express button when paymentMethods.express is configured for stripe', async () => { renderCheckout({ sessionOverrides: { diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx index 0cbafd42..449a0d93 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx @@ -445,7 +445,7 @@ describe('Checkout free / offline orders', () => { ).not.toBeInTheDocument(); }); - it('switches to FreePaymentForm when selecting a free shipping rate makes the total zero', async () => { + it('switches to FreePaymentForm when the cheapest shipping rate makes the total zero', async () => { const draftOrder = buildDraftOrder({ totals: { subTotal: { value: 0, currencyCode: 'USD' }, @@ -482,23 +482,14 @@ describe('Checkout free / offline orders', () => { enableShipping: true, enableLocalPickup: false, enableTaxCollection: false, - experimental_rules: { - freeShipping: { enabled: true, minimumOrderTotal: 0 }, - }, }); - const { user } = renderCheckout({ + renderCheckout({ session, draftOrder, apiOverrides: { shippingMethods: buildShippingRates() }, }); await waitForCheckoutReady(); - expect( - await screen.findByRole('button', { name: /pay now/i }) - ).toBeInTheDocument(); - - clearOperations(); - await user.click(screen.getByRole('radio', { name: /free/i })); await waitForOperation('ApplyCheckoutSessionShippingMethod'); expect( diff --git a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx index 87061b35..ad93332f 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -95,7 +95,7 @@ describe('Checkout shipping behavior', () => { ).not.toBeInTheDocument(); }); - it('filters free shipping below the minimum order total and shows it once the subtotal qualifies', async () => { + it('shows free shipping returned by the API', async () => { const shippingMethods = [ { serviceCode: 'free-shipping', @@ -118,35 +118,12 @@ describe('Checkout shipping behavior', () => { cost: { value: 500, currencyCode: 'USD' }, }, ]; - const experimental_rules = { - freeShipping: { enabled: true, minimumOrderTotal: 5000 }, - }; - const { unmount } = renderCheckout({ - sessionOverrides: { experimental_rules }, - apiOverrides: { shippingMethods }, - }); - await waitForCheckoutReady(); - - expect( - screen.queryByRole('radio', { name: /free/i }) - ).not.toBeInTheDocument(); - expect(screen.getAllByText('Paid Rate').length).toBeGreaterThan(0); - - unmount(); - renderCheckout({ - sessionOverrides: { experimental_rules }, - draftOrderOverrides: { - totals: { - subTotal: { value: 5000, currencyCode: 'USD' }, - total: { value: 5000, currencyCode: 'USD' }, - }, - }, - apiOverrides: { shippingMethods }, - }); + renderCheckout({ apiOverrides: { shippingMethods } }); await waitForCheckoutReady(); expect(screen.getByRole('radio', { name: /free/i })).toBeInTheDocument(); + expect(screen.getAllByText('Paid Rate').length).toBeGreaterThan(0); }); it('renders FREE for a single zero-cost shipping method', async () => { diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx index b2ae0c4c..765100bf 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -629,21 +629,45 @@ function applyShippingLines(shippingMethods: unknown) { function applyDiscountCodes(discountCodes: string[]) { if (!state) return; - const discounts = discountCodes.map(code => discount(code)); const totals = state.draftOrder.totals ?? defaultTotals(); + const hasFreeShipping = discountCodes.some( + code => code.toLowerCase() === 'freeship' + ); + const orderDiscountCodes = hasFreeShipping + ? discountCodes.filter(code => code.toLowerCase() !== 'freeship') + : discountCodes; + const discounts = orderDiscountCodes.map(code => discount(code)); const freeOrderDiscount = (totals.subTotal?.value ?? 0) + (totals.shippingTotal?.value ?? 0) + (totals.taxTotal?.value ?? 0) + (totals.feeTotal?.value ?? 0); + const shippingDiscount = hasFreeShipping + ? (totals.shippingTotal?.value ?? 0) + : 0; const discountTotal = money( discountCodes.some(code => code.toLowerCase() === 'free100') ? freeOrderDiscount - : discountCodes.length * 100 + : orderDiscountCodes.length * 100 + shippingDiscount ); + const shippingLines = + state.draftOrder.shippingLines?.map(shippingLine => ({ + ...shippingLine, + discounts: hasFreeShipping + ? [ + { + ...discount('freeship'), + amount: money(shippingLine.amount?.value ?? 0), + metafields: [], + }, + ] + : [], + })) ?? null; + state.draftOrder = recalculateTotal({ ...state.draftOrder, discounts, + shippingLines, totals: { ...(state.draftOrder.totals ?? defaultTotals()), discountTotal, @@ -998,6 +1022,11 @@ export function setPriceAdjustments(adjustments: unknown[]) { state.priceAdjustments = adjustments; } +export function setShippingMethods(shippingMethods: ShippingMethod[]) { + if (!state) throw new Error('mockGodaddyApi must be called first'); + state.shippingMethods = shippingMethods; +} + export function getOperations(op?: OperationName) { const operations = state?.operations ?? []; return op ? operations.filter(operation => operation.op === op) : operations; diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts new file mode 100644 index 00000000..4b980309 --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import type { DraftOrder } from '@/types'; +import { getDraftOrderDiscountCodes } from './get-draft-order-discount-codes'; + +describe('getDraftOrderDiscountCodes', () => { + it('collects unique order, line-item, and shipping-line discount codes', () => { + const draftOrder = { + discounts: [{ code: 'order' }], + lineItems: [{ discounts: [{ code: 'line' }, { code: 'shared' }] }], + shippingLines: [ + { discounts: [{ code: 'shipping' }, { code: 'shared' }] }, + ], + } as DraftOrder; + + expect(getDraftOrderDiscountCodes(draftOrder)).toEqual([ + 'line', + 'order', + 'shared', + 'shipping', + ]); + }); + + it('returns an empty list without a draft order', () => { + expect(getDraftOrderDiscountCodes()).toEqual([]); + }); +}); diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts new file mode 100644 index 00000000..800d1c9b --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts @@ -0,0 +1,25 @@ +import type { DraftOrder } from '@/types'; + +export function getDraftOrderDiscountCodes( + draftOrder?: DraftOrder | null +): string[] { + const codes = new Set(); + + for (const discount of draftOrder?.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + + for (const lineItem of draftOrder?.lineItems ?? []) { + for (const discount of lineItem.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + } + + for (const shippingLine of draftOrder?.shippingLines ?? []) { + for (const discount of shippingLine.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + } + + return Array.from(codes).sort(); +} diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts new file mode 100644 index 00000000..486cf2fe --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts @@ -0,0 +1,134 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ResultOf } from 'gql.tada'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { + checkoutMutationKeys, + checkoutQueryKeys, +} from '@/components/checkout/utils/query-keys'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { ApplyCheckoutSessionDiscountMutation } from '@/lib/godaddy/checkout-mutations.ts'; +import { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; +import { applyDiscount } from '@/lib/godaddy/godaddy'; +import type { ApplyCheckoutSessionDiscountInput } from '@/types'; + +type DiscountMutationResult = ResultOf< + typeof ApplyCheckoutSessionDiscountMutation +>; +type DiscountOrder = NonNullable< + DiscountMutationResult['applyCheckoutSessionDiscount'] +>; + +export interface ApplyDiscountVariables { + discountCodes: ApplyCheckoutSessionDiscountInput['input']['discountCodes']; +} + +interface UseApplyDiscountCoreOptions { + onSuccess?: ( + data: DiscountMutationResult, + variables: ApplyDiscountVariables + ) => Promise | void; +} + +export function updateDiscountCache( + queryClient: QueryClient, + sessionId: string, + updatedOrder: DiscountOrder, + discountCodes: ApplyDiscountVariables['discountCodes'] +) { + queryClient.setQueryData( + checkoutQueryKeys.draftOrder(sessionId), + (cached: ResultOf | undefined) => { + const currentOrder = cached?.checkoutSession?.draftOrder; + if (!cached || !currentOrder) return cached; + + return { + ...cached, + checkoutSession: { + ...cached.checkoutSession, + draftOrder: { + ...currentOrder, + totals: { + ...currentOrder.totals, + discountTotal: + updatedOrder.totals?.discountTotal ?? + currentOrder.totals?.discountTotal, + total: updatedOrder.totals?.total ?? currentOrder.totals?.total, + }, + discounts: + updatedOrder.discounts ?? + (discountCodes?.length ? currentOrder.discounts : []), + lineItems: currentOrder.lineItems?.map(currentLineItem => { + const updatedLineItem = updatedOrder.lineItems?.find( + lineItem => lineItem.id === currentLineItem.id + ); + + if (!updatedLineItem) { + return discountCodes?.length + ? currentLineItem + : { ...currentLineItem, discounts: [] }; + } + + return { + ...currentLineItem, + discounts: updatedLineItem.discounts ?? [], + totals: { + ...currentLineItem.totals, + discountTotal: + updatedLineItem.totals?.discountTotal ?? + currentLineItem.totals?.discountTotal, + }, + }; + }), + shippingLines: + currentOrder.shippingLines?.map((currentShippingLine, index) => { + const updatedShippingLine = updatedOrder.shippingLines?.[index]; + + if (!updatedShippingLine) { + return discountCodes?.length + ? currentShippingLine + : { ...currentShippingLine, discounts: [] }; + } + + return { + ...currentShippingLine, + discounts: updatedShippingLine.discounts ?? [], + }; + }) ?? null, + }, + }, + }; + } + ); +} + +export function useApplyDiscountCore( + options: UseApplyDiscountCoreOptions = {} +) { + const { session, jwt } = useCheckoutContext(); + const { apiHost } = useGoDaddyContext(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: checkoutMutationKeys.applyDiscount(session?.id), + mutationFn: async ({ discountCodes }: ApplyDiscountVariables) => + jwt + ? applyDiscount(discountCodes, { accessToken: jwt }, apiHost) + : applyDiscount(discountCodes, session, apiHost), + onSuccess: async (data, variables) => { + if (!session) return; + + const updatedOrder = data.applyCheckoutSessionDiscount; + if (updatedOrder) { + updateDiscountCache( + queryClient, + session.id, + updatedOrder, + variables.discountCodes + ); + } + + await options.onSuccess?.(data, variables); + }, + }); +} diff --git a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts index 440cf1d8..5470a350 100644 --- a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts +++ b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts @@ -1,181 +1,12 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { ResultOf } from 'gql.tada'; -import { useFormContext } from 'react-hook-form'; -import { useCheckoutContext } from '@/components/checkout/checkout'; -import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; -import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; -import { - checkoutMutationKeys, - checkoutQueryKeys, -} from '@/components/checkout/utils/query-keys'; -import { useGoDaddyContext } from '@/godaddy-provider'; -import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; -import { applyDiscount } from '@/lib/godaddy/godaddy'; -import type { ApplyCheckoutSessionDiscountInput } from '@/types'; +import { useApplyDiscountCore } from './use-apply-discount-core'; +import { useReconcileAfterDiscount } from './use-reconcile-after-discount'; export function useDiscountApply() { - const { session, jwt } = useCheckoutContext(); - const { apiHost } = useGoDaddyContext(); - const form = useFormContext(); - const queryClient = useQueryClient(); - const updateTaxes = useUpdateTaxes(); - const { data: draftOrder } = useDraftOrder(); + const reconcileAfterDiscount = useReconcileAfterDiscount(); - return useMutation({ - mutationKey: checkoutMutationKeys.applyDiscount(session?.id), - mutationFn: async ({ - discountCodes, - }: { - discountCodes: ApplyCheckoutSessionDiscountInput['input']['discountCodes']; - }) => { - const data = jwt - ? await applyDiscount(discountCodes, { accessToken: jwt }, apiHost) - : await applyDiscount(discountCodes, session, apiHost); - return data; - }, - onSuccess: async (data, { discountCodes }) => { - if (!session) return; - - const discountTotal = - data?.applyCheckoutSessionDiscount?.totals?.discountTotal; - const responseData = data?.applyCheckoutSessionDiscount; - // Update the cached draft-order query (includes totals) - - if (discountTotal) { - queryClient.setQueryData( - checkoutQueryKeys.draftOrder(session.id), - (old: ResultOf | undefined) => { - if (!old) return old; - return { - ...old, - checkoutSession: { - ...old.checkoutSession, - draftOrder: { - ...old?.checkoutSession?.draftOrder, - totals: { - ...old?.checkoutSession?.draftOrder?.totals, - discountTotal, - total: - responseData?.totals?.total || - old?.checkoutSession?.draftOrder?.totals?.total, - }, - // Update order-level discounts - discounts: - responseData?.discounts || - old?.checkoutSession?.draftOrder?.discounts || - [], - // Update lineItem discounts - lineItems: - responseData?.lineItems - ?.map(responseLineItem => { - const existingLineItem = - old?.checkoutSession?.draftOrder?.lineItems?.find( - li => li.id === responseLineItem.id - ); - return existingLineItem - ? { - ...existingLineItem, - discounts: responseLineItem.discounts || [], - } - : existingLineItem; - }) - .filter(Boolean) || - old?.checkoutSession?.draftOrder?.lineItems, - // Update shippingLine discounts - shippingLines: - responseData?.shippingLines - ?.map((responseShippingLine, index) => { - const existingShippingLine = - old?.checkoutSession?.draftOrder?.shippingLines?.[ - index - ]; - return existingShippingLine - ? { - ...existingShippingLine, - discounts: responseShippingLine.discounts || [], - } - : existingShippingLine; - }) - .filter(Boolean) || - old?.checkoutSession?.draftOrder?.shippingLines, - }, - }, - }; - } - ); - } - - if (!discountCodes?.length) { - // If no discount codes, we need to remove any existing discounts from the cache - queryClient.setQueryData( - checkoutQueryKeys.draftOrder(session.id), - (old: ResultOf | undefined) => { - if (!old) return old; - return { - ...old, - checkoutSession: { - ...old.checkoutSession, - draftOrder: { - ...old?.checkoutSession?.draftOrder, - discounts: [], - lineItems: old?.checkoutSession?.draftOrder?.lineItems?.map( - li => ({ - ...li, - discounts: [], - }) - ), - shippingLines: - old?.checkoutSession?.draftOrder?.shippingLines?.map( - sl => ({ - ...sl, - discounts: [], - }) - ) || null, - }, - }, - }; - } - ); - } - - if (session.enableTaxCollection) { - // TODO: Move this to API layer - const deliveryMethod = form.getValues('deliveryMethod'); - - if (deliveryMethod === DeliveryMethods.PICKUP) { - const pickupLocationId = form.getValues('pickupLocationId'); - const locationAddress = session.locations?.find( - loc => loc.id === pickupLocationId - )?.address; - - if (locationAddress) { - await updateTaxes.mutateAsync(locationAddress); - return; - } - } else if ( - deliveryMethod === DeliveryMethods.PURCHASE || - deliveryMethod === DeliveryMethods.DIGITAL - ) { - const billingAddress = draftOrder?.billing?.address; - - if (billingAddress?.postalCode && billingAddress?.countryCode) { - await updateTaxes.mutateAsync(billingAddress); - return; - } - } else { - const shippingAddress = draftOrder?.shipping?.address; - - if (shippingAddress?.postalCode && shippingAddress?.countryCode) { - await updateTaxes.mutateAsync(undefined); - return; - } - } - } - - await queryClient.invalidateQueries({ - queryKey: checkoutQueryKeys.draftOrder(session.id), - }); + return useApplyDiscountCore({ + onSuccess: async (_data, variables) => { + await reconcileAfterDiscount(variables); }, }); } diff --git a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts new file mode 100644 index 00000000..b4d0d8f4 --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts @@ -0,0 +1,118 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useFormContext } from 'react-hook-form'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; +import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; +import { buildShippingPayload } from '@/components/checkout/shipping/utils/build-shipping-payload'; +import { + getShippingMethodsKey, + requiresShippingReconciliation, + selectShippingMethod, +} from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; +import { useApplyShippingMethodCore } from '@/components/checkout/shipping/utils/use-apply-shipping-method-core'; +import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { + type ApplyDiscountVariables, + useApplyDiscountCore, +} from './use-apply-discount-core'; + +export function useReconcileAfterDiscount() { + const { session } = useCheckoutContext(); + const form = useFormContext(); + const queryClient = useQueryClient(); + const updateTaxes = useUpdateTaxes(); + const { data: draftOrder } = useDraftOrder(); + const shippingMethodsQuery = useDraftOrderShippingMethods(); + const applyShippingMethod = useApplyShippingMethodCore(); + const reapplyDiscount = useApplyDiscountCore(); + + return async (variables: ApplyDiscountVariables) => { + if (!session) return; + + const deliveryMethod = form.getValues('deliveryMethod'); + const shippingAddress = draftOrder?.shipping?.address; + const hasShippingDestination = Boolean( + shippingAddress?.addressLine1 && + shippingAddress.postalCode && + shippingAddress.countryCode + ); + + if (deliveryMethod === DeliveryMethods.SHIP && hasShippingDestination) { + const previousShippingMethods = shippingMethodsQuery.data ?? []; + const { data, isError } = await shippingMethodsQuery.refetch(); + const refreshedMethods = isError ? [] : (data ?? []); + const shippingRequiresReconciliation = requiresShippingReconciliation({ + shippingMethods: refreshedMethods, + previousShippingMethods, + currentShippingLine: draftOrder?.shippingLines?.[0], + selectedServiceCode: form.getValues('shippingMethod'), + }); + + if (shippingRequiresReconciliation) { + const currentServiceCode = + form.getValues('shippingMethod') || + draftOrder?.shippingLines?.[0]?.requestedService; + const { selectedMethod } = selectShippingMethod({ + shippingMethods: refreshedMethods, + currentServiceCode, + previousMethodsKey: getShippingMethodsKey(previousShippingMethods), + }); + + await applyShippingMethod.mutateAsync( + selectedMethod ? buildShippingPayload(selectedMethod) : [] + ); + form.setValue('shippingMethod', selectedMethod?.serviceCode ?? '', { + shouldDirty: false, + }); + + if (session.enablePromotionCodes && variables.discountCodes?.length) { + await reapplyDiscount.mutateAsync(variables); + } + + if (session.enableTaxCollection) { + await updateTaxes.mutateAsync(undefined); + } else { + await invalidateDraftOrder(); + } + return; + } + } + + if (session.enableTaxCollection) { + if (deliveryMethod === DeliveryMethods.PICKUP) { + const pickupLocationId = form.getValues('pickupLocationId'); + const locationAddress = session.locations?.find( + location => location.id === pickupLocationId + )?.address; + + if (locationAddress) { + await updateTaxes.mutateAsync(locationAddress); + return; + } + } else if ( + deliveryMethod === DeliveryMethods.PURCHASE || + deliveryMethod === DeliveryMethods.DIGITAL + ) { + const billingAddress = draftOrder?.billing?.address; + + if (billingAddress?.postalCode && billingAddress?.countryCode) { + await updateTaxes.mutateAsync(billingAddress); + return; + } + } else if (shippingAddress?.postalCode && shippingAddress?.countryCode) { + await updateTaxes.mutateAsync(undefined); + return; + } + } + + await invalidateDraftOrder(); + }; + + function invalidateDraftOrder() { + return queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(session?.id), + }); + } +} diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index 7d570fa7..bcecac7f 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -229,11 +229,7 @@ export function CheckoutForm({ subtotal > 0 && hasExpressCheckoutPaymentMethod && session?.enableShipping === true && - !fulfillmentSummary.isDigitalOnly && - deliveryMethod !== DeliveryMethods.PURCHASE && - deliveryMethod !== DeliveryMethods.DIGITAL && - !fulfillmentSummary.hasPickupLineItems && - !fulfillmentSummary.hasPurchaseLineItems + !fulfillmentSummary.isDigitalOnly ); const enableDelivery = Boolean( !fulfillmentSummary.isDigitalOnly && diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index 5d2ddd19..f7f2efa0 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { useDraftOrder, @@ -24,7 +25,7 @@ import { import { useConfirmExpressCheckout } from '@/components/checkout/payment/utils/use-confirm-express-checkout'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect'; -import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; +import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods'; import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes'; import { @@ -94,6 +95,7 @@ export function ExpressCheckoutButton() { // Use refs to store current coupon state to avoid stale closures in event handlers const appliedCouponCodeRef = useRef(null); const calculatedAdjustmentsRef = useRef(null); + const couponSyncRequestRef = useRef(0); const calculateGodaddyExpressTaxes = useCallback( async ({ @@ -144,13 +146,7 @@ export function ExpressCheckoutButton() { setShippingMethods(shippingMethodsData); - const orderSubTotal = totals?.subTotal?.value || 0; - - const sortedMethods = filterAndSortShippingMethods({ - shippingMethods: shippingMethodsData || [], - orderSubTotal, - experimentalRules: session?.experimental_rules, - }); + const sortedMethods = sortShippingMethods(shippingMethodsData || []); const methods = sortedMethods?.map(method => { const shippingMethodPrice = formatCurrency({ @@ -177,7 +173,7 @@ export function ExpressCheckoutButton() { return methods; }, - [getShippingMethodsByAddress.mutateAsync, session, totals] + [getShippingMethodsByAddress.mutateAsync, currencyCode, formatCurrency] ); const handleExpressPayClick = useCallback( @@ -306,95 +302,55 @@ export function ExpressCheckoutButton() { const [couponFetchStatus, setCouponFetchStatus] = useState< 'idle' | 'fetching' | 'done' >('idle'); + const [couponSyncRevision, setCouponSyncRevision] = useState(0); - // Extract discount codes from draft order for comparison - const draftOrderDiscountCodes = useMemo(() => { - const allCodes = new Set(); - - // Add order-level discount codes - if (draftOrder?.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - - // Add line item-level discount codes - if (draftOrder?.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - return Array.from(allCodes).sort().join(','); // Stable string for comparison - }, [draftOrder]); + const draftOrderDiscountCodes = useMemo( + () => getDraftOrderDiscountCodes(draftOrder), + [draftOrder] + ); + const discountCodesKey = JSON.stringify(draftOrderDiscountCodes); + const hasDraftOrder = Boolean(draftOrder); + const areCouponAdjustmentsReady = + draftOrderDiscountCodes.length === 0 || couponFetchStatus === 'done'; useEffect(() => { - if (!draftOrder) return; - // Prevent concurrent fetches (but allow new fetches when draft order changes) - if (couponFetchStatus === 'fetching') return; + if (!hasDraftOrder) return; - const fetchPriceAdjustments = async () => { - setCouponFetchStatus('fetching'); + const requestId = ++couponSyncRequestRef.current; + const couponCode = draftOrderDiscountCodes[0]; + setCouponFetchStatus('fetching'); + const syncPriceAdjustments = async () => { try { - const allCodes = new Set(); - - // Add order-level discount codes - if (draftOrder?.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } + if (!couponCode) { + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; + return; } - // Add line item-level discount codes - if (draftOrder?.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } + const result = await getPriceAdjustments.mutateAsync({ + discountCodes: [couponCode], + }); - const discountCodes = Array.from(allCodes); + if (requestId !== couponSyncRequestRef.current) return; - // Update refs based on what's in the draft order - if (discountCodes?.length && discountCodes?.[0]) { - const result = await getPriceAdjustments.mutateAsync({ - discountCodes: [discountCodes?.[0]], - }); + appliedCouponCodeRef.current = result ? couponCode : null; + calculatedAdjustmentsRef.current = result ?? null; + } catch { + if (requestId !== couponSyncRequestRef.current) return; - if (result) { - // Update refs with current coupon state - appliedCouponCodeRef.current = discountCodes?.[0]; - calculatedAdjustmentsRef.current = result; - } - } else { - // No coupons in draft order - clear refs - appliedCouponCodeRef.current = null; - calculatedAdjustmentsRef.current = null; - } + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; } finally { - setCouponFetchStatus('done'); + if (requestId === couponSyncRequestRef.current) { + setCouponFetchStatus('done'); + } } }; - fetchPriceAdjustments(); + syncPriceAdjustments(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draftOrder, draftOrderDiscountCodes]); + }, [hasDraftOrder, discountCodesKey, couponSyncRevision]); // Initialize the TokenizeJs instance when the component mounts // But only after price adjustments have been fetched @@ -407,7 +363,7 @@ export function ExpressCheckoutButton() { !isCollectLoading || !draftOrder || hasMounted.current || - couponFetchStatus !== 'done' + !areCouponAdjustmentsReady ) return; @@ -502,7 +458,7 @@ export function ExpressCheckoutButton() { businessId, isCollectLoading, draftOrder, - couponFetchStatus, + areCouponAdjustmentsReady, countryCode, currencyCode, session?.storeId, @@ -547,6 +503,7 @@ export function ExpressCheckoutButton() { // Reset coupon fetch status to trigger re-sync with draft order on next open // This ensures any coupon changes made inside the wallet (but not committed) are discarded setCouponFetchStatus('idle'); + setCouponSyncRevision(value => value + 1); setCalculatedTaxes(null); // Clear coupon refs - will be re-synced with draft order on next fetch diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 03fb32fc..31f5a9e5 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -10,6 +10,7 @@ import type { } from '@stripe/stripe-js'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { useDraftOrder, @@ -18,7 +19,7 @@ import { import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useStripeCheckout } from '@/components/checkout/payment/utils/use-stripe-checkout'; import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use-stripe-payment-intent'; -import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; +import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods'; import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes'; @@ -74,104 +75,51 @@ export function StripeExpressCheckoutForm() { const [shippingAddress, setShippingAddress] = useState(null); - // Track the status of coupon code fetching - const [couponFetchStatus, setCouponFetchStatus] = useState< - 'idle' | 'fetching' | 'done' - >('idle'); - // Use refs for values needed in event handlers to avoid stale closures const appliedCouponCodeRef = useRef(null); const calculatedAdjustmentsRef = useRef(null); + const couponSyncRequestRef = useRef(0); - // Extract discount codes from draft order for comparison (stable string) - const draftOrderDiscountCodes = useMemo(() => { - const allCodes = new Set(); - - // Add order-level discount codes - if (draftOrder?.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - - // Add line item-level discount codes - if (draftOrder?.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - return Array.from(allCodes).sort().join(','); // Stable string for comparison - }, [draftOrder]); + const draftOrderDiscountCodes = useMemo( + () => getDraftOrderDiscountCodes(draftOrder), + [draftOrder] + ); + const discountCodesKey = JSON.stringify(draftOrderDiscountCodes); + const hasDraftOrder = Boolean(draftOrder); - // Fetch and cache price adjustments for pre-applied coupons useEffect(() => { - if (!draftOrder) return; - // Prevent concurrent fetches (but allow new fetches when draft order changes) - if (couponFetchStatus === 'fetching') return; - - const fetchPriceAdjustments = async () => { - setCouponFetchStatus('fetching'); + if (!hasDraftOrder) return; - try { - const allCodes = new Set(); + const requestId = ++couponSyncRequestRef.current; + const couponCode = draftOrderDiscountCodes[0]; - // Add order-level discount codes - if (draftOrder?.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } + const syncPriceAdjustments = async () => { + if (!couponCode) { + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; + return; + } - // Add line item-level discount codes - if (draftOrder?.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } + try { + const result = await getPriceAdjustments.mutateAsync({ + discountCodes: [couponCode], + }); - const discountCodes = Array.from(allCodes); + if (requestId !== couponSyncRequestRef.current) return; - // Update refs based on what's in the draft order - if (discountCodes?.length && discountCodes?.[0]) { - const result = await getPriceAdjustments.mutateAsync({ - discountCodes: [discountCodes[0]], - }); + appliedCouponCodeRef.current = result ? couponCode : null; + calculatedAdjustmentsRef.current = result ?? null; + } catch { + if (requestId !== couponSyncRequestRef.current) return; - if (result) { - // Update refs with current coupon state - appliedCouponCodeRef.current = discountCodes[0]; - calculatedAdjustmentsRef.current = result; - } - } else { - // No coupons in draft order - clear refs - appliedCouponCodeRef.current = null; - calculatedAdjustmentsRef.current = null; - } - } finally { - setCouponFetchStatus('done'); + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; } }; - fetchPriceAdjustments(); + syncPriceAdjustments(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draftOrder, draftOrderDiscountCodes]); + }, [hasDraftOrder, discountCodesKey]); // Calculate taxes for express checkout const calculateExpressTaxes = useCallback( @@ -224,19 +172,9 @@ export function StripeExpressCheckoutForm() { setShippingMethods(shippingMethodsData || null); - const orderSubTotal = totals?.subTotal?.value || 0; - - return filterAndSortShippingMethods({ - shippingMethods: shippingMethodsData || [], - orderSubTotal, - experimentalRules: session?.experimental_rules, - }); + return sortShippingMethods(shippingMethodsData || []); }, - [ - getShippingMethodsByAddress, - session?.experimental_rules, - totals?.subTotal?.value, - ] + [getShippingMethodsByAddress] ); // Convert shipping methods to Stripe ShippingRate format diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index d14bcab2..ef545db3 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -1,4 +1,4 @@ -import { useQueryClient } from '@tanstack/react-query'; +import { useIsMutating, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; @@ -7,61 +7,48 @@ import { useDraftOrder, useDraftOrderShipping, useDraftOrderShippingAddress, - useDraftOrderTotals, } from '@/components/checkout/order/use-draft-order'; -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 { buildShippingPayload } from '@/components/checkout/shipping/utils/build-shipping-payload'; +import { + getShippingMethodsKey, + selectShippingMethod, +} from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; import { getShippingFulfillmentSyncKey, shouldApplyShippingMethod, } from '@/components/checkout/shipping/utils/should-apply-shipping-method'; +import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods'; 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'; -import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { + checkoutMutationKeys, + checkoutQueryKeys, +} from '@/components/checkout/utils/query-keys'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { useGoDaddyContext } from '@/godaddy-provider'; import { cn } from '@/lib/utils'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; -import type { ShippingMethod } from '@/types'; - -// Helper function to build the shipping payload -function buildShippingPayload(method: ShippingMethod) { - return [ - { - taxTotal: { - value: 0, - currencyCode: method?.cost?.currencyCode || 'USD', - }, - subTotal: { - value: method?.cost?.value || 0, - currencyCode: method?.cost?.currencyCode || 'USD', - }, - requestedService: method?.serviceCode, - requestedProvider: method?.carrierCode, - name: method?.displayName || '', - }, - ]; -} export function ShippingMethodForm() { const formatCurrency = useFormatCurrency(); const form = useFormContext(); const { t } = useGoDaddyContext(); const { session, isConfirmingCheckout } = useCheckoutContext(); - const updateTaxes = useUpdateTaxes(); const queryClient = useQueryClient(); const isPaymentDisabled = useIsPaymentDisabled(); - const { data: shippingMethodsData, isLoading: isShippingMethodsLoading } = - useDraftOrderShippingMethods(); + const { + data: shippingMethodsData, + isError: isShippingMethodsError, + isLoading: isShippingMethodsLoading, + } = useDraftOrderShippingMethods(); const { data: shippingAddress, isLoading: isShippingAddressLoading } = useDraftOrderShippingAddress(); - const { data: totals } = useDraftOrderTotals(); const { data: order, isLoading: isDraftOrderLoading } = useDraftOrder(); const { data: shippingLines } = useDraftOrderShipping(); @@ -74,15 +61,17 @@ export function ShippingMethodForm() { const fulfillmentSyncKey = getShippingFulfillmentSyncKey(order?.lineItems); const hasLineItemsMissingShippingFulfillment = Boolean(fulfillmentSyncKey); - const orderSubTotal = totals?.subTotal?.value || 0; - - const shippingMethods = filterAndSortShippingMethods({ - shippingMethods: shippingMethodsData || [], - orderSubTotal, - experimentalRules: session?.experimental_rules, - }); + const shippingMethods = sortShippingMethods( + isShippingMethodsError ? [] : shippingMethodsData || [] + ); const applyShippingMethod = useApplyShippingMethod(); + const isApplyingDiscount = + useIsMutating({ + mutationKey: checkoutMutationKeys.applyDiscount(session?.id), + }) > 0; + const lastShippingMethodsKeyRef = useRef(null); + const wasApplyingDiscountRef = useRef(false); // Track the last processed state to avoid duplicate API calls const lastProcessedStateRef = useRef<{ @@ -102,6 +91,19 @@ export function ShippingMethodForm() { }); useEffect(() => { + if (isApplyingDiscount) { + wasApplyingDiscountRef.current = true; + lastShippingMethodsKeyRef.current = + getShippingMethodsKey(shippingMethods); + lastProcessedStateRef.current = { + ...lastProcessedStateRef.current, + serviceCode: shippingLines?.requestedService ?? null, + cost: shippingLines?.amount?.value ?? null, + hadShippingMethods: shippingMethods.length > 0, + }; + return; + } + if ( isShippingMethodsLoading || isDraftOrderLoading || @@ -110,6 +112,8 @@ export function ShippingMethodForm() { ) return; + const discountJustSettled = wasApplyingDiscountRef.current; + wasApplyingDiscountRef.current = false; const hasShippingMethods = (shippingMethods?.length ?? 0) > 0; const currentServiceCode = shippingLines?.requestedService || null; const lastState = lastProcessedStateRef.current; @@ -126,6 +130,20 @@ export function ShippingMethodForm() { // Case 1: No shipping methods available - clear shipping and set fulfillment to SHIP if (!hasShippingMethods && hasShippingAddress) { + lastShippingMethodsKeyRef.current = getShippingMethodsKey([]); + + if (discountJustSettled && !currentServiceCode) { + lastProcessedStateRef.current = { + serviceCode: null, + cost: null, + hadShippingMethods: false, + wasPickup: isPickup, + clearedShippingMethod: true, + blockedFulfillmentKey: null, + }; + return; + } + // Apply empty shipping method if: // - Pickup mode and has shipping code OR wasn't pickup before // - Shipping mode and (had methods before OR haven't cleared yet) @@ -134,8 +152,16 @@ export function ShippingMethodForm() { : lastState.hadShippingMethods || !lastState.clearedShippingMethod; if (shouldClearShipping) { + const previousShippingMethod = + form.getValues('shippingMethod') || currentServiceCode || ''; form.setValue('shippingMethod', '', { shouldDirty: false }); - applyShippingMethod.mutate([]); + applyShippingMethod.mutate([], { + onError: () => { + form.setValue('shippingMethod', previousShippingMethod, { + shouldDirty: false, + }); + }, + }); lastProcessedStateRef.current = { serviceCode: null, cost: null, @@ -158,19 +184,17 @@ export function ShippingMethodForm() { // Case 2: Shipping methods available - apply or re-apply as needed if (hasShippingMethods) { - const firstMethod = shippingMethods[0]; const currentFormMethod = form.getValues('shippingMethod'); const existingMethod = currentFormMethod || currentServiceCode; + const { selectedMethod: methodToApply, methodsKey } = + selectShippingMethod({ + shippingMethods, + currentServiceCode: existingMethod, + previousMethodsKey: lastShippingMethodsKeyRef.current, + }); + lastShippingMethodsKeyRef.current = methodsKey; - // Try to find the existing method in available methods. Prefer the - // current form selection so an in-flight explicit user click is not - // overwritten by the stale draft-order shipping line while the mutation - // and refetch settle. - const matchedMethod = existingMethod - ? shippingMethods.find(m => m.serviceCode === existingMethod) - : null; - - const methodToApply = matchedMethod || firstMethod; + if (!methodToApply) return; // 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 @@ -201,7 +225,14 @@ export function ShippingMethodForm() { }; } + const previousShippingMethod = + currentFormMethod || currentServiceCode || ''; applyShippingMethod.mutate(buildShippingPayload(methodToApply), { + onError: () => { + form.setValue('shippingMethod', previousShippingMethod, { + shouldDirty: false, + }); + }, onSuccess: () => { if (!isFulfillmentSync || !session?.id) return; @@ -210,8 +241,6 @@ export function ShippingMethodForm() { }); }, }); - } else if (session?.enableTaxCollection) { - updateTaxes.mutate(undefined); } lastProcessedStateRef.current = { @@ -228,14 +257,13 @@ export function ShippingMethodForm() { } }, [ isConfirmingCheckout, + isApplyingDiscount, shippingMethods, shippingLines, hasShippingAddress, isShippingMethodsLoading, form, applyShippingMethod, - updateTaxes.mutate, - session?.enableTaxCollection, queryClient, session?.id, isPickup, diff --git a/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts b/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts new file mode 100644 index 00000000..f393d5b5 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts @@ -0,0 +1,18 @@ +import type { ShippingMethod } from '@/types'; + +export function buildShippingPayload(method: ShippingMethod) { + const currencyCode = method.cost?.currencyCode || 'USD'; + + return [ + { + taxTotal: { value: 0, currencyCode }, + subTotal: { + value: method.cost?.value || 0, + currencyCode, + }, + requestedService: method.serviceCode, + requestedProvider: method.carrierCode, + name: method.displayName || '', + }, + ]; +} diff --git a/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts deleted file mode 100644 index 677fed18..00000000 --- a/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CheckoutSession, ShippingMethod } from '@/types'; - -interface FilterShippingMethodsParams { - shippingMethods: ShippingMethod[]; - orderSubTotal: number; - experimentalRules?: CheckoutSession['experimental_rules']; -} - -export function filterAndSortShippingMethods({ - shippingMethods, - orderSubTotal, - experimentalRules, -}: FilterShippingMethodsParams): ShippingMethod[] { - const enableFreeShippingRule = experimentalRules?.freeShipping?.enabled; - const freeShippingMinimumOrderTotal = - experimentalRules?.freeShipping?.minimumOrderTotal || 0; - - return shippingMethods - .filter( - method => - !( - enableFreeShippingRule && - method?.cost?.value === 0 && - orderSubTotal < freeShippingMinimumOrderTotal - ) - ) - .sort((a, b) => { - const costA = a?.cost?.value || 0; - const costB = b?.cost?.value || 0; - - // First sort by cost - if (costA !== costB) { - return costA - costB; - } - - // If costs are equal, sort by name - const nameA = a?.displayName || ''; - const nameB = b?.displayName || ''; - return nameA.localeCompare(nameB); - }); -} diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts new file mode 100644 index 00000000..ab8764f6 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import type { ShippingLines, ShippingMethod } from '@/types'; +import { requiresShippingReconciliation } from './requires-shipping-reconciliation'; + +function shippingMethod(serviceCode: string, cost: number): ShippingMethod { + return { + serviceCode, + carrierCode: 'carrier', + displayName: serviceCode, + description: null, + features: [], + minDeliveryDate: null, + maxDeliveryDate: null, + cost: { value: cost, currencyCode: 'USD' }, + }; +} + +function shippingLine(serviceCode: string, cost: number): ShippingLines { + return { + id: `shipping-${serviceCode}`, + requestedService: serviceCode, + requestedProvider: 'carrier', + name: serviceCode, + amount: { value: cost, currencyCode: 'USD' }, + discounts: [], + }; +} + +describe('requiresShippingReconciliation', () => { + it('returns false when the selected service and cost are unchanged', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [shippingMethod('standard', 1000)], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(false); + }); + + it('returns true when a cheaper default method becomes available', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [ + shippingMethod('standard', 1000), + shippingMethod('free', 0), + ], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(true); + }); + + it('preserves the selected method when available methods are unchanged', () => { + const shippingMethods = [ + shippingMethod('standard', 1000), + shippingMethod('free', 0), + ]; + + expect( + requiresShippingReconciliation({ + shippingMethods, + previousShippingMethods: shippingMethods, + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(false); + }); + + it('returns true when the selected service becomes free', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [shippingMethod('standard', 0)], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(true); + }); + + it('returns true when the selected service is no longer available', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [shippingMethod('express', 1500)], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(true); + }); + + it('returns true when no methods remain for an applied shipping line', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(true); + }); + + it('returns false when there are no methods and no applied shipping line', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [], + currentShippingLine: null, + selectedServiceCode: null, + }) + ).toBe(false); + }); +}); diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts new file mode 100644 index 00000000..a68ae6a7 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts @@ -0,0 +1,63 @@ +import type { ShippingLines, ShippingMethod } from '@/types'; +import { sortShippingMethods } from './sort-shipping-methods'; + +interface SelectShippingMethodParams { + shippingMethods: ShippingMethod[]; + currentServiceCode?: string | null; + previousMethodsKey?: string | null; +} + +interface RequiresShippingReconciliationParams { + shippingMethods: ShippingMethod[]; + previousShippingMethods?: ShippingMethod[]; + currentShippingLine?: ShippingLines | null; + selectedServiceCode?: string | null; +} + +export function getShippingMethodsKey(shippingMethods: ShippingMethod[]) { + return JSON.stringify( + sortShippingMethods(shippingMethods).map(method => ({ + serviceCode: method.serviceCode, + carrierCode: method.carrierCode, + cost: method.cost, + })) + ); +} + +export function selectShippingMethod({ + shippingMethods, + currentServiceCode, + previousMethodsKey, +}: SelectShippingMethodParams) { + const availableMethods = sortShippingMethods(shippingMethods); + const methodsKey = getShippingMethodsKey(availableMethods); + const methodsChanged = methodsKey !== previousMethodsKey; + const selectedMethod = methodsChanged + ? availableMethods[0] + : availableMethods.find( + method => method.serviceCode === currentServiceCode + ) || availableMethods[0]; + + return { selectedMethod, methodsKey }; +} + +export function requiresShippingReconciliation({ + shippingMethods, + previousShippingMethods = [], + currentShippingLine, + selectedServiceCode, +}: RequiresShippingReconciliationParams) { + const currentServiceCode = + selectedServiceCode || currentShippingLine?.requestedService; + const { selectedMethod } = selectShippingMethod({ + shippingMethods, + currentServiceCode, + previousMethodsKey: getShippingMethodsKey(previousShippingMethods), + }); + + return selectedMethod + ? selectedMethod.serviceCode !== currentShippingLine?.requestedService || + (selectedMethod.cost?.value ?? null) !== + (currentShippingLine?.amount?.value ?? null) + : Boolean(currentShippingLine?.requestedService); +} diff --git a/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts new file mode 100644 index 00000000..7aafc090 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts @@ -0,0 +1,18 @@ +import type { ShippingMethod } from '@/types'; + +export function sortShippingMethods( + shippingMethods: ShippingMethod[] +): ShippingMethod[] { + return [...shippingMethods].sort((a, b) => { + const costA = a?.cost?.value || 0; + const costB = b?.cost?.value || 0; + + if (costA !== costB) { + return costA - costB; + } + + const nameA = a?.displayName || ''; + const nameB = b?.displayName || ''; + return nameA.localeCompare(nameB); + }); +} diff --git a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts new file mode 100644 index 00000000..9181d0ef --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts @@ -0,0 +1,97 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ResultOf } from 'gql.tada'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { + checkoutMutationKeys, + checkoutQueryKeys, +} from '@/components/checkout/utils/query-keys'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { ApplyCheckoutSessionShippingMethodMutation } from '@/lib/godaddy/checkout-mutations.ts'; +import { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; +import { applyShippingMethod } from '@/lib/godaddy/godaddy'; +import type { ApplyCheckoutSessionShippingMethodInput } from '@/types'; + +type ShippingMutationResult = ResultOf< + typeof ApplyCheckoutSessionShippingMethodMutation +>; +type ShippingMethods = ApplyCheckoutSessionShippingMethodInput['input']; + +interface UseApplyShippingMethodCoreOptions { + onSuccess?: ( + data: ShippingMutationResult, + shippingMethods: ShippingMethods + ) => Promise | void; + onError?: (error: Error) => void; +} + +export function updateShippingMethodCache( + queryClient: QueryClient, + sessionId: string, + data: ShippingMutationResult, + shippingMethods: ShippingMethods +) { + const shippingTotal = + data.applyCheckoutSessionShippingMethod?.draftOrder?.totals?.shippingTotal; + if (!shippingTotal) return; + + queryClient.setQueryData( + checkoutQueryKeys.draftOrder(sessionId), + (cached: ResultOf | undefined) => { + if (!cached) return cached; + + return { + ...cached, + checkoutSession: { + ...cached.checkoutSession, + draftOrder: { + ...cached.checkoutSession?.draftOrder, + shippingLines: shippingMethods[0] + ? [ + { + ...cached.checkoutSession?.draftOrder?.shippingLines?.[0], + name: shippingMethods[0].name, + requestedProvider: + shippingMethods[0].requestedProvider ?? null, + requestedService: + shippingMethods[0].requestedService ?? null, + amount: { ...shippingTotal }, + }, + ] + : [], + totals: { + ...cached.checkoutSession?.draftOrder?.totals, + shippingTotal: { ...shippingTotal }, + }, + }, + }, + }; + } + ); +} + +export function useApplyShippingMethodCore( + options: UseApplyShippingMethodCoreOptions = {} +) { + const { session, jwt } = useCheckoutContext(); + const { apiHost } = useGoDaddyContext(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: checkoutMutationKeys.applyShippingMethod(session?.id), + mutationFn: async (shippingMethods: ShippingMethods) => { + if (!session) return; + + return jwt + ? applyShippingMethod(shippingMethods, { accessToken: jwt }, apiHost) + : applyShippingMethod(shippingMethods, session, apiHost); + }, + onSuccess: async (data, shippingMethods) => { + if (!session || !data) return; + + updateShippingMethodCache(queryClient, session.id, data, shippingMethods); + await options.onSuccess?.(data, shippingMethods); + }, + onError: options.onError, + }); +} diff --git a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts index 331fedee..896f6ae2 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts @@ -1,88 +1,26 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { ResultOf } from 'gql.tada'; +import { useQueryClient } from '@tanstack/react-query'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDiscountApply } from '@/components/checkout/discount'; +import { useApplyDiscountCore } from '@/components/checkout/discount/utils/use-apply-discount-core'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; -import { - checkoutMutationKeys, - checkoutQueryKeys, -} from '@/components/checkout/utils/query-keys'; -import { useGoDaddyContext } from '@/godaddy-provider'; -import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; -import { applyShippingMethod } from '@/lib/godaddy/godaddy'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; -import type { ApplyCheckoutSessionShippingMethodInput } from '@/types'; +import { useApplyShippingMethodCore } from './use-apply-shipping-method-core'; export function useApplyShippingMethod() { - const { session, jwt, setCheckoutErrors } = useCheckoutContext(); - const { apiHost } = useGoDaddyContext(); + const { session, setCheckoutErrors } = useCheckoutContext(); const { data: order } = useDraftOrder(); const updateTaxes = useUpdateTaxes(); - const applyDiscount = useDiscountApply(); + const applyDiscount = useApplyDiscountCore(); const queryClient = useQueryClient(); - return useMutation({ - mutationKey: checkoutMutationKeys.applyShippingMethod(session?.id), - mutationFn: async ( - shippingMethods: ApplyCheckoutSessionShippingMethodInput['input'] - ) => { - if (!session) return; - const data = jwt - ? await applyShippingMethod( - shippingMethods, - { accessToken: jwt }, - apiHost - ) - : await applyShippingMethod(shippingMethods, session, apiHost); - return data; - }, - onSuccess: async data => { + return useApplyShippingMethodCore({ + onSuccess: async () => { setCheckoutErrors(undefined); if (!session) return; - // Extract shippingTotal from mutation response - const shippingTotal = - data?.applyCheckoutSessionShippingMethod?.draftOrder?.totals - ?.shippingTotal; - - // Update the cached draft-order query (includes totals) - if (shippingTotal) { - queryClient.setQueryData( - checkoutQueryKeys.draftOrder(session.id), - (old: ResultOf | undefined) => { - if (!old) return old; - - return { - ...old, - checkoutSession: { - ...old.checkoutSession, - draftOrder: { - ...old?.checkoutSession?.draftOrder, - shippingLines: [ - { - ...old?.checkoutSession?.draftOrder?.shippingLines?.[0], - amount: { - ...shippingTotal, - }, - }, - ], - totals: { - ...old?.checkoutSession?.draftOrder?.totals, - shippingTotal: { - ...shippingTotal, - }, - }, - }, - }, - }; - } - ); - } - const allCodes = new Set(); - // Add order-level discount codes if (order?.discounts) { for (const discount of order.discounts) { if (discount.code) { @@ -91,9 +29,6 @@ export function useApplyShippingMethod() { } } - // Line item-level discount codes do not need to be re-applied as they would not be affected by shipping method changes - - // Add shipping line-level discount codes if (order?.shippingLines) { for (const shippingLine of order.shippingLines) { if (shippingLine.discounts) { @@ -108,12 +43,11 @@ export function useApplyShippingMethod() { const discountCodes = Array.from(allCodes); - if (session?.enablePromotionCodes && discountCodes?.length) { - /* should re-apply discounts if they were previously applied */ - await applyDiscount.mutateAsync({ - discountCodes, - }); - } else if (session?.enableTaxCollection) { + if (session.enablePromotionCodes && discountCodes.length) { + await applyDiscount.mutateAsync({ discountCodes }); + } + + if (session.enableTaxCollection) { await updateTaxes.mutateAsync(undefined); } else { await queryClient.invalidateQueries({ diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 39f4780a..0c6f4ff1 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -58,10 +58,6 @@ export const CreateCheckoutSessionMutation = graphql(` } } experimental_rules { - freeShipping { - enabled - minimumOrderTotal - } gopay_override { enabled goPayAppId diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index 4e3e6185..815e3e53 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -58,10 +58,6 @@ export const GetCheckoutSessionQuery = graphql(` } } experimental_rules { - freeShipping { - enabled - minimumOrderTotal - } gopay_override { enabled goPayAppId