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/.changeset/clever-clubs-crash.md b/.changeset/clever-clubs-crash.md new file mode 100644 index 00000000..336fb607 --- /dev/null +++ b/.changeset/clever-clubs-crash.md @@ -0,0 +1,10 @@ +--- +"@godaddy/react": patch +--- + +Fix billing collection across checkout flows. + +- Align billing fields and validation for paid, free, pickup, shipping, purchase, and digital orders. +- Respect billing, shipping, phone, and tax collection settings. +- Clear hidden billing addresses when switching to a names-only flow. +- Keep totals and taxes accurate when discounts are applied. diff --git a/.changeset/retry-shipping-rates.md b/.changeset/retry-shipping-rates.md new file mode 100644 index 00000000..0b51e816 --- /dev/null +++ b/.changeset/retry-shipping-rates.md @@ -0,0 +1,6 @@ +--- +"@godaddy/react": patch +"@godaddy/localizations": patch +--- + +Show a localized retry action when shipping rates cannot be loaded. Clear applied shipping when rates cannot be loaded, and apply the refreshed default method after a successful retry. diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 33dfc622..af9669e4 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -70,6 +70,9 @@ export const deDe = { noShippingMethodAddress: 'Geben Sie Ihre Adresse ein, um verfügbare Versandmethoden zu sehen.', noShippingMethods: 'Keine Versandmethoden gefunden.', + failedToLoadMethods: + 'Versandarten konnten nicht geladen werden. Bitte versuche es erneut.', + retryMethods: 'Erneut versuchen', phone: 'Telefonnummer', country: 'Land', selectCountry: 'Land auswählen', diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index 59815771..39d3bbd3 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -70,6 +70,8 @@ export const enAu = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 89470301..2cb6f8d5 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -70,6 +70,8 @@ export const enIe = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index 95a556da..9a1852f4 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -70,6 +70,8 @@ export const enUs = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index 8128fee0..0eebdfc4 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -70,6 +70,9 @@ export const esAr = { noShippingMethodAddress: 'Ingresá tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index ccb43c9a..81446be8 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -70,6 +70,9 @@ export const esCl = { noShippingMethodAddress: 'Ingresa tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index b80b2f9c..31ea0b86 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -70,6 +70,9 @@ export const esCo = { noShippingMethodAddress: 'Ingresa tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index 013cf3a6..76167844 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -70,6 +70,9 @@ export const esEs = { noShippingMethodAddress: 'Introduce tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index 171dc3c8..a0907b11 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -70,6 +70,9 @@ export const esMx = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index 3d1f4f36..ea086c41 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -70,6 +70,9 @@ export const esPe = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index fc77429b..5be7c6a4 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -70,6 +70,9 @@ export const esUs = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index 4f4eed86..e65d2dcc 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -70,6 +70,9 @@ export const frCa = { noShippingMethodAddress: "Entrez votre adresse pour voir les méthodes d'expédition disponibles.", noShippingMethods: "Aucune méthode d'expédition trouvée.", + failedToLoadMethods: + 'Impossible de charger les modes de livraison. Veuillez réessayer.', + retryMethods: 'Réessayer', phone: 'Numéro de téléphone', country: 'Pays', selectCountry: 'Sélectionner un pays', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index 09d4c91a..e786253c 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -70,6 +70,9 @@ export const frFr = { noShippingMethodAddress: 'Entrez votre adresse pour voir les méthodes de livraison disponibles.', noShippingMethods: "Aucune méthode d'expédition trouvée.", + failedToLoadMethods: + 'Impossible de charger les modes de livraison. Veuillez réessayer.', + retryMethods: 'Réessayer', phone: 'Numéro de téléphone', country: 'Pays', selectCountry: 'Sélectionner un pays', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 4bdc412e..be48d21a 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -70,6 +70,9 @@ export const idId = { noShippingMethodAddress: 'Masukkan alamat Anda untuk melihat metode pengiriman yang tersedia.', noShippingMethods: 'Tidak ada metode pengiriman ditemukan.', + failedToLoadMethods: + 'Tidak dapat memuat metode pengiriman. Silakan coba lagi.', + retryMethods: 'Coba lagi', phone: 'Nomor Telepon', country: 'Negara', selectCountry: 'Pilih negara', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index de2515f5..e11df876 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -70,6 +70,9 @@ export const itIt = { noShippingMethodAddress: 'Inserisci il tuo indirizzo per vedere i metodi di spedizione disponibili.', noShippingMethods: 'Nessun metodo di spedizione trovato.', + failedToLoadMethods: + 'Impossibile caricare i metodi di spedizione. Riprova.', + retryMethods: 'Riprova', phone: 'Numero di Telefono', country: 'Paese', selectCountry: 'Seleziona paese', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index d47a9bba..f0856279 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -70,6 +70,9 @@ export const ptBr = { noShippingMethodAddress: 'Digite seu endereço para ver os métodos de envio disponíveis.', noShippingMethods: 'Nenhum método de envio encontrado.', + failedToLoadMethods: + 'Não foi possível carregar as formas de envio. Tente novamente.', + retryMethods: 'Tentar novamente', phone: 'Número de Telefone', country: 'País', selectCountry: 'Selecionar país', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 208d5935..7c97ec6e 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -70,6 +70,8 @@ export const qaPs = { noShippingMethodAddress: '[Ëñťër ÿöür âddrëšš ťö šëë âvâîlâblë šhîþþîñg mëťhödš fôr ÿöür löçâťîöñ.]', noShippingMethods: '[Ñö šhîþþîñg mëťhödš föüñd fôr ţhîš löçâţîöñ.]', + failedToLoadMethods: '[Üñâblë ťö löâd šhîþþîñg mëťhödš. Þléâšé ťrÿ âgâîñ.]', + retryMethods: '[Ťrÿ âgâîñ]', phone: '[Þhöñë Ñümkër Îñförmâťîöñ]', country: '[Çöüñţrÿ Šëlëçťîöñ]', selectCountry: '[Šëlëçť çöüñţrÿ frôm lîšť]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index daa63b5e..4ea7f7d0 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -70,6 +70,9 @@ export const trTr = { noShippingMethodAddress: 'Mevcut kargo yöntemlerini görmek için adresinizi girin.', noShippingMethods: 'Kargo yöntemi bulunamadı.', + failedToLoadMethods: + 'Gönderim yöntemleri yüklenemedi. Lütfen tekrar deneyin.', + retryMethods: 'Tekrar dene', phone: 'Telefon Numarası', country: 'Ülke', selectCountry: 'Ülke seçin', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index 37ff3b91..5ae72923 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -70,6 +70,9 @@ export const viVn = { noShippingMethodAddress: 'Nhập địa chỉ của bạn để xem các phương thức vận chuyển có sẵn.', noShippingMethods: 'Không tìm thấy phương thức vận chuyển nào.', + failedToLoadMethods: + 'Không thể tải phương thức vận chuyển. Vui lòng thử lại.', + retryMethods: 'Thử lại', phone: 'Số điện thoại', country: 'Quốc gia', selectCountry: 'Chọn quốc gia', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 19eb5bf9..4dbb9c3e 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -66,6 +66,8 @@ export const zhCn = { method: '配送方式', noShippingMethodAddress: '请输入您的地址以查看可用的配送方式。', noShippingMethods: '未找到配送方式。', + failedToLoadMethods: '无法加载配送方式。请重试。', + retryMethods: '重试', phone: '手机号码', country: '国家', selectCountry: '选择国家', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index 52f1938b..a480ebec 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -66,6 +66,8 @@ export const zhSg = { method: '运送方式', noShippingMethodAddress: '输入您的地址以查看可用的运送方式。', noShippingMethods: '未找到运送方式。', + failedToLoadMethods: '无法加载配送方式。请重试。', + retryMethods: '重试', phone: '电话号码', country: '国家', selectCountry: '选择国家', diff --git a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx index 1724ab89..e6c57367 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { describe, expect, it } from 'vitest'; @@ -9,6 +9,7 @@ import { PaymentProvider, useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { buildCheckoutSession, @@ -508,7 +509,7 @@ describe('Checkout confirm errors', () => { const draftOrder = buildDraftOrder({ shippingLines: [] }); const session = buildCheckoutSession({ draftOrder }); - const { user } = renderCheckout({ + const { user, queryClient } = renderCheckout({ session, draftOrder, checkoutProps: { @@ -521,6 +522,20 @@ describe('Checkout confirm errors', () => { }); await waitForCheckoutReady(); clearOperations(); + setApiError('getDraftOrderShippingMethods', 'rates failed'); + await act(async () => { + await queryClient.refetchQueries({ + queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id), + }); + }); + expect( + queryClient + .getQueryCache() + .findAll({ + queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id), + }) + .some(query => query.state.status === 'error') + ).toBe(true); await user.click( await screen.findByRole('button', { name: /express confirm seam/i }) 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..2539704a 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -1,14 +1,20 @@ import { enUs } from '@godaddy/localizations'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { getDraftOrderShippingMethods } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { CheckoutType, PaymentProvider } from '@/types'; import { buildBillingAddress, + buildShippingRates, + clearApiError, clearOperations, flushPromises, getOperations, renderCheckout, setApiError, + setShippingMethods, waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; @@ -148,6 +154,651 @@ 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('clears applied shipping once when the discount rate refresh returns an empty array', 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(); + 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); + expect( + screen.queryByRole('button', { name: enUs.shipping.retryMethods }) + ).not.toBeInTheDocument(); + }); + + it.each([ + ['error', 'same'], + ['null', 'same'], + ['error', 'changed'], + ['error', 'default'], + ['error', 'empty'], + ] as const)( + 'clears shipping after a %s rate failure and recovers with %s rates', + async (failure, recovery) => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + if (recovery === 'default') { + paidShipping.push( + ...buildShippingRates([ + { + serviceCode: 'express', + carrierCode: 'carrier', + displayName: 'Express', + cost: { value: 2000, currencyCode: 'USD' }, + }, + ]) + ); + } + const { user, queryClient, session } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + sessionOverrides: { + paymentMethods: { + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + if (recovery === 'default') { + await user.click(screen.getByRole('radio', { name: /express/i })); + await waitFor(() => { + expect(queryClient.isMutating()).toBe(0); + expect(screen.getByRole('radio', { name: /express/i })).toBeChecked(); + }); + } + clearOperations(); + if (failure === 'error') { + setApiError('getDraftOrderShippingMethods', 'rates failed'); + } else { + vi.mocked(getDraftOrderShippingMethods).mockResolvedValueOnce({ + checkoutSession: { + id: session.id, + storeId: session.storeId, + draftOrder: { id: 'order-1', calculatedShippingRates: null }, + }, + }); + } + await applyCoupon(user, 'onedollar'); + const retry = await screen.findByRole('button', { + name: enUs.shipping.retryMethods, + }); + await waitFor(() => expect(retry).toBeEnabled()); + expect( + screen.getByText(enUs.shipping.failedToLoadMethods) + ).toBeInTheDocument(); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect( + queryClient.getQueryData(checkoutQueryKeys.draftOrder(session.id)) + ).toMatchObject({ + checkoutSession: { + draftOrder: { + shippingLines: [], + }, + }, + }); + const pay = screen.getByRole('button', { name: /complete your order/i }); + expect(pay).toBeEnabled(); + await user.click(pay); + await screen.findByText(enUs.apiErrors.MISSING_SHIPPING_INFO); + await waitFor(() => expect(retry).toBeEnabled()); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + // Repeated failures keep the retry action usable without clearing twice. + setApiError('getDraftOrderShippingMethods', 'rates still failed'); + await user.click(retry); + await waitFor(() => expect(retry).toBeEnabled()); + expect(pay).toBeEnabled(); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + clearApiError('getDraftOrderShippingMethods'); + if (recovery === 'changed') { + setShippingMethods( + buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]) + ); + } else if (recovery === 'empty') { + setShippingMethods([]); + } + await user.click(retry); + await waitFor(() => + expect( + screen.queryByRole('button', { name: enUs.shipping.retryMethods }) + ).not.toBeInTheDocument() + ); + if (recovery === 'empty') { + expect( + getOperations('ApplyCheckoutSessionShippingMethod') + ).toHaveLength(1); + expect( + screen.getByText(enUs.shipping.noShippingMethods) + ).toBeInTheDocument(); + } else { + await waitFor(() => + expect( + getOperations('ApplyCheckoutSessionShippingMethod') + ).toHaveLength(2) + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[1].input + ).toEqual([ + expect.objectContaining({ + requestedService: 'standard', + subTotal: { + value: recovery === 'changed' ? 0 : 1000, + currencyCode: 'USD', + }, + }), + ]); + await waitFor(() => expect(pay).toBeEnabled()); + expect(screen.getByText('Standard')).toBeInTheDocument(); + if (recovery === 'default') { + expect( + screen.getByRole('radio', { name: /standard/i }) + ).toBeChecked(); + expect( + screen.getByRole('radio', { name: /express/i }) + ).not.toBeChecked(); + } + expect( + screen.queryByText(enUs.apiErrors.MISSING_SHIPPING_INFO) + ).not.toBeInTheDocument(); + } + } + ); + + 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-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx index 6d3a8538..788ae2cf 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 @@ -473,7 +473,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' }, @@ -510,23 +510,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 aca516b9..48aa8d16 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -1,3 +1,4 @@ +import { enUs } from '@godaddy/localizations'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; @@ -8,6 +9,7 @@ import { buildDraftOrder, buildLineItem, buildShippingAddress, + clearApiError, clearOperations, flushPromises, getOperations, @@ -20,6 +22,38 @@ import { } from './checkout-test-env'; describe('Checkout shipping behavior', () => { + it.each([true, false])( + 'preserves an existing rate and refreshes initial taxes only when enabled (%s)', + async enableTaxCollection => { + const { queryClient } = renderCheckout({ + sessionOverrides: { enableTaxCollection }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + }, + }); + await waitForCheckoutReady(); + await waitFor(() => { + expect(queryClient.isMutating()).toBe(0); + expect(queryClient.isFetching()).toBe(0); + }); + + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 0 + ); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength( + enableTaxCollection ? 1 : 0 + ); + } + ); + it('shows the no-origin-address message when shipping origin is missing', async () => { renderCheckout({ sessionOverrides: { shipping: { originAddress: null } }, @@ -97,7 +131,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', @@ -120,35 +154,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 () => { @@ -451,7 +462,7 @@ describe('Checkout shipping behavior', () => { ).toBeInTheDocument(); }); - it('records a shipping-method fetch failure when rates are refetched', async () => { + it('clears shipping after an address rate-fetch failure and reapplies the default on retry', async () => { const { user } = renderCheckout(); await waitForCheckoutReady(); clearOperations(); @@ -467,6 +478,29 @@ describe('Checkout shipping behavior', () => { ).toMatchObject({ destination: expect.objectContaining({ postalCode: '94016' }), }); + const retry = await screen.findByRole('button', { + name: enUs.shipping.retryMethods, + }); + await waitFor(() => expect(retry).toBeEnabled()); + expect( + getOperations('ApplyCheckoutSessionShippingMethod').at(-1)?.input + ).toEqual([]); + clearOperations(); + clearApiError('getDraftOrderShippingMethods'); + await user.click(retry); + await waitFor(() => + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ) + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([expect.objectContaining({ requestedService: 'free-shipping' })]); + await waitFor(() => + expect( + screen.queryByRole('button', { name: enUs.shipping.retryMethods }) + ).not.toBeInTheDocument() + ); }); it.each([ 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 dc86dd3f..1cbc2420 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -631,21 +631,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, @@ -1011,6 +1035,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/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index c71f6c9c..f19986ed 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 @@ -406,7 +362,7 @@ export function ExpressCheckoutButton() { !isCollectLoading || !draftOrder || hasMounted.current || - couponFetchStatus !== 'done' + !areCouponAdjustmentsReady ) return; @@ -501,7 +457,7 @@ export function ExpressCheckoutButton() { businessId, isCollectLoading, draftOrder, - couponFetchStatus, + areCouponAdjustmentsReady, countryCode, currencyCode, session?.storeId, @@ -546,6 +502,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 42107715..742602c4 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/payment/utils/use-flush-checkout-sync.test.tsx b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx index bbf5ea1b..06acf34d 100644 --- a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx @@ -1,3 +1,4 @@ +import { QueryObserver } from '@tanstack/react-query'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -151,6 +152,36 @@ async function clickFlush(user: ReturnType) { } describe('useFlushCheckoutSync', () => { + it('waits for a failed shipping refresh without validating checkout state', async () => { + const onCheckoutErrors = vi.fn(); + const { user, queryClient } = renderHookHost({ + onCheckoutErrors, + timeoutMs: 1000, + }); + const request = deferred(); + const observer = new QueryObserver(queryClient, { + queryKey: + checkoutQueryKeys.draftOrderShippingMethods('checkout-session-1'), + queryFn: () => request.promise, + retry: false, + }); + const unsubscribe = observer.subscribe(() => undefined); + try { + await clickFlush(user); + await act(async () => { + request.reject(new Error('Provider unavailable')); + await flushPromises(); + await vi.advanceTimersByTimeAsync(60); + }); + await waitFor(() => + expect(screen.getByTestId('result')).toHaveTextContent('resolved') + ); + expect(onCheckoutErrors).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + it('excludes tax mutation waits when tax collection is disabled', async () => { const { user } = renderHookHost({ enableTaxCollection: false, diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 4a8a8172..5d72a27a 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 { useIsMutating } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; @@ -6,45 +7,31 @@ 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 { checkoutMutationKeys } from '@/components/checkout/utils/query-keys'; +import { Button } from '@/components/ui/button'; 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(); @@ -54,11 +41,15 @@ export function ShippingMethodForm() { const updateTaxes = useUpdateTaxes(); const isPaymentDisabled = useIsPaymentDisabled(); - const { data: shippingMethodsData, isLoading: isShippingMethodsLoading } = - useDraftOrderShippingMethods(); + const { + data: shippingMethodsData, + isError: isShippingMethodsError, + isLoading: isShippingMethodsLoading, + isFetching: isShippingMethodsFetching, + refetch: refetchShippingMethods, + } = useDraftOrderShippingMethods(); const { data: shippingAddress, isLoading: isShippingAddressLoading } = useDraftOrderShippingAddress(); - const { data: totals } = useDraftOrderTotals(); const { data: order, isLoading: isDraftOrderLoading } = useDraftOrder(); const { data: shippingLines } = useDraftOrderShipping(); @@ -71,15 +62,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<{ @@ -99,14 +92,32 @@ export function ShippingMethodForm() { }); useEffect(() => { + if (isApplyingDiscount) { + wasApplyingDiscountRef.current = true; + if (!isShippingMethodsFetching) { + lastShippingMethodsKeyRef.current = + getShippingMethodsKey(shippingMethods); + lastProcessedStateRef.current = { + ...lastProcessedStateRef.current, + serviceCode: shippingLines?.requestedService ?? null, + cost: shippingLines?.amount?.value ?? null, + hadShippingMethods: shippingMethods.length > 0, + }; + } + return; + } + if ( isShippingMethodsLoading || + isShippingMethodsFetching || isDraftOrderLoading || isConfirmingCheckout || applyShippingMethod.isPending ) return; + const discountJustSettled = wasApplyingDiscountRef.current; + wasApplyingDiscountRef.current = false; const hasShippingMethods = (shippingMethods?.length ?? 0) > 0; const currentServiceCode = shippingLines?.requestedService || null; const lastState = lastProcessedStateRef.current; @@ -123,6 +134,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) @@ -131,8 +156,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, @@ -155,19 +188,18 @@ 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 isInitialSelection = lastShippingMethodsKeyRef.current === null; + 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 @@ -198,8 +230,18 @@ export function ShippingMethodForm() { }; } - applyShippingMethod.mutate(buildShippingPayload(methodToApply)); - } else if (session?.enableTaxCollection) { + const previousShippingMethod = + currentFormMethod || currentServiceCode || ''; + applyShippingMethod.mutate(buildShippingPayload(methodToApply), { + onError: () => { + form.setValue('shippingMethod', previousShippingMethod, { + shouldDirty: false, + }); + }, + }); + } else if (session?.enableTaxCollection && isInitialSelection) { + // Refresh taxes for an existing shipping selection on load. Discount + // reconciliation already owns the tax refresh when a coupon changes. updateTaxes.mutate(undefined); } @@ -217,10 +259,13 @@ export function ShippingMethodForm() { } }, [ isConfirmingCheckout, + isApplyingDiscount, shippingMethods, shippingLines, hasShippingAddress, isShippingMethodsLoading, + isShippingMethodsFetching, + isShippingMethodsError, form, applyShippingMethod, updateTaxes.mutate, @@ -247,6 +292,26 @@ export function ShippingMethodForm() { ); } + if (hasShippingAddress && isShippingMethodsError) { + return ( +
+

+ {t.shipping.failedToLoadMethods} +

+ +
+ ); + } + if ( hasShippingAddress && !isShippingMethodsLoading && 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/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts index dcb2f349..89c191cc 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts @@ -58,14 +58,25 @@ export function useDraftOrderShippingMethods() { countryCode: shippingAddress?.countryCode, }, ], - queryFn: () => - jwt - ? getDraftOrderShippingMethods( + queryFn: async () => { + const result = jwt + ? await getDraftOrderShippingMethods( { accessToken: jwt }, destination, apiHost ) - : getDraftOrderShippingMethods(session, destination, apiHost), + : await getDraftOrderShippingMethods(session, destination, apiHost); + // A null response can represent an upstream provider failure. Only an + // actual array (including []) is a validated shipping-rate result. + if ( + !Array.isArray( + result?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates + ) + ) { + throw new Error('Shipping rates are unavailable'); + } + return result; + }, enabled: !!session?.id && hasShippingAddress, select: data => data?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates, diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 89c49081..04abfdf7 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -70,10 +70,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 42e342f8..0da8f029 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -70,10 +70,6 @@ export const GetCheckoutSessionQuery = graphql(` } } experimental_rules { - freeShipping { - enabled - minimumOrderTotal - } gopay_override { enabled goPayAppId