diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b8b844dd..d507f8ea 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -24,10 +24,11 @@ jobs: - name: Node 🧰 uses: actions/setup-node@v6 with: - node-version: 20.x + node-version: 22.x registry-url: https://registry.npmjs.org cache: 'pnpm' + # npm >= 11.5.1 is required for trusted publishing (OIDC) on npmjs. - name: Update npm run: npm install -g npm@latest diff --git a/lerna.json b/lerna.json index 02b70f76..294cf0ef 100644 --- a/lerna.json +++ b/lerna.json @@ -2,10 +2,10 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useNx": false, "npmClient": "pnpm", - "version": "4.29.6", + "version": "5.0.0-beta.1", "command": { "version": { "preid": "beta" } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index ec72e269..a349d6dc 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "clean": "pnpm dlx rimraf --glob **/node_modules **/pnpm-lock.yaml" }, "devDependencies": { - "@biomejs/biome": "^2.5.4", + "@biomejs/biome": "^2.5.8", "husky": "^9.1.7", - "lerna": "^9.0.7", + "lerna": "^10.0.0", "typescript": "^6.0.3" }, "pnpm": {} diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 09b47a30..bad01afb 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,6 +1,6 @@ { "name": "@commercelayer/core-components", - "version": "1.0.0", + "version": "5.0.0-beta.1", "description": "Commerce Layer Core", "type": "module", "main": "./dist/index.js", @@ -43,10 +43,10 @@ "tsdown": "^0.22.12", "typescript": "^6.0.3", "vite-tsconfig-paths": "^6.1.1", - "vitest": "^4.1.10" + "vitest": "^4.1.5" }, "dependencies": { - "@commercelayer/js-auth": "^7.4.2", + "@commercelayer/js-auth": "^8.0.0", "@commercelayer/sdk": "8.0.0-beta.11" } } diff --git a/packages/docs/package.json b/packages/docs/package.json index 18cc0b8c..454e1aec 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -3,9 +3,9 @@ "name": "docs", "version": "4.28.3", "devDependencies": { - "@babel/core": "^7.29.7", - "@babel/preset-env": "^7.29.7", - "@commercelayer/js-auth": "^7.4.2", + "@babel/core": "^8.0.1", + "@babel/preset-env": "^8.0.2", + "@commercelayer/js-auth": "^8.0.0", "@commercelayer/sdk": "8.0.0-beta.11", "@mdx-js/react": "^3.1.1", "@storybook/addon-actions": "^9.0.8", diff --git a/packages/document/package.json b/packages/document/package.json index bf5ba019..3adae8d8 100644 --- a/packages/document/package.json +++ b/packages/document/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", - "@commercelayer/js-auth": "^7.4.2", + "@commercelayer/js-auth": "^8.0.0", "@storybook/addon-docs": "^10.5.3", "@storybook/addon-links": "^10.5.3", "@storybook/addon-mcp": "^0.7.0", diff --git a/packages/react-components/package.json b/packages/react-components/package.json index db61c321..a2c87fa6 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -1,6 +1,6 @@ { "name": "@commercelayer/react-components", - "version": "4.29.6", + "version": "5.0.0-beta.1", "description": "The Official Commerce Layer React Components", "type": "module", "main": "./dist/index.js", @@ -65,44 +65,43 @@ "@commercelayer/organization-config": "^2.8.4", "@commercelayer/react-hooks-components": "workspace:*", "@commercelayer/sdk": "8.0.0-beta.11", - "@stripe/react-stripe-js": "^6.8.0", + "@stripe/react-stripe-js": "^6.8.1", "@stripe/stripe-js": "^9.10.0", + "@iframe-resizer/parent": "^5.5.9", "@tanstack/react-table": "^8.21.3", - "@types/iframe-resizer": "^4.0.0", "braintree-web": "^3.143.0", "frames-react": "^1.2.4", - "iframe-resizer": "^4.3.6", - "rapid-form": "^4.0.4" + "rapid-form": "^5.0.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", - "@babel/core": "^7.29.7", - "@commercelayer/js-auth": "^7.4.2", + "@babel/core": "^8.0.1", + "@commercelayer/js-auth": "^8.0.0", "@faker-js/faker": "^10.5.0", "@playwright/test": "^1.61.1", "@rolldown/plugin-babel": "^0.2.3", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/braintree-web": "^3.96.17", - "@types/node": "^25.9.5", + "@types/node": "^26.2.0", "@types/react": "^19.2.17", "@types/react-test-renderer": "^19.1.0", "@types/react-window": "^2.0.0", "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.10", "babel-plugin-react-compiler": "^1.0.0", - "jsdom": "^29.1.1", + "jsdom": "^30.0.1", "msw": "^2.15.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-test-renderer": "^19.2.7", - "swr": "^2.4.2", + "swr": "^2.5.1", "tsdown": "^0.22.12", "tslib": "^2.8.1", "typescript": "^6.0.3", "vite": "^8.1.5", "vite-tsconfig-paths": "^6.1.1", - "vitest": "^4.1.10" + "vitest": "^4.1.5" }, "peerDependencies": { "react": ">=19.0.0" diff --git a/packages/react-components/specs/addresses/BillingAddressForm.spec.tsx b/packages/react-components/specs/addresses/BillingAddressForm.spec.tsx index a1901354..28ee8aa7 100644 --- a/packages/react-components/specs/addresses/BillingAddressForm.spec.tsx +++ b/packages/react-components/specs/addresses/BillingAddressForm.spec.tsx @@ -206,25 +206,54 @@ describe("BillingAddressForm", () => { }) }) - it("calls saveAddressToCustomerAddressBook for checkbox fields", async () => { - const saveAddressToCustomerAddressBook = vi.fn() - renderForm({ + it.each([ + ["ticked", "true", true], + ["unticked", "false", false], + ])( + "reports a %s save-to-address-book checkbox from rapid-form's string value", + async (_label, reported, expected) => { + // rapid-form v5 reports a checkbox as the string "true"/"false" and carries no + // `type`/`checked`. Tracking it at all requires trackUnvalidatedFields, since the + // checkbox is not a required field. + const saveAddressToCustomerAddressBook = vi.fn() + renderForm({ + values: { + billing_address_first_name: { value: "Jane", required: true }, + billing_address_save_to_customer_book: { + name: "billing_address_save_to_customer_book", + value: reported, + }, + }, + orderOverrides: { saveAddressToCustomerAddressBook }, + }) + + await waitFor(() => { + expect(saveAddressToCustomerAddressBook).toHaveBeenCalledWith( + expect.objectContaining({ type: "billing_address", value: expected }) + ) + }) + } + ) + + it("keeps the checkbox out of the address attributes", async () => { + // "false" is a truthy string, so a checkbox leaking into the values would be + // PATCHed onto the address as a bogus `save_to_customer_book` attribute. + const { setAddress } = renderForm({ values: { billing_address_first_name: { value: "Jane", required: true }, billing_address_save_to_customer_book: { - value: "on", - type: "checkbox", - checked: true, + name: "billing_address_save_to_customer_book", + value: "false", }, }, - orderOverrides: { saveAddressToCustomerAddressBook }, }) await waitFor(() => { - expect(saveAddressToCustomerAddressBook).toHaveBeenCalledWith( - expect.objectContaining({ type: "billing_address", value: true }) - ) + expect(setAddress).toHaveBeenCalled() }) + for (const call of setAddress.mock.calls) { + expect(call[0].values).not.toHaveProperty("save_to_customer_book") + } }) it("sets address errors when input validation fails", async () => { diff --git a/packages/react-components/specs/addresses/ShippingAddressForm.spec.tsx b/packages/react-components/specs/addresses/ShippingAddressForm.spec.tsx index b5475af1..e7a0cd5b 100644 --- a/packages/react-components/specs/addresses/ShippingAddressForm.spec.tsx +++ b/packages/react-components/specs/addresses/ShippingAddressForm.spec.tsx @@ -233,25 +233,54 @@ describe("ShippingAddressForm", () => { }) }) - it("calls saveAddressToCustomerAddressBook for checkbox fields", async () => { - const saveAddressToCustomerAddressBook = vi.fn() - renderForm({ + it.each([ + ["ticked", "true", true], + ["unticked", "false", false], + ])( + "reports a %s save-to-address-book checkbox from rapid-form's string value", + async (_label, reported, expected) => { + // rapid-form v5 reports a checkbox as the string "true"/"false" and carries no + // `type`/`checked`. Tracking it at all requires trackUnvalidatedFields, since the + // checkbox is not a required field. + const saveAddressToCustomerAddressBook = vi.fn() + renderForm({ + values: { + shipping_address_first_name: { value: "Jane", required: true }, + shipping_address_save_to_customer_book: { + name: "shipping_address_save_to_customer_book", + value: reported, + }, + }, + orderOverrides: { saveAddressToCustomerAddressBook }, + }) + + await waitFor(() => { + expect(saveAddressToCustomerAddressBook).toHaveBeenCalledWith( + expect.objectContaining({ type: "shipping_address", value: expected }) + ) + }) + } + ) + + it("keeps the checkbox out of the address attributes", async () => { + // "false" is a truthy string, so a checkbox leaking into the values would be + // PATCHed onto the address as a bogus `save_to_customer_book` attribute. + const { setAddress } = renderForm({ values: { shipping_address_first_name: { value: "Jane", required: true }, shipping_address_save_to_customer_book: { - value: "on", - type: "checkbox", - checked: true, + name: "shipping_address_save_to_customer_book", + value: "false", }, }, - orderOverrides: { saveAddressToCustomerAddressBook }, }) await waitFor(() => { - expect(saveAddressToCustomerAddressBook).toHaveBeenCalledWith( - expect.objectContaining({ type: "shipping_address", value: true }) - ) + expect(setAddress).toHaveBeenCalled() }) + for (const call of setAddress.mock.calls) { + expect(call[0].values).not.toHaveProperty("save_to_customer_book") + } }) it("sets address errors when input validation fails (with shouldSyncShippingAddress)", async () => { diff --git a/packages/react-components/specs/orders/hosted-cart.spec.tsx b/packages/react-components/specs/orders/hosted-cart.spec.tsx index 592672cc..5e20d86c 100644 --- a/packages/react-components/specs/orders/hosted-cart.spec.tsx +++ b/packages/react-components/specs/orders/hosted-cart.spec.tsx @@ -7,8 +7,8 @@ import OrderStorageContext from "#context/OrderStorageContext" import * as applicationLinkUtils from "#utils/getApplicationLink" import * as organizationUtils from "#utils/organization" -vi.mock("iframe-resizer", () => ({ - iframeResizer: vi.fn(), +vi.mock("@iframe-resizer/parent", () => ({ + default: vi.fn(), })) describe("HostedCart component", () => { diff --git a/packages/react-components/specs/orders/order-amounts.spec.tsx b/packages/react-components/specs/orders/order-amounts.spec.tsx new file mode 100644 index 00000000..931d76c9 --- /dev/null +++ b/packages/react-components/specs/orders/order-amounts.spec.tsx @@ -0,0 +1,163 @@ +import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it } from "vitest" +import { AdjustmentAmount } from "#components/orders/AdjustmentAmount" +import { DiscountAmount } from "#components/orders/DiscountAmount" +import { OrderNumber } from "#components/orders/OrderNumber" +import { PaymentMethodAmount } from "#components/orders/PaymentMethodAmount" +import { ShippingAmount } from "#components/orders/ShippingAmount" +import { SubTotalAmount } from "#components/orders/SubTotalAmount" +import { TaxesAmount } from "#components/orders/TaxesAmount" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import getAmount from "#utils/getAmount" + +const ORDER = { + id: "order-1", + number: 2826178, + formatted_subtotal_amount: "€145,00", + subtotal_amount_cents: 14500, + formatted_discount_amount: "-€50,00", + discount_amount_cents: -5000, + formatted_shipping_amount: "€10,00", + shipping_amount_cents: 1000, + formatted_total_tax_amount: "€0,00", + total_tax_amount_cents: 0, + formatted_adjustment_amount: "€1,00", + adjustment_amount_cents: 100, + formatted_payment_method_amount: "€2,00", + payment_method_amount_cents: 200, +} + +function withOrder(children: ReactNode, order: unknown = ORDER) { + return render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + {children} + + ) +} + +describe("order amount components", () => { + it.each([ + ["SubTotalAmount", SubTotalAmount, "€145,00", 14500], + ["DiscountAmount", DiscountAmount, "-€50,00", -5000], + ["ShippingAmount", ShippingAmount, "€10,00", 1000], + ["TaxesAmount", TaxesAmount, "€0,00", 0], + ["AdjustmentAmount", AdjustmentAmount, "€1,00", 100], + ["PaymentMethodAmount", PaymentMethodAmount, "€2,00", 200], + ])("%s renders its formatted amount", (_name, Component, expected) => { + withOrder() + + expect(screen.getByTestId("amount").textContent).toBe(expected) + }) + + it.each([ + ["SubTotalAmount", SubTotalAmount, "€145,00", 14500], + ["DiscountAmount", DiscountAmount, "-€50,00", -5000], + ["ShippingAmount", ShippingAmount, "€10,00", 1000], + ["TaxesAmount", TaxesAmount, "€0,00", 0], + ["AdjustmentAmount", AdjustmentAmount, "€1,00", 100], + ["PaymentMethodAmount", PaymentMethodAmount, "€2,00", 200], + ])("%s hands price and cents to a children function", (_name, Component, price, cents) => { + withOrder( + + {({ price: p, priceCents }) => {`${p}|${priceCents}`}} + + ) + + expect(screen.getByTestId("custom").textContent).toBe(`${price}|${cents}`) + }) + + it("renders an empty amount when the order has none", () => { + withOrder(, {}) + + expect(screen.getByTestId("amount").textContent).toBe("") + }) + + it("reads the raw cents when asked for that format", () => { + withOrder() + + expect(screen.getByTestId("amount").textContent).toBe("14500") + }) + + it("passes props through to the span", () => { + withOrder() + + expect(screen.getByTestId("amount").className).toBe("total") + }) +}) + +describe("OrderNumber", () => { + it("renders the order number", () => { + withOrder() + + expect(screen.getByTestId("number").textContent).toBe("2826178") + }) + + it("renders nothing when the order has no number", () => { + withOrder(, {}) + + expect(screen.getByTestId("number").textContent).toBe("") + }) + + it("renders nothing without an order at all", () => { + withOrder(, null) + + expect(screen.getByTestId("number").textContent).toBe("") + }) + + it("hands the attribute to a children function", () => { + withOrder( + + {({ attribute }) => {`#${attribute}`}} + + ) + + expect(screen.getByTestId("custom").textContent).toBe("#2826178") + }) +}) + +describe("getAmount", () => { + it.each([ + // format_type_base + [{ formatted_subtotal_amount: "€1" }, "formatted", "€1"], + // type_base_format + [{ subtotal_amount_cents: 100 }, "cents", 100], + ])("resolves %o for the %s format", (obj, format, expected) => { + expect(getAmount({ base: "amount", type: "subtotal", format, obj })).toBe(expected) + }) + + it("resolves the format_base_type key order", () => { + expect( + getAmount({ + base: "amount", + type: "subtotal", + format: "formatted", + obj: { formatted_amount_subtotal: "€1" }, + }) + ).toBe("€1") + }) + + it("resolves the base_type_format key order", () => { + expect( + getAmount({ + base: "amount", + type: "subtotal", + format: "cents", + obj: { amount_subtotal_cents: 100 }, + }) + ).toBe(100) + }) + + it("returns undefined when no key matches", () => { + expect( + getAmount({ base: "amount", type: "subtotal", format: "formatted", obj: { other: 1 } }) + ).toBeUndefined() + }) + + it("returns undefined for an empty object", () => { + expect( + getAmount({ base: "amount", type: "subtotal", format: "formatted", obj: {} }) + ).toBeUndefined() + }) +}) diff --git a/packages/react-components/specs/payment_source/AdyenPayment.spec.tsx b/packages/react-components/specs/payment_source/AdyenPayment.spec.tsx new file mode 100644 index 00000000..7e164325 --- /dev/null +++ b/packages/react-components/specs/payment_source/AdyenPayment.spec.tsx @@ -0,0 +1,850 @@ +// Regression coverage for the partial gift-card authorization flow. When an Adyen gift card +// covers only part of the order, the Drop-in is refreshed once for the remaining amount via +// Core's `update()`. It used to be `mount()`ed again instead, which re-rendered it with the +// *old* amount — losing the shopper's selection for no benefit — and did so repeatedly. +import { act, fireEvent, render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import PaymentGateway from "#components/payment_gateways/PaymentGateway" +import { PaymentMethod } from "#components/payment_methods/PaymentMethod" +import { AdyenPayment } from "#components/payment_source/AdyenPayment" +import { PaymentSource } from "#components/payment_source/PaymentSource" +import CommerceLayerContext from "#context/CommerceLayerContext" +import CustomerContext from "#context/CustomerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import PaymentMethodChildrenContext from "#context/PaymentMethodChildrenContext" +import PaymentMethodContext, { defaultPaymentMethodContext } from "#context/PaymentMethodContext" +import PlaceOrderContext, { defaultPlaceOrderContext } from "#context/PlaceOrderContext" + +const TEST_TOKEN = "test-token" + +const adyen = vi.hoisted(() => ({ + coreUpdate: vi.fn(), + dropinMount: vi.fn(), + dropinRemove: vi.fn(), + dropinSubmit: vi.fn(), + // The Core configuration the component builds, so tests can invoke the real + // `onSubmit` handler it installs. + // biome-ignore lint/suspicious/noExplicitAny: test cast + captured: { options: null as any }, +})) + +vi.mock("@adyen/adyen-web/auto", () => ({ + // biome-ignore lint/suspicious/noExplicitAny: test cast + AdyenCheckout: vi.fn(async (options: any) => { + adyen.captured.options = options + return { update: adyen.coreUpdate } + }), + Dropin: class FakeDropin { + mount(selector: string): this { + adyen.dropinMount(selector) + return this + } + submit(): void { + adyen.dropinSubmit() + } + remove(): void { + adyen.dropinRemove() + } + unmount(): this { + return this + } + handleAction(): void {} + }, +})) + +// derives the Adyen environment from the access token; the test token is not +// a real JWT. +vi.mock("#utils/jwt", () => ({ + jwt: () => ({ test: true }), +})) + +vi.mock("#utils/getPublicIp", () => ({ + getPublicIP: vi.fn(async () => "127.0.0.1"), +})) + +// biome-ignore lint/suspicious/noExplicitAny: test cast +const ORDER: any = { + id: "order-1", + currency_code: "EUR", + country_code: "IT", + language_code: "en-US", + status: "pending", + payment_status: "unpaid", + total_amount_with_taxes_cents: 1000, + line_items: [], +} + +/** Adyen's payment_methods payload, so the component does not log a config error. */ +const PAYMENT_SOURCE = { + id: "ps-1", + type: "adyen_payments", + payment_methods: { + paymentMethods: [{ type: "giftcard" }, { type: "scheme" }], + }, +} + +function Providers({ + children, + order = ORDER, + paymentSource = PAYMENT_SOURCE, + placeOrderStatus = "standby", + updateOrder, + getOrderByFields = vi.fn().mockResolvedValue({ status: "pending", payment_status: "unpaid" }), + setPaymentSource, + setPaymentMethodErrors = vi.fn(), + setPaymentRef = vi.fn(), +}: { + children: ReactNode + // biome-ignore lint/suspicious/noExplicitAny: test cast + order?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + paymentSource?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + placeOrderStatus?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + updateOrder?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + getOrderByFields?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + setPaymentSource?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + setPaymentMethodErrors?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + setPaymentRef?: any +}) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const paymentMethodCtx: any = { + ...defaultPaymentMethodContext, + _isProvided: true as const, + paymentSource, + currentPaymentMethodType: "giftcard", + setPaymentSource, + setPaymentMethodErrors, + setPaymentRef, + errors: [], + } + return ( + + + + + + {children} + + + + + + ) +} + +/** Resolves every pending promise chain kicked off by the component. */ +async function flush(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +/** + * Mounts the Drop-in, then submits a gift card. `balance` is what the gift card is worth + * and `orderAfterAuthorization` is the order the API returns from the authorize call. + */ +async function submitGiftCard({ + balance, + // biome-ignore lint/suspicious/noExplicitAny: test cast + orderAfterAuthorization, +}: { + balance: number + // biome-ignore lint/suspicious/noExplicitAny: test cast + orderAfterAuthorization: any +}): Promise<{ resolve: ReturnType; reject: ReturnType }> { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const setPaymentSource = vi.fn(async ({ attributes }: any) => { + if (attributes?._balance) return { ...PAYMENT_SOURCE, balance } + return { ...PAYMENT_SOURCE, payment_response: {} } + }) + const updateOrder = vi.fn().mockResolvedValue({ order: orderAfterAuthorization }) + + await act(async () => { + render( + + {message}, + }} + /> + + ) + }) + await flush() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "giftcard" } }, isValid: true }, + { mount: vi.fn() }, + actions + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + return actions +} + +describe("AdyenPayment gift card partial authorization", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("updates the mounted Drop-in with the remaining amount instead of remounting it", async () => { + const actions = await submitGiftCard({ + balance: 400, + orderAfterAuthorization: { + ...ORDER, + payment_status: "partially_authorized", + // The Commerce Layer gift-card total stays 0 — an Adyen gift card authorized via + // `_authorization_amount_cents` is not a `gift_card` resource. + gift_card_amount_cents: 0, + payment_source: { payment_response: { resultCode: "Authorised" } }, + }, + }) + + expect(actions.resolve).toHaveBeenCalledWith({ resultCode: "Authorised" }) + // 1000 total - 400 authorized by the gift card + expect(adyen.coreUpdate).toHaveBeenCalledWith( + { amount: { currency: "EUR", value: 600 } }, + { shouldReinitializeCheckout: true } + ) + // Mounted once at initialization and never again + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinMount).toHaveBeenCalledWith("#adyen-dropin") + }) + + it("prefers Adyen's own remainingAmount when the response carries one", async () => { + await submitGiftCard({ + balance: 400, + orderAfterAuthorization: { + ...ORDER, + payment_status: "partially_authorized", + payment_source: { + payment_response: { + resultCode: "Authorised", + order: { remainingAmount: { currency: "EUR", value: 550 } }, + }, + }, + }, + }) + + expect(adyen.coreUpdate).toHaveBeenCalledWith( + { amount: { currency: "EUR", value: 550 } }, + { shouldReinitializeCheckout: true } + ) + }) + + it("does not touch the amount when the gift card covers the whole order", async () => { + const actions = await submitGiftCard({ + balance: 1000, + orderAfterAuthorization: { + ...ORDER, + payment_status: "authorized", + payment_source: { payment_response: { resultCode: "Authorised" } }, + }, + }) + + expect(actions.resolve).toHaveBeenCalledWith({ resultCode: "Authorised" }) + expect(adyen.coreUpdate).not.toHaveBeenCalled() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + }) + + it("removes the Adyen instance on unmount so a remount can re-initialize", async () => { + const setPaymentSource = vi.fn(async () => PAYMENT_SOURCE) + const { unmount } = render( + + + + ) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // Without this the ref kept pointing at a destroyed Drop-in and the `!dropinRef.current` + // init guard left the component wired to it forever. + unmount() + expect(adyen.dropinRemove).toHaveBeenCalledTimes(1) + }) + + it("rejects and surfaces an error for a gift card with no balance", async () => { + const actions = await submitGiftCard({ + balance: 0, + orderAfterAuthorization: ORDER, + }) + + expect(actions.reject).toHaveBeenCalled() + expect(actions.resolve).not.toHaveBeenCalled() + expect(adyen.coreUpdate).not.toHaveBeenCalled() + expect(screen.getByTestId("gc-error").textContent).toContain("no balance") + }) +}) + +// `Dropin.submit()` throws synchronously when it has no `activePaymentMethod`. Because +// `handleSubmit` is async that became a rejected promise which awaited +// without a catch, so it surfaced as `unhandledRejection: Error: No active payment method.` +// and took the page (and the Playwright run) down instead of telling the shopper anything. +describe("AdyenPayment submit with no active payment method", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + adyen.dropinSubmit.mockReset() + }) + + it("surfaces the Adyen error instead of rejecting", async () => { + adyen.dropinSubmit.mockImplementation(() => { + throw new Error("No active payment method.") + }) + const setPaymentMethodErrors = vi.fn() + + const { container } = render( + PAYMENT_SOURCE)} + updateOrder={vi.fn()} + setPaymentMethodErrors={setPaymentMethodErrors} + > + + + ) + await flush() + + const form = container.querySelector("form") + expect(form).not.toBeNull() + + // Must not throw and must not leave a rejected promise behind. + await act(async () => { + fireEvent.submit(form as HTMLFormElement) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(setPaymentMethodErrors).toHaveBeenCalledWith([ + expect.objectContaining({ + resource: "payment_methods", + message: "No active payment method.", + }), + ]) + }) + + it("does not report an error when submit succeeds", async () => { + const setPaymentMethodErrors = vi.fn() + + const { container } = render( + PAYMENT_SOURCE)} + updateOrder={vi.fn()} + setPaymentMethodErrors={setPaymentMethodErrors} + > + + + ) + await flush() + + await act(async () => { + fireEvent.submit(container.querySelector("form") as HTMLFormElement) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(adyen.dropinSubmit).toHaveBeenCalledTimes(1) + expect(setPaymentMethodErrors).not.toHaveBeenCalled() + }) +}) + +// The reported glitch: "when the order updates the Adyen component keeps reloading". +// Re-initializing means a fresh AdyenCheckout() + new Dropin().mount(), which throws the +// shopper's selection away and re-fetches translations/analytics. An order update must not +// cause it. +describe("AdyenPayment stability across order updates", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("does not re-initialize when the payment source is recreated by an order update", async () => { + const setPaymentSource = vi.fn(async () => PAYMENT_SOURCE) + const tree = ( + // biome-ignore lint/suspicious/noExplicitAny: test cast + paymentSource: any + ) => ( + + + + ) + + const { rerender } = render(tree(PAYMENT_SOURCE)) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // What does on a mismatched amount: create a brand new payment source. + // New id and new object identity, same Adyen account so the same `public_key`. + await act(async () => { + rerender(tree({ ...PAYMENT_SOURCE, id: "ps-2", mismatched_amounts: false })) + }) + await flush() + + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinRemove).not.toHaveBeenCalled() + }) + + it("does not re-initialize across a place-order status round trip", async () => { + const setPaymentSource = vi.fn(async () => PAYMENT_SOURCE) + // biome-ignore lint/suspicious/noExplicitAny: test cast + const tree = (placeOrderStatus: any) => ( + + + + ) + + const { rerender } = render(tree("standby")) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // `status` is a dependency of the main effect, so this fires its cleanup and body again. + for (const status of ["placing", "standby"]) { + await act(async () => { + rerender(tree(status)) + }) + await flush() + } + + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinRemove).not.toHaveBeenCalled() + }) +}) + +// Refreshing the Drop-in once, when the order becomes partially authorized, is intended: the +// shopper now owes a smaller amount. Refreshing it repeatedly for the same authorization is +// the glitch — it discards the selection and re-fetches translations/analytics each time. +describe("AdyenPayment partial-authorization refresh happens once", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("refreshes once even if the gift card is submitted again for the same source", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const setPaymentSource = vi.fn(async ({ attributes }: any) => { + if (attributes?._balance) return { ...PAYMENT_SOURCE, balance: 400 } + return { ...PAYMENT_SOURCE, payment_response: {} } + }) + const updateOrder = vi.fn().mockResolvedValue({ + order: { + ...ORDER, + payment_status: "partially_authorized", + payment_source: { payment_response: { resultCode: "Authorised" } }, + }, + }) + + await act(async () => { + render( + + + + ) + }) + await flush() + + const submitOnce = async (): Promise => { + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "giftcard" } }, isValid: true }, + { mount: vi.fn() }, + actions + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + } + + await submitOnce() + expect(adyen.coreUpdate).toHaveBeenCalledTimes(1) + + // Three more passes over the same authorization — repeated effect passes, a retry, an + // order refetch that lands on the same partially-authorized state. + await submitOnce() + await submitOnce() + await submitOnce() + + // Still exactly one refresh, and the Drop-in was never remounted. + expect(adyen.coreUpdate).toHaveBeenCalledTimes(1) + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinRemove).not.toHaveBeenCalled() + }) +}) + +// The refresh resets the Drop-in's `activePaymentMethod`, so the form genuinely is not +// submittable until the shopper picks a method again. Leaving `ref.current.onsubmit` patched +// is what let call `Dropin.submit()` on an empty Drop-in. +describe("AdyenPayment disarms the submit wiring on refresh", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("clears the payment ref when the Drop-in is refreshed", async () => { + const setPaymentRef = vi.fn() + // biome-ignore lint/suspicious/noExplicitAny: test cast + const setPaymentSource = vi.fn(async ({ attributes }: any) => { + if (attributes?._balance) return { ...PAYMENT_SOURCE, balance: 400 } + return { ...PAYMENT_SOURCE, payment_response: {} } + }) + const updateOrder = vi.fn().mockResolvedValue({ + order: { + ...ORDER, + payment_status: "partially_authorized", + payment_source: { payment_response: { resultCode: "Authorised" } }, + }, + }) + + await act(async () => { + render( + + + + ) + }) + await flush() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "giftcard" } }, isValid: true }, + { mount: vi.fn() }, + actions + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(adyen.coreUpdate).toHaveBeenCalledTimes(1) + expect(setPaymentRef).toHaveBeenCalledWith({ ref: { current: null } }) + }) +}) + +// The real cost of the loader swap, measured through the real : it used to +// return `loaderComponent` instead of the gateway, unmounting the Adyen Drop-in and forcing a +// full re-initialization on the way back. `status: "placing"` is one of the flips that does it. +describe("PaymentGateway keeps the Adyen Drop-in mounted across loading flips", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("does not unmount the Drop-in when the place-order status flips", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const order: any = { + ...ORDER, + payment_method: { id: "pm-1", payment_source_type: "adyen_payments" }, + payment_source: { id: "ps-1", mismatched_amounts: false }, + } + // biome-ignore lint/suspicious/noExplicitAny: test cast + const source: any = { ...PAYMENT_SOURCE, public_key: "test_CLIENTKEY" } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const tree = (placeOrderStatus: any) => ( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + + source), + paymentSource: source, + paymentMethods: [{ id: "pm-1" }], + errors: [], + setPaymentMethodErrors: vi.fn(), + setPaymentRef: vi.fn(), + // biome-ignore lint/suspicious/noExplicitAny: test cast + } as any + } + > + {}} /> + + + + + + + ) + + const { rerender } = render(tree("standby")) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // `status === "placing"` sets loading true inside PaymentGateway. + await act(async () => { + rerender(tree("placing")) + }) + await flush() + await act(async () => { + rerender(tree("standby")) + }) + await flush() + + // Never torn down, never re-initialized. + expect(adyen.dropinRemove).not.toHaveBeenCalled() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + }) +}) + +// End-to-end through the real chain: → → → +// → . Both and implement their +// loader by replacing the subtree, so either one flipping `loading` used to tear the Drop-in +// down and re-initialize it. This is the "keeps reloading" glitch, measured where it happens. +describe("the real payment chain keeps the Adyen Drop-in mounted", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("survives the order updates of a partial gift-card authorization", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const source: any = { ...PAYMENT_SOURCE, public_key: "test_CLIENTKEY" } + const baseOrder = { + ...ORDER, + payment_method: { id: "pm-1", payment_source_type: "adyen_payments" }, + available_payment_methods: [ + { id: "pm-1", payment_source_type: "adyen_payments", name: "Adyen" }, + ], + payment_source: { id: "ps-1", mismatched_amounts: false }, + } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const tree = (order: any) => ( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + source), + paymentSource: source, + paymentMethods: baseOrder.available_payment_methods, + errors: [], + setPaymentMethodErrors: vi.fn(), + setPaymentRef: vi.fn(), + setPaymentMethod: vi.fn(), + setLoading: vi.fn(), + // biome-ignore lint/suspicious/noExplicitAny: test cast + } as any + } + > + Loading}> + + + + + + + + + + ) + + const { rerender } = render(tree(baseOrder)) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // The order updates a partial gift-card authorization produces, in order: the payment + // response lands first (the balance check refetches the order), then the authorization + // flips payment_status, then the amounts read as mismatched. + const updates = [ + { ...baseOrder, payment_source: { id: "ps-1", payment_response: { status: "authorized" } } }, + { + ...baseOrder, + payment_status: "partially_authorized", + payment_source: { id: "ps-1", payment_response: { status: "authorized" } }, + }, + { + ...baseOrder, + payment_status: "partially_authorized", + payment_source: { + id: "ps-1", + mismatched_amounts: true, + payment_response: { status: "authorized" }, + }, + }, + ] + for (const order of updates) { + await act(async () => { + rerender(tree(order)) + }) + await flush() + } + + // One mount, never torn down — no matter how the order churned underneath. + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinRemove).not.toHaveBeenCalled() + }) +}) + +// Adyen's session — the `order_data` that expires after a minute — is baked into the Drop-in +// at creation. When the API rejects a call because it expired, the reducer destroys the +// payment source and a fresh one takes its place, and the instance on screen is left talking +// to a session the API now refuses. It has to be rebuilt, or the shopper's retry can never +// succeed. This is the one case that may rebuild: the test above pins down that an ordinary +// order update must NOT. +describe("AdyenPayment expired Adyen session", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("rebuilds the Drop-in against the replacement payment source", async () => { + // What setPaymentSource returns once the API answers 422 "order_data - is expired": + // the reducer swallows the error and hands back undefined. + const setPaymentSource = vi.fn(async () => undefined) + const setPaymentMethodErrors = vi.fn() + const tree = ( + // biome-ignore lint/suspicious/noExplicitAny: test cast + paymentSource: any + ) => ( + + + + ) + + const { rerender } = render(tree(PAYMENT_SOURCE)) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "giftcard" } }, isValid: true }, + { mount: vi.fn() }, + actions + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + // The shopper is told the session expired, not that their gift card is empty — the + // balance check came back undefined because the request failed, not because it is zero. + const [[errors]] = setPaymentMethodErrors.mock.calls + expect(errors[0].message).toMatch(/session expired/i) + + // creates the replacement: new id, and the payment methods payload + // arrives with it. + await act(async () => { + rerender(tree({ ...PAYMENT_SOURCE, id: "ps-expired-replacement" })) + }) + await flush() + + expect(adyen.dropinRemove).toHaveBeenCalled() + expect(adyen.dropinMount).toHaveBeenCalledTimes(2) + }) +}) + +// recreates the adyen_payment whenever the order carries more than one +// payment method, and it can land after the Drop-in was built. The Drop-in installs its +// `onSubmit` once, so without a latest-value ref the submit authorizes against the source +// the order has already replaced: Adyen redeems the gift card against an orphan, the order +// comes back with `gift_card_amount_cents: 0`, and no amount is ever shown. +describe("AdyenPayment when the payment source is recreated mid-flight", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("submits against the current payment source, not the one it was built with", async () => { + const setPaymentSource = vi.fn(async () => ({ ...PAYMENT_SOURCE, balance: 5000 })) + const tree = ( + // biome-ignore lint/suspicious/noExplicitAny: test cast + paymentSource: any + ) => ( + + + + ) + + const { rerender } = render(tree(PAYMENT_SOURCE)) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // The replacement arrives after the Drop-in is already mounted. It must not rebuild it + // — that is the reload loop guarded elsewhere — but the next submit has to follow it. + await act(async () => { + rerender(tree({ ...PAYMENT_SOURCE, id: "ps-recreated" })) + }) + await flush() + expect(adyen.dropinRemove).not.toHaveBeenCalled() + + setPaymentSource.mockClear() + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "giftcard" } }, isValid: true }, + { mount: vi.fn() }, + { resolve: vi.fn(), reject: vi.fn() } + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + const ids = setPaymentSource.mock.calls.map(([args]) => args.paymentSourceId) + expect(ids.length).toBeGreaterThan(0) + expect(new Set(ids)).toEqual(new Set(["ps-recreated"])) + }) +}) diff --git a/packages/react-components/specs/reducers/AddressCloneReducers.spec.ts b/packages/react-components/specs/reducers/AddressCloneReducers.spec.ts new file mode 100644 index 00000000..9b58be42 --- /dev/null +++ b/packages/react-components/specs/reducers/AddressCloneReducers.spec.ts @@ -0,0 +1,342 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const addressesUpdate = vi.fn().mockResolvedValue({}) +const inStockCreate = vi.fn().mockResolvedValue({ id: "iss_1" }) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSdk: vi.fn(() => ({ + addresses: { update: addressesUpdate }, + in_stock_subscriptions: { create: inStockCreate }, + })), + } +}) + +const { + default: billingAddressReducer, + billingAddressInitialState, + setBillingAddress, + setBillingCustomerAddressId, +} = await import("#reducers/BillingAddressReducer") + +const { + default: shippingAddressReducer, + shippingAddressInitialState, + setShippingAddress, + setShippingCustomerAddressId, +} = await import("#reducers/ShippingAddressReducer") + +const { default: inStockSubscriptionReducer, setInStockSubscription } = await import( + "#reducers/InStockSubscriptionReducer" +) + +// biome-ignore lint/suspicious/noExplicitAny: test cast +const config = { accessToken: "token" } as any +// biome-ignore lint/suspicious/noExplicitAny: test cast +const ORDER = { id: "order-1" } as any + +describe("BillingAddressReducer", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + it("starts with an empty clone id", () => { + expect(billingAddressInitialState).toEqual({ _billing_address_clone_id: "" }) + }) + + it("stores the clone id on setBillingAddress", () => { + const state = billingAddressReducer(billingAddressInitialState, { + type: "setBillingAddress", + payload: { _billing_address_clone_id: "addr_1" }, + }) + + expect(state._billing_address_clone_id).toBe("addr_1") + }) + + it("dispatches the clone id without touching the API when no customer address is given", async () => { + const dispatch = vi.fn() + + await setBillingAddress("addr_1", { config, dispatch, order: ORDER }) + + expect(addressesUpdate).not.toHaveBeenCalled() + expect(dispatch).toHaveBeenCalledWith({ + type: "setBillingAddress", + payload: { _billing_address_clone_id: "addr_1" }, + }) + }) + + it("references the customer address on the address resource when one is given", async () => { + const dispatch = vi.fn() + + await setBillingAddress("addr_1", { + config, + dispatch, + order: ORDER, + customerAddressId: "cust_addr_1", + }) + + expect(addressesUpdate).toHaveBeenCalledWith({ id: "addr_1", reference: "cust_addr_1" }) + expect(dispatch).toHaveBeenCalled() + }) + + it("does nothing without an order", async () => { + const dispatch = vi.fn() + + await setBillingAddress("addr_1", { config, dispatch }) + + expect(dispatch).not.toHaveBeenCalled() + }) + + it("swallows and logs an API failure", async () => { + addressesUpdate.mockRejectedValueOnce(new Error("boom")) + const dispatch = vi.fn() + + await expect( + setBillingAddress("addr_1", { + config, + dispatch, + order: ORDER, + customerAddressId: "cust_addr_1", + }) + ).resolves.toBeUndefined() + + expect(console.error).toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + }) + + it("propagates the order's billing address reference", () => { + const dispatch = vi.fn() + const setCloneAddress = vi.fn() + + setBillingCustomerAddressId({ + dispatch, + // biome-ignore lint/suspicious/noExplicitAny: test cast + order: { billing_address: { reference: "cust_addr_1" } } as any, + setCloneAddress, + }) + + expect(dispatch).toHaveBeenCalledWith({ + type: "setBillingCustomerAddressId", + payload: { billingCustomerAddressId: "cust_addr_1" }, + }) + expect(setCloneAddress).toHaveBeenCalledWith("cust_addr_1", "billing_address") + }) + + it("does nothing when the order has no billing address reference", () => { + const dispatch = vi.fn() + const setCloneAddress = vi.fn() + + // biome-ignore lint/suspicious/noExplicitAny: test cast + setBillingCustomerAddressId({ dispatch, order: {} as any, setCloneAddress }) + + expect(dispatch).not.toHaveBeenCalled() + expect(setCloneAddress).not.toHaveBeenCalled() + }) + + it("logs rather than throwing when the clone callback fails", () => { + const setCloneAddress = vi.fn(() => { + throw new Error("clone failed") + }) + + expect(() => + setBillingCustomerAddressId({ + dispatch: vi.fn(), + // biome-ignore lint/suspicious/noExplicitAny: test cast + order: { billing_address: { reference: "cust_addr_1" } } as any, + setCloneAddress, + }) + ).not.toThrow() + + expect(console.error).toHaveBeenCalled() + }) +}) + +describe("ShippingAddressReducer", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + it("starts with an empty clone id", () => { + expect(shippingAddressInitialState).toEqual({ _shipping_address_clone_id: "" }) + }) + + it("stores the clone id on setShippingAddress", () => { + const state = shippingAddressReducer(shippingAddressInitialState, { + type: "setShippingAddress", + payload: { _shipping_address_clone_id: "addr_2" }, + }) + + expect(state._shipping_address_clone_id).toBe("addr_2") + }) + + it("dispatches the clone id without touching the API when no customer address is given", async () => { + const dispatch = vi.fn() + + await setShippingAddress("addr_2", { config, dispatch, order: ORDER }) + + expect(addressesUpdate).not.toHaveBeenCalled() + expect(dispatch).toHaveBeenCalledWith({ + type: "setShippingAddress", + payload: { _shipping_address_clone_id: "addr_2" }, + }) + }) + + it("references the customer address when one is given", async () => { + const dispatch = vi.fn() + + await setShippingAddress("addr_2", { + config, + dispatch, + order: ORDER, + customerAddressId: "cust_addr_2", + }) + + expect(addressesUpdate).toHaveBeenCalledWith({ id: "addr_2", reference: "cust_addr_2" }) + }) + + it("does nothing without an order", async () => { + const dispatch = vi.fn() + + await setShippingAddress("addr_2", { config, dispatch }) + + expect(dispatch).not.toHaveBeenCalled() + }) + + it("swallows and logs an API failure", async () => { + addressesUpdate.mockRejectedValueOnce(new Error("boom")) + const dispatch = vi.fn() + + await expect( + setShippingAddress("addr_2", { + config, + dispatch, + order: ORDER, + customerAddressId: "cust_addr_2", + }) + ).resolves.toBeUndefined() + + expect(console.error).toHaveBeenCalled() + }) + + it("propagates the order's shipping address reference", () => { + const dispatch = vi.fn() + const setCloneAddress = vi.fn() + + setShippingCustomerAddressId({ + dispatch, + // biome-ignore lint/suspicious/noExplicitAny: test cast + order: { shipping_address: { reference: "cust_addr_2" } } as any, + setCloneAddress, + }) + + expect(dispatch).toHaveBeenCalledWith({ + type: "setShippingCustomerAddressId", + payload: { shippingCustomerAddressId: "cust_addr_2" }, + }) + expect(setCloneAddress).toHaveBeenCalledWith("cust_addr_2", "shipping_address") + }) + + it("does nothing when the order has no shipping address reference", () => { + const dispatch = vi.fn() + const setCloneAddress = vi.fn() + + // biome-ignore lint/suspicious/noExplicitAny: test cast + setShippingCustomerAddressId({ dispatch, order: {} as any, setCloneAddress }) + + expect(dispatch).not.toHaveBeenCalled() + }) + + it("logs rather than throwing when the clone callback fails", () => { + const setCloneAddress = vi.fn(() => { + throw new Error("clone failed") + }) + + expect(() => + setShippingCustomerAddressId({ + dispatch: vi.fn(), + // biome-ignore lint/suspicious/noExplicitAny: test cast + order: { shipping_address: { reference: "cust_addr_2" } } as any, + setCloneAddress, + }) + ).not.toThrow() + + expect(console.error).toHaveBeenCalled() + }) +}) + +describe("InStockSubscriptionReducer", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("merges the dispatched payload into state", () => { + // baseReducer spreads the payload over state, so the errors land under `errors` + // rather than replacing the array wholesale. + // biome-ignore lint/suspicious/noExplicitAny: test cast + const errors = [{ code: "VALIDATION_ERROR", resource: "in_stock_subscriptions" }] as any + + const state = inStockSubscriptionReducer([], { type: "setErrors", payload: { errors } }) + + expect(state).toEqual({ errors }) + }) + + it("ignores an unknown action type", () => { + const initial = inStockSubscriptionReducer([], { + // biome-ignore lint/suspicious/noExplicitAny: exercising the unknown-action path + type: "unknown" as any, + // biome-ignore lint/suspicious/noExplicitAny: test cast + payload: { errors: [] } as any, + }) + + expect(initial).toEqual([]) + }) + + it("creates a subscription from a sku code alone", async () => { + const result = await setInStockSubscription({ config, dispatch: vi.fn(), skuCode: "SKU" }) + + expect(inStockCreate).toHaveBeenCalledWith({ sku_code: "SKU" }) + expect(result).toEqual({ success: true }) + }) + + it("includes the customer email when one is supplied", async () => { + await setInStockSubscription({ + config, + dispatch: vi.fn(), + skuCode: "SKU", + customerEmail: "a@b.com", + }) + + expect(inStockCreate).toHaveBeenCalledWith({ sku_code: "SKU", customer_email: "a@b.com" }) + }) + + it("reports failure and dispatches errors when the API rejects", async () => { + inStockCreate.mockRejectedValueOnce({ errors: [{ code: "VALIDATION_ERROR" }] }) + const dispatch = vi.fn() + + const result = await setInStockSubscription({ config, dispatch, skuCode: "SKU" }) + + expect(result).toEqual({ success: false }) + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "setErrors" })) + }) + + it("reports failure without a dispatch", async () => { + inStockCreate.mockRejectedValueOnce({ errors: [{ code: "VALIDATION_ERROR" }] }) + + const result = await setInStockSubscription({ config, skuCode: "SKU" }) + + expect(result).toEqual({ success: false }) + }) + + it("fails when no config is supplied", async () => { + const dispatch = vi.fn() + + // biome-ignore lint/suspicious/noExplicitAny: exercising the missing-config guard + const result = await setInStockSubscription({ config: null as any, dispatch, skuCode: "SKU" }) + + expect(result).toEqual({ success: false }) + }) +}) diff --git a/packages/react-components/specs/reducers/PaymentMethodReducer.spec.ts b/packages/react-components/specs/reducers/PaymentMethodReducer.spec.ts index f4f41e05..052a755b 100644 --- a/packages/react-components/specs/reducers/PaymentMethodReducer.spec.ts +++ b/packages/react-components/specs/reducers/PaymentMethodReducer.spec.ts @@ -80,3 +80,38 @@ describe("setPaymentSource request coalescing", () => { expect(stripeCreate).not.toHaveBeenCalled() }) }) + +describe("setPaymentSource when the payment session expired", () => { + it("returns undefined and clears the payment source instead of reporting a balance", async () => { + const expired = Object.assign(new Error("expired"), { + errors: [ + { + code: "VALIDATION_ERROR", + status: "422", + title: "is expired", + detail: "order_data - is expired", + meta: { error: "expired" }, + }, + ], + }) + stripeCreate.mockRejectedValueOnce(expired) + const dispatch = vi.fn() + + const result = await setPaymentSource( + createParams({ + dispatch, + // biome-ignore lint/suspicious/noExplicitAny: test cast + order: { id: "order-1", status: "pending", payment_source: { id: "sp-1" } } as any, + }), + ) + + // The caller must be able to tell "the call failed" from "the card is empty": + // AdyenPayment used to read `undefined?.balance ?? 0` as a zero balance and told + // the shopper to find another gift card. + expect(result).toBeUndefined() + expect(dispatch).toHaveBeenCalledWith({ + type: "setPaymentSource", + payload: { paymentSource: undefined }, + }) + }) +}) diff --git a/packages/react-components/specs/shipments/Shipment.spec.tsx b/packages/react-components/specs/shipments/Shipment.spec.tsx new file mode 100644 index 00000000..0c77e295 --- /dev/null +++ b/packages/react-components/specs/shipments/Shipment.spec.tsx @@ -0,0 +1,293 @@ +import { act, render, screen, waitFor } from "@testing-library/react" +import { useContext } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { Shipment } from "#components/shipments/Shipment" +import ShipmentChildrenContext from "#context/ShipmentChildrenContext" +import ShipmentContext from "#context/ShipmentContext" + +const SHIPPING_METHOD_A = { id: "sm_1", name: "Standard" } +const SHIPPING_METHOD_B = { id: "sm_2", name: "Express" } + +function makeShipment(overrides = {}) { + return { + id: "ship_1", + available_shipping_methods: [SHIPPING_METHOD_A, SHIPPING_METHOD_B], + shipping_method: SHIPPING_METHOD_A, + stock_line_items: [ + { quantity: 3, line_item: { id: "li_1", name: "T-shirt", quantity: 1 } }, + { quantity: 1, line_item: null }, + ], + stock_transfers: [{ id: "st_1" }], + parcels: [{ id: "parcel_1" }], + stock_location: { id: "loc_1" }, + ...overrides, + } +} + +const DELIVERY_LEAD_TIMES = [ + { id: "dlt_1", stock_location: { id: "loc_1" } }, + { id: "dlt_2", stock_location: { id: "loc_other" } }, +] + +const mockSetShippingMethod = vi.fn() + +/** Captures whatever ShipmentChildrenContext the component publishes. */ +let captured: ReturnType> | undefined + +function Capture() { + captured = useContext(ShipmentChildrenContext) + return child +} + +function renderShipment({ + shipments, + deliveryLeadTimes = DELIVERY_LEAD_TIMES, + autoSelectSingleShippingMethod, +}: { + shipments: unknown + deliveryLeadTimes?: unknown + autoSelectSingleShippingMethod?: boolean | ((order?: unknown) => void) +}) { + return render( + + loading} + > + + + + ) +} + +describe("Shipment", () => { + beforeEach(() => { + captured = undefined + vi.clearAllMocks() + mockSetShippingMethod.mockResolvedValue({ success: true, order: { id: "order-1" } }) + }) + + it("shows the loader until shipments resolve", () => { + renderShipment({ shipments: null }) + + expect(screen.getByTestId("loader")).toBeDefined() + expect(screen.queryByTestId("child")).toBeNull() + }) + + it("falls back to the default loader when none is supplied", () => { + render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + + + + ) + + expect(screen.getByText("Loading...")).toBeDefined() + }) + + it("renders one child subtree per shipment once loaded", async () => { + renderShipment({ shipments: [makeShipment(), makeShipment({ id: "ship_2" })] }) + + await waitFor(() => { + expect(screen.getAllByTestId("child")).toHaveLength(2) + }) + }) + + it("publishes the shipment's derived data on ShipmentChildrenContext", async () => { + renderShipment({ shipments: [makeShipment()] }) + + await waitFor(() => expect(captured).toBeDefined()) + + // Line items are lifted out of stock_line_items, with the stock quantity copied + // onto the line item — and a null line_item is passed through untouched. + expect(captured?.lineItems).toEqual([{ id: "li_1", name: "T-shirt", quantity: 3 }, null]) + expect(captured?.shippingMethods).toEqual([SHIPPING_METHOD_A, SHIPPING_METHOD_B]) + expect(captured?.parcels).toEqual([{ id: "parcel_1" }]) + expect(captured?.stockTransfers).toEqual([{ id: "st_1" }]) + expect(captured?.keyNumber).toBe("ship_1") + // Only lead times for this shipment's stock location. + expect(captured?.deliveryLeadTimes).toEqual([{ id: "dlt_1", stock_location: { id: "loc_1" } }]) + }) + + it("derives currentShippingMethodId from the shipment's saved shipping method", async () => { + // This is the hop that carried a stale selection in the coupon bug: the radio + // button's checked state is driven entirely by this value. + renderShipment({ shipments: [makeShipment()] }) + + await waitFor(() => expect(captured?.currentShippingMethodId).toBe("sm_1")) + }) + + it("reports no current shipping method once the order clears it", async () => { + // Applying a coupon nulls shipping_method server-side; nothing may stay selected. + renderShipment({ shipments: [makeShipment({ shipping_method: null })] }) + + await waitFor(() => expect(captured).toBeDefined()) + expect(captured?.currentShippingMethodId).toBeUndefined() + }) + + it("publishes no lead times when the shipment has no stock location", async () => { + renderShipment({ shipments: [makeShipment({ stock_location: undefined })] }) + + await waitFor(() => expect(captured).toBeDefined()) + expect(captured?.deliveryLeadTimes).toEqual([]) + }) + + it("tolerates lead times being absent entirely", async () => { + renderShipment({ shipments: [makeShipment()], deliveryLeadTimes: null }) + + await waitFor(() => expect(captured).toBeDefined()) + expect(captured?.deliveryLeadTimes).toBeUndefined() + }) + + describe("autoSelectSingleShippingMethod", () => { + it("auto-selects when the shipment has exactly one method and none chosen", async () => { + const onAutoSelect = vi.fn() + + renderShipment({ + shipments: [ + makeShipment({ + shipping_method: null, + available_shipping_methods: [SHIPPING_METHOD_A], + }), + ], + autoSelectSingleShippingMethod: onAutoSelect, + }) + + await waitFor(() => { + expect(mockSetShippingMethod).toHaveBeenCalledWith("ship_1", "sm_1") + }) + // The callback receives the order returned by setShippingMethod. + await waitFor(() => expect(onAutoSelect).toHaveBeenCalledWith({ id: "order-1" })) + }) + + it("stays on the loader while auto-selection is in flight", async () => { + // The effect only clears `loading` on the branch that skips auto-selection, so a + // shipment being auto-selected keeps showing the loader. + renderShipment({ + shipments: [ + makeShipment({ + shipping_method: null, + available_shipping_methods: [SHIPPING_METHOD_A], + }), + ], + autoSelectSingleShippingMethod: true, + }) + + await waitFor(() => expect(mockSetShippingMethod).toHaveBeenCalled()) + expect(screen.getByTestId("loader")).toBeDefined() + expect(screen.queryByTestId("child")).toBeNull() + }) + + it("prefers the lone available method over the saved one when auto-selecting", async () => { + // With auto-select on and exactly one method available, currentShippingMethodId + // comes from that method rather than from `shipment.shipping_method`. Saved and + // available deliberately differ so the two branches are distinguishable. + renderShipment({ + shipments: [ + makeShipment({ + shipping_method: SHIPPING_METHOD_B, + available_shipping_methods: [SHIPPING_METHOD_A], + }), + ], + autoSelectSingleShippingMethod: true, + }) + + await waitFor(() => expect(captured).toBeDefined()) + expect(captured?.currentShippingMethodId).toBe("sm_1") + }) + + it("does not fire the callback when the update fails", async () => { + mockSetShippingMethod.mockResolvedValue({ success: false }) + const onAutoSelect = vi.fn() + + renderShipment({ + shipments: [ + makeShipment({ + shipping_method: null, + available_shipping_methods: [SHIPPING_METHOD_A], + }), + ], + autoSelectSingleShippingMethod: onAutoSelect, + }) + + await waitFor(() => expect(mockSetShippingMethod).toHaveBeenCalled()) + expect(onAutoSelect).not.toHaveBeenCalled() + }) + + it("does not auto-select when more than one method is available", async () => { + renderShipment({ + shipments: [makeShipment({ shipping_method: null })], + autoSelectSingleShippingMethod: true, + }) + + await waitFor(() => { + expect(screen.getByTestId("child")).toBeDefined() + }) + expect(mockSetShippingMethod).not.toHaveBeenCalled() + }) + + it("does not auto-select when a shipping method is already chosen", async () => { + renderShipment({ + shipments: [makeShipment({ available_shipping_methods: [SHIPPING_METHOD_A] })], + autoSelectSingleShippingMethod: true, + }) + + await waitFor(() => { + expect(screen.getByTestId("child")).toBeDefined() + }) + expect(mockSetShippingMethod).not.toHaveBeenCalled() + }) + + it("tolerates a missing setShippingMethod on the context", async () => { + render( + + loading}> + + + + ) + + // Stays on the loader rather than throwing: there is nothing to select with. + await act(async () => {}) + expect(mockSetShippingMethod).not.toHaveBeenCalled() + }) + + it("skips a shipment with no available shipping methods at all", async () => { + renderShipment({ + shipments: [makeShipment({ shipping_method: null, available_shipping_methods: null })], + autoSelectSingleShippingMethod: true, + }) + + await waitFor(() => { + expect(screen.getByTestId("child")).toBeDefined() + }) + expect(mockSetShippingMethod).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/react-components/specs/shipments/ShipmentField.spec.tsx b/packages/react-components/specs/shipments/ShipmentField.spec.tsx new file mode 100644 index 00000000..2eca3857 --- /dev/null +++ b/packages/react-components/specs/shipments/ShipmentField.spec.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it } from "vitest" +import { ShipmentField } from "#components/shipments/ShipmentField" +import ShipmentChildrenContext, { + type InitialShipmentContext, +} from "#context/ShipmentChildrenContext" + +const SHIPMENT = { + id: "ship_1", + number: "#1234", + currency_code: "EUR", + status: "draft", + cost_amount_cents: 500, + cost_amount_float: 5, + formatted_cost_amount: "€5,00", +} + +function withContext(children: ReactNode, overrides: Partial = {}) { + return render( + + {children} + + ) +} + +describe("ShipmentField", () => { + it.each([ + ["number", "#1234"], + ["currency_code", "EUR"], + ["status", "draft"], + ["cost_amount_cents", "500"], + ["cost_amount_float", "5"], + ["formatted_cost_amount", "€5,00"], + ])("renders the %s attribute", (name, expected) => { + // biome-ignore lint/suspicious/noExplicitAny: exercising every ShipmentAttribute + withContext() + + expect(screen.getByTestId("field").textContent).toBe(expected) + }) + + it("renders keyNumber rather than a shipment attribute for key_number", () => { + // `key_number` is synthesised by , not a field on the resource. + withContext() + + expect(screen.getByTestId("field").textContent).toBe("2") + }) + + it("passes props through to the span", () => { + withContext() + + expect(screen.getByTestId("field").className).toBe("custom") + }) + + it("renders nothing for an attribute the shipment does not carry", () => { + withContext(, { shipment: undefined }) + + expect(screen.getByTestId("field").textContent).toBe("") + }) + + it("hands the shipment to a children function instead of rendering a span", () => { + withContext( + + {({ shipment }) => {`shipment ${shipment?.id}`}} + + ) + + expect(screen.getByTestId("custom").textContent).toBe("shipment ship_1") + expect(screen.queryByTestId("field")).toBeNull() + }) +}) diff --git a/packages/react-components/specs/shipments/Shipments.spec.tsx b/packages/react-components/specs/shipments/Shipments.spec.tsx index 59e12a5b..b40f2838 100644 --- a/packages/react-components/specs/shipments/Shipments.spec.tsx +++ b/packages/react-components/specs/shipments/Shipments.spec.tsx @@ -21,6 +21,7 @@ const MOCK_DELIVERY_LEAD_TIMES = [{ id: "dlt_1", shipping_method: { id: "sm_1" } const mockHookSetShippingMethod = vi.fn().mockResolvedValue(undefined) const mockMutate = vi.fn() +const mockReload = vi.fn() const mockUseShipments = vi.fn() @@ -40,14 +41,21 @@ function defaultHookReturn(overrides = {}) { isValidating: false, error: null, setShippingMethod: mockHookSetShippingMethod, - reload: vi.fn(), + reload: mockReload, mutate: mockMutate, ...overrides, } } -// biome-ignore lint/suspicious/noExplicitAny: test cast -const MOCK_ORDER_PENDING = { id: "order-1", status: "pending", shipments: MOCK_SHIPMENTS } as any +const ORDER_UPDATED_AT = "2026-08-12T10:00:00.000Z" + +const MOCK_ORDER_PENDING = { + id: "order-1", + status: "pending", + updated_at: ORDER_UPDATED_AT, + shipments: MOCK_SHIPMENTS, + // biome-ignore lint/suspicious/noExplicitAny: test cast +} as any function Providers({ accessToken = "token", @@ -462,6 +470,212 @@ describe("Shipments component", () => { consoleError.mockRestore() }) + it("keeps the same errors array when a recompute yields identical error codes", async () => { + // The errors effect bails out via a functional updater when the recomputed codes + // match, so repeated order updates don't hand children a new array each time and + // spin the render loop. Needs a non-empty error to actually run the comparison. + mockUseShipments.mockReturnValue( + defaultHookReturn({ + shipments: [{ id: "ship_1", available_shipping_methods: [] }], + }) + ) + + const seen: unknown[] = [] + function Consumer() { + const { errors } = useContext(ShipmentContext) + seen.push(errors) + return null + } + + const tree = (order: unknown) => ( + + + + + + ) + + const { rerender } = render(tree(MOCK_ORDER_PENDING)) + + await act(async () => { + rerender(tree({ ...MOCK_ORDER_PENDING, updated_at: "2026-08-12T10:05:00.000Z" })) + }) + + const withErrors = seen.filter( + (e): e is Array<{ code: string }> => Array.isArray(e) && e.length > 0 + ) + expect(withErrors[0]?.[0]?.code).toBe("NO_SHIPPING_METHODS") + // Same identity across the recompute — the updater returned `prev`. + expect(new Set(withErrors).size).toBe(1) + }) + + describe("revalidating shipments when the order changes", () => { + // Regression tests for "shipping method stays selected after applying a coupon". + // The API clears `shipment.shipping_method` server-side whenever the order totals + // change, because shipping method availability depends on them. The shipments SWR + // cache is keyed on (accessToken, orderId) alone with revalidateOnFocus/Reconnect + // off, so it never revalidated on its own: the cached shipment kept a shipping + // method the order no longer had, stayed checked on it, + // and re-clicking a checked radio fires no change event — so the user was stuck + // with a disabled save button and no way to re-select. + + // Every "does not reload" assertion below is paired with a positive control in the + // same test — a real order revision that MUST refetch. Without it, a test asserting + // only "reload was not called" would also pass if the revalidation effect were + // deleted outright, i.e. it would guard nothing. Each test names the specific broken + // implementation it exists to kill. + const NEXT_REVISION = "2026-08-12T10:05:00.000Z" + const LATER_REVISION = "2026-08-12T10:10:00.000Z" + + function renderScenario({ + order, + getOrder, + children = content, + }: { + order: unknown + getOrder?: unknown + children?: ReactNode + }) { + const tree = (o: unknown) => ( + + {children} + + ) + const view = render(tree(order)) + return { + ...view, + async showOrder(next: unknown) { + await act(async () => { + view.rerender(tree(next)) + }) + }, + } + } + + it("reloads shipments when order.updated_at changes (e.g. a coupon is applied)", async () => { + // The bug itself: applying a coupon bumps the order revision and nulls + // shipping_method server-side, so the shipments cache must refetch. + // Kills: no revalidation effect at all (the original implementation). + const { showOrder } = renderScenario({ order: MOCK_ORDER_PENDING }) + + expect(mockReload).not.toHaveBeenCalled() + + await showOrder({ + ...MOCK_ORDER_PENDING, + coupon_code: "test50off", + updated_at: NEXT_REVISION, + }) + + expect(mockReload).toHaveBeenCalledTimes(1) + }) + + it("reloads once per order revision, not once per render", async () => { + // Kills: refetching on every render that carries a revision differing from the + // one the effect last acted on, rather than stamping it as synced. + const orderWithCoupon = { ...MOCK_ORDER_PENDING, updated_at: NEXT_REVISION } + const { showOrder } = renderScenario({ order: MOCK_ORDER_PENDING }) + + await showOrder(orderWithCoupon) + await showOrder(orderWithCoupon) + await showOrder(orderWithCoupon) + + expect(mockReload).toHaveBeenCalledTimes(1) + }) + + it("does not reload on mount, only once the order moves on", async () => { + // Kills: dropping the first-order-seen guard, which would make mount treat the + // very first revision as a change and refetch shipments we just fetched. + const { showOrder } = renderScenario({ order: MOCK_ORDER_PENDING }) + + expect(mockReload).not.toHaveBeenCalled() + + // Positive control: the effect is live, so the assertion above is about the + // mount guard rather than about a missing mechanism. + await showOrder({ ...MOCK_ORDER_PENDING, updated_at: NEXT_REVISION }) + expect(mockReload).toHaveBeenCalledTimes(1) + }) + + it("does not refetch on every render when the hook returns a fresh reload identity", async () => { + // `reload` is a dependency of the revalidation effect, so an unstable identity + // re-runs the effect on every single render. + // Kills: relying on the dependency array alone and dropping the + // `syncedOrderUpdatedAt.current === updatedAt` short-circuit. + mockUseShipments.mockImplementation(() => ({ + ...defaultHookReturn(), + reload: (...args: unknown[]) => mockReload(...args), + })) + + const { showOrder } = renderScenario({ order: MOCK_ORDER_PENDING }) + + await showOrder(MOCK_ORDER_PENDING) + await showOrder(MOCK_ORDER_PENDING) + await showOrder(MOCK_ORDER_PENDING) + + expect(mockReload).not.toHaveBeenCalled() + + // Positive control, with the identity still churning on every render. + await showOrder({ ...MOCK_ORDER_PENDING, updated_at: NEXT_REVISION }) + expect(mockReload).toHaveBeenCalledTimes(1) + }) + + it("suppresses only the order revision produced by our own setShippingMethod", async () => { + // `hookSetShippingMethod` already revalidates the cache, so refetching for the + // order update it caused is a redundant round trip. + // Kills: dropping the stamp in setShippingMethod. The coupon step then proves the + // suppression is scoped to our own revision and does not deafen the effect. + const orderAfterSelection = { ...MOCK_ORDER_PENDING, updated_at: NEXT_REVISION } + const getOrder = vi.fn().mockResolvedValue(orderAfterSelection) + + let capturedSetShippingMethod: ((id: string, smId: string) => Promise) | undefined + + function Consumer() { + const { setShippingMethod } = useContext(ShipmentContext) + capturedSetShippingMethod = setShippingMethod + return null + } + + const { showOrder } = renderScenario({ + order: MOCK_ORDER_PENDING, + getOrder, + children: , + }) + + await act(async () => { + await capturedSetShippingMethod?.("ship_1", "sm_1") + }) + + // OrderContext now carries the revision our own update produced. + await showOrder(orderAfterSelection) + + expect(mockHookSetShippingMethod).toHaveBeenCalledWith("ship_1", "sm_1") + expect(mockReload).not.toHaveBeenCalled() + + // Positive control: an order change we did NOT cause must still refetch. + await showOrder({ + ...orderAfterSelection, + coupon_code: "test50off", + updated_at: LATER_REVISION, + }) + expect(mockReload).toHaveBeenCalledTimes(1) + }) + + it("does not reload when an order revision goes missing", async () => { + // A partial or optimistic order object without `updated_at` must not read as a + // new revision. + // Kills: dropping the `updatedAt == null` guard, which would compare the stamped + // revision against undefined, see a difference, and refetch. + const { showOrder } = renderScenario({ order: MOCK_ORDER_PENDING }) + + await showOrder({ ...MOCK_ORDER_PENDING, updated_at: undefined }) + expect(mockReload).not.toHaveBeenCalled() + + // Positive control: the next real revision still refetches, so the guard skipped + // the update without desynchronising the stamped revision. + await showOrder({ ...MOCK_ORDER_PENDING, updated_at: NEXT_REVISION }) + expect(mockReload).toHaveBeenCalledTimes(1) + }) + }) + it("setShipmentErrors updates the errors in context", async () => { let capturedCtx: { errors: unknown diff --git a/packages/react-components/specs/shipments/ShipmentsCount.spec.tsx b/packages/react-components/specs/shipments/ShipmentsCount.spec.tsx new file mode 100644 index 00000000..ece95721 --- /dev/null +++ b/packages/react-components/specs/shipments/ShipmentsCount.spec.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it } from "vitest" +import { ShipmentsCount } from "#components/shipments/ShipmentsCount" +import ShipmentContext from "#context/ShipmentContext" + +function withShipments(shipments: unknown, children: ReactNode) { + return render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + {children} + ) +} + +describe("ShipmentsCount", () => { + it("renders the number of shipments", () => { + withShipments([{ id: "ship_1" }, { id: "ship_2" }], ) + + expect(screen.getByTestId("count").textContent).toBe("2") + }) + + it("renders zero when there are no shipments", () => { + withShipments(null, ) + + expect(screen.getByTestId("count").textContent).toBe("0") + }) + + it("passes props through to the span", () => { + withShipments([{ id: "ship_1" }], ) + + expect(screen.getByTestId("count").className).toBe("custom") + }) + + it("hands quantity and shipments to a children function", () => { + withShipments( + [{ id: "ship_1" }, { id: "ship_2" }], + + {({ quantity, shipments }) => ( + {`${quantity} of ${shipments?.length}`} + )} + + ) + + expect(screen.getByTestId("custom").textContent).toBe("2 of 2") + expect(screen.queryByTestId("count")).toBeNull() + }) + + it("throws outside of ", () => { + // useCustomContext guards against being rendered without its provider. + expect(() => render()).toThrow( + "Cannot use outside of " + ) + }) +}) diff --git a/packages/react-components/specs/utils/currencies.spec.ts b/packages/react-components/specs/utils/currencies.spec.ts new file mode 100644 index 00000000..fa4802cf --- /dev/null +++ b/packages/react-components/specs/utils/currencies.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest" +import { + addCurrencySymbol, + type Currency, + formatCentsToCurrency, + getCurrency, + getDecimalLength, + makePlaceholder, +} from "#utils/currencies" + +describe("getCurrency", () => { + it("looks a currency up case-insensitively", () => { + expect(getCurrency("EUR")?.iso_code).toBe("EUR") + expect(getCurrency("eur")?.iso_code).toBe("EUR") + }) + + it("returns undefined for an unknown code", () => { + // biome-ignore lint/suspicious/noExplicitAny: exercising the unknown-code path + expect(getCurrency("ZZZ" as any)).toBeUndefined() + }) +}) + +describe("getDecimalLength", () => { + it.each([ + ["EUR", 2], + ["USD", 2], + // Zero-decimal currency. + ["JPY", 0], + // Four-decimal currency. + ["CLF", 4], + ])("%s has %i decimals", (code, expected) => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(getDecimalLength(getCurrency(code as any) as Currency)).toBe(expected) + }) +}) + +describe("addCurrencySymbol", () => { + it("prefixes the symbol when symbol_first", () => { + const currency = { symbol: "€", symbol_first: true } as Currency + + expect(addCurrencySymbol({ formattedValue: "1,00", currency })).toBe("€1,00") + }) + + it("suffixes the symbol otherwise", () => { + const currency = { symbol: "kr", symbol_first: false } as Currency + + expect(addCurrencySymbol({ formattedValue: "1,00", currency })).toBe("1,00kr") + }) + + it("returns an empty string when there is no value", () => { + const currency = { symbol: "€", symbol_first: true } as Currency + + expect( + // biome-ignore lint/suspicious/noExplicitAny: exercising the null-value guard + addCurrencySymbol({ formattedValue: null as any, currency }) + ).toBe("") + }) +}) + +describe("makePlaceholder", () => { + it("builds a placeholder with the currency's decimals and mark", () => { + expect(makePlaceholder(getCurrency("EUR") as Currency)).toBe("0,00") + expect(makePlaceholder(getCurrency("USD") as Currency)).toBe("0.00") + expect(makePlaceholder(getCurrency("CLF") as Currency)).toBe("0,0000") + }) + + it("collapses to a bare zero for zero-decimal currencies", () => { + expect(makePlaceholder(getCurrency("JPY") as Currency)).toBe("0") + }) + + it("applies a prefix", () => { + expect(makePlaceholder(getCurrency("EUR") as Currency, "€")).toBe("€0,00") + }) +}) + +describe("formatCentsToCurrency", () => { + it.each([ + [100, "EUR", "€1,00"], + [100000, "USD", "$1000.00"], + [100, "JPY", "¥100"], + [0, "EUR", "€0,00"], + ])("formats %i %s as %s", (cents, code, expected) => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(formatCentsToCurrency(cents, code as any)).toBe(expected) + }) + + it("strips decimals on a whole unit when asked", () => { + expect(formatCentsToCurrency(100, "EUR", true)).toBe("€1") + }) + + it("keeps decimals on a fractional unit even when stripping", () => { + expect(formatCentsToCurrency(150, "EUR", true)).toBe("€1,50") + }) + + it("falls back to the raw cents for an unknown currency", () => { + // biome-ignore lint/suspicious/noExplicitAny: exercising the unknown-currency path + expect(formatCentsToCurrency(1234, "ZZZ" as any)).toBe("1234") + }) +}) diff --git a/packages/react-components/specs/utils/data-helpers.spec.tsx b/packages/react-components/specs/utils/data-helpers.spec.tsx new file mode 100644 index 00000000..a6b339ff --- /dev/null +++ b/packages/react-components/specs/utils/data-helpers.spec.tsx @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import checkIncludeResources from "#utils/checkIncludeResource" +import compareObjAttribute from "#utils/compareObjAttribute" +import { formCleaner } from "#utils/formCleaner" +import { isEmpty } from "#utils/isEmpty" +import { sortPaymentMethods } from "#utils/payment-methods/sortPaymentMethods" +import scrollbarWidth from "#utils/scrollbarWidth" +import { isDoNotShip, shipmentsFilled } from "#utils/shipments" + +describe("isEmpty", () => { + it.each([ + ["null", null], + ["undefined", undefined], + ["empty string", ""], + ["empty array", []], + ["empty object", {}], + ["empty Map", new Map()], + ["empty Set", new Set()], + ])("treats %s as empty", (_label, value) => { + expect(isEmpty(value)).toBe(true) + }) + + it.each([ + ["string", "a"], + ["array", [1]], + ["object", { a: 1 }], + ["Map", new Map([["a", 1]])], + ["Set", new Set([1])], + ["number", 0], + ["boolean", false], + ])("treats %s as non-empty", (_label, value) => { + expect(isEmpty(value)).toBe(false) + }) +}) + +describe("shipmentsFilled", () => { + it("is true when at least one shipment has a shipping method", () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shipments = [{ shipping_method: null }, { shipping_method: { id: "sm_1" } }] as any + + expect(shipmentsFilled(shipments)).toBe(true) + }) + + it("is false when no shipment has one", () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(shipmentsFilled([{ shipping_method: null }, {}] as any)).toBe(false) + }) + + it("is false for no shipments at all", () => { + expect(shipmentsFilled([])).toBe(false) + }) +}) + +describe("isDoNotShip", () => { + it("is true only when every sku line item is flagged do_not_ship", () => { + const lineItems = [ + { item_type: "skus", item: { do_not_ship: true } }, + { item_type: "skus", item: { do_not_ship: true } }, + // Non-sku rows are excluded from the comparison entirely. + { item_type: "payment_methods", item: {} }, + // biome-ignore lint/suspicious/noExplicitAny: test cast + ] as any + + expect(isDoNotShip(lineItems)).toBe(true) + }) + + it("is false when only some sku line items are flagged", () => { + const lineItems = [ + { item_type: "skus", item: { do_not_ship: true } }, + { item_type: "skus", item: { do_not_ship: false } }, + // biome-ignore lint/suspicious/noExplicitAny: test cast + ] as any + + expect(isDoNotShip(lineItems)).toBe(false) + }) + + it.each([ + ["null", null], + ["undefined", undefined], + ["empty", []], + ])("is false for %s line items", (_label, value) => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(isDoNotShip(value as any)).toBe(false) + }) +}) + +describe("checkIncludeResources", () => { + it("is true when every requested resource is present on the order", () => { + const order = { line_items: [], shipments: [] } + + expect( + // biome-ignore lint/suspicious/noExplicitAny: test cast + checkIncludeResources({ order: order as any, resourceInclude: ["line_items", "shipments"] }) + ).toBe(true) + }) + + it("resolves a dotted include against its first segment", () => { + const order = { shipments: [] } + + expect( + checkIncludeResources({ + // biome-ignore lint/suspicious/noExplicitAny: test cast + order: order as any, + resourceInclude: ["shipments.shipping_method"], + }) + ).toBe(true) + }) + + it("is false when a requested resource is missing", () => { + expect( + // biome-ignore lint/suspicious/noExplicitAny: test cast + checkIncludeResources({ order: {} as any, resourceInclude: ["line_items"] }) + ).toBe(false) + }) + + it("is false when a dotted include's root is missing", () => { + expect( + // biome-ignore lint/suspicious/noExplicitAny: test cast + checkIncludeResources({ order: {} as any, resourceInclude: ["shipments.shipping_method"] }) + ).toBe(false) + }) + + it("is true for an empty include list", () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(checkIncludeResources({ order: {} as any, resourceInclude: [] })).toBe(true) + }) +}) + +describe("compareObjAttribute", () => { + it("returns only the scalar attributes that differ", () => { + const result = compareObjAttribute({ + attributes: { city: "Rome", zip: "00100" }, + object: { city: "Milan", zip: "00100" }, + }) + + expect(result).toEqual({ city: "Rome" }) + }) + + it("compares nested objects regardless of key order", () => { + const sameContent = compareObjAttribute({ + attributes: { meta: { b: 2, a: 1 } }, + object: { meta: { a: 1, b: 2 } }, + }) + expect(sameContent).toEqual({}) + + const different = compareObjAttribute({ + attributes: { meta: { a: 1 } }, + object: { meta: { a: 2 } }, + }) + expect(different).toEqual({ meta: { a: 1 } }) + }) + + it("ignores keys absent or falsy on the attributes side", () => { + const result = compareObjAttribute({ + attributes: { city: "" }, + object: { city: "Milan", zip: "00100" }, + }) + + expect(result).toEqual({}) + }) +}) + +describe("sortPaymentMethods", () => { + const method = (type: string) => + // biome-ignore lint/suspicious/noExplicitAny: test cast + ({ id: type, payment_source_type: type }) as any + + it("orders methods by the supplied label order", () => { + const methods = [method("stripe_payments"), method("adyen_payments")] + + const sorted = sortPaymentMethods(methods, [ + // biome-ignore lint/suspicious/noExplicitAny: test cast + "adyen_payments" as any, + // biome-ignore lint/suspicious/noExplicitAny: test cast + "stripe_payments" as any, + ]) + + expect(sorted.map((m) => m.payment_source_type)).toEqual(["adyen_payments", "stripe_payments"]) + }) + + it("pushes unlisted methods to the end and keeps unlisted pairs stable", () => { + const methods = [method("wire_transfers"), method("paypal_payments"), method("adyen_payments")] + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const sorted = sortPaymentMethods(methods, ["adyen_payments" as any]) + + expect(sorted[0]?.payment_source_type).toBe("adyen_payments") + expect(sorted.slice(1).map((m) => m.payment_source_type)).toHaveLength(2) + }) + + it("leaves the order untouched when no method is listed", () => { + const methods = [method("wire_transfers"), method("paypal_payments")] + + expect(sortPaymentMethods(methods, []).map((m) => m.payment_source_type)).toEqual([ + "wire_transfers", + "paypal_payments", + ]) + }) + + it("demotes an unlisted method that already sits after a listed one", () => { + // Listed first, unlisted second: the comparator is asked to rank the unlisted + // method against a listed one, which is the branch that sends it to the back. + const methods = [method("adyen_payments"), method("wire_transfers")] + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const sorted = sortPaymentMethods(methods, ["adyen_payments" as any]) + + expect(sorted.map((m) => m.payment_source_type)).toEqual(["adyen_payments", "wire_transfers"]) + }) +}) + +describe("formCleaner", () => { + it("strips shipping_address_ and billing_address_ prefixes", () => { + const cleaned = formCleaner({ + shipping_address_city: "Rome", + billing_address_zip: "00100", + // biome-ignore lint/suspicious/noExplicitAny: test cast + } as any) + + expect(cleaned).toEqual({ city: "Rome", zip: "00100" }) + }) + + it("drops save_to_customer_book", () => { + const cleaned = formCleaner({ + shipping_address_save_to_customer_book: true, + shipping_address_city: "Rome", + // biome-ignore lint/suspicious/noExplicitAny: test cast + } as any) + + expect(cleaned).toEqual({ city: "Rome" }) + }) + + it("leaves already-clean keys alone", () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(formCleaner({ city: "Rome" } as any)).toEqual({ city: "Rome" }) + }) + + it.each([ + ["undefined", undefined], + ["null", null], + ])("passes %s straight through", (_label, value) => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(formCleaner(value as any)).toBe(value) + }) +}) + +describe("scrollbarWidth", () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("measures without leaving the probe element behind", () => { + const before = document.body.childElementCount + + expect(typeof scrollbarWidth()).toBe("number") + expect(document.body.childElementCount).toBe(before) + }) + + it("returns zero when there is no document (SSR)", () => { + vi.stubGlobal("document", undefined) + + expect(scrollbarWidth()).toBe(0) + }) +}) diff --git a/packages/react-components/specs/utils/errors-and-async.spec.tsx b/packages/react-components/specs/utils/errors-and-async.spec.tsx new file mode 100644 index 00000000..c6e740a4 --- /dev/null +++ b/packages/react-components/specs/utils/errors-and-async.spec.tsx @@ -0,0 +1,203 @@ +import { render } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import filterChildren from "#utils/filterChildren" +import getErrors, { setErrors } from "#utils/getErrors" +import promisify from "#utils/promisify" + +describe("getErrors", () => { + it("stamps the resource onto every API error", () => { + const error = { errors: [{ code: "VALIDATION_ERROR", detail: "bad" }] } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const result = getErrors({ error: error as any, resource: "orders" }) + + expect(result).toEqual([{ code: "VALIDATION_ERROR", detail: "bad", resource: "orders" }]) + }) + + it("adds the field when one is supplied", () => { + const error = { errors: [{ code: "VALIDATION_ERROR" }] } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const result = getErrors({ error: error as any, resource: "orders", field: "email" }) + + expect(result[0]?.field).toBe("email") + }) + + it("ignores an empty field", () => { + const error = { errors: [{ code: "VALIDATION_ERROR" }] } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const result = getErrors({ error: error as any, resource: "orders", field: "" }) + + expect(result[0]).not.toHaveProperty("field") + }) + + it("merges extra attributes", () => { + const error = { errors: [{ code: "VALIDATION_ERROR" }] } + + const result = getErrors({ + // biome-ignore lint/suspicious/noExplicitAny: test cast + error: error as any, + resource: "orders", + attributes: { extra: true }, + }) + + expect(result[0]).toMatchObject({ extra: true }) + }) + + it("returns undefined when the payload carries no errors", () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(getErrors({ error: {} as any, resource: "orders" })).toBeUndefined() + }) +}) + +describe("setErrors", () => { + const err = (code: string, field?: string) => + // biome-ignore lint/suspicious/noExplicitAny: test cast + ({ code, field, resource: "orders" }) as any + + it("returns the new errors when there are none yet", () => { + expect(setErrors({ currentErrors: [], newErrors: [err("A")] })).toEqual([err("A")]) + }) + + it("keeps existing errors that the new batch does not supersede", () => { + const result = setErrors({ currentErrors: [err("A"), err("B")], newErrors: [err("B")] }) + + expect(result.map((e) => e.code)).toEqual(["A", "B", "A"]) + }) + + it("filters by a custom key", () => { + const result = setErrors({ + currentErrors: [err("A", "email"), err("A", "city")], + newErrors: [err("A", "email")], + filterBy: "field", + }) + + expect(result.map((e) => e.field)).toEqual(["email", "city", "city"]) + }) + + it("filters by a predicate function", () => { + const result = setErrors({ + currentErrors: [err("A", "email"), err("B", "city")], + newErrors: [err("A", "email")], + filterBy: (item) => item.field, + }) + + expect(result.map((e) => e.field)).toEqual(["email", "city", "city"]) + }) + + it("dispatches the merged errors when a dispatch is given", () => { + const dispatch = vi.fn() + + const result = setErrors({ currentErrors: [], newErrors: [err("A")], dispatch }) + + expect(dispatch).toHaveBeenCalledWith({ type: "setErrors", payload: { errors: result } }) + }) + + it("defaults both lists to empty", () => { + expect(setErrors({})).toEqual([]) + }) +}) + +describe("promisify", () => { + it("resolves a node-style callback invoked with params", async () => { + const cb = (params: unknown, done: (err: unknown, res: unknown) => void) => { + done(null, { echoed: params }) + } + + await expect(promisify(cb, { a: 1 })).resolves.toEqual({ echoed: { a: 1 } }) + }) + + it("rejects when the params form yields an error", async () => { + const cb = (_params: unknown, done: (err: unknown) => void) => { + done(new Error("boom")) + } + + await expect(promisify(cb, { a: 1 })).rejects.toThrow("boom") + }) + + it("prefers a tokenize method when no params are given", async () => { + const cb = { tokenize: (done: (err: unknown, payload: unknown) => void) => done(null, "tok") } + + await expect(promisify(cb)).resolves.toBe("tok") + }) + + it("rejects when tokenize fails", async () => { + const cb = { tokenize: (done: (err: unknown) => void) => done(new Error("nope")) } + + await expect(promisify(cb)).rejects.toThrow("nope") + }) + + it("calls a bare callback when there are no params and no tokenize", async () => { + const cb = (done: (err: unknown, res: unknown) => void) => done(null, "plain") + + await expect(promisify(cb)).resolves.toBe("plain") + }) + + it("rejects when a bare callback fails", async () => { + const cb = (done: (err: unknown) => void) => done(new Error("bare")) + + await expect(promisify(cb)).rejects.toThrow("bare") + }) +}) + +describe("filterChildren", () => { + function Allowed() { + return allowed + } + Allowed.displayName = "Allowed" + + function Other() { + return other + } + Other.displayName = "Other" + + it("keeps only children whose displayName is listed", () => { + const result = filterChildren({ + children: [, ], + filterBy: ["Allowed"], + componentName: "Parent", + }) + + expect(Array.isArray(result) ? result : []).toHaveLength(1) + }) + + it("returns a single child untouched", () => { + const result = filterChildren({ + children: , + filterBy: ["Allowed"], + componentName: "Parent", + }) + + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(false) + }) + + it("rejects a host element among an array of children", () => { + expect(() => + filterChildren({ + children: [, ], + filterBy: ["Allowed"], + componentName: "Parent", + }) + ).toThrow("Only library components are allowed into ") + }) + + it("rejects a lone host element child", () => { + expect(() => + filterChildren({ children: , filterBy: ["Allowed"], componentName: "Parent" }) + ).toThrow("Only library components are allowed into ") + }) + + it("renders the filtered output", () => { + const result = filterChildren({ + children: [, ], + filterBy: ["Allowed"], + componentName: "Parent", + }) + + const { container } = render(
{result}
) + + expect(container.textContent).toBe("allowed") + }) +}) diff --git a/packages/react-components/specs/utils/generic-fields.spec.tsx b/packages/react-components/specs/utils/generic-fields.spec.tsx new file mode 100644 index 00000000..d64bc95f --- /dev/null +++ b/packages/react-components/specs/utils/generic-fields.spec.tsx @@ -0,0 +1,153 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { CustomerField } from "#components/customers/CustomerField" +import { ParcelField } from "#components/parcels/ParcelField" +import { ParcelLineItemField } from "#components/parcels/ParcelLineItemField" +import GenericFieldComponent from "#components/utils/GenericFieldComponent" +import CustomerContext from "#context/CustomerContext" +import ParcelChildrenContext from "#context/ParcelChildrenContext" +import ParcelLineItemChildrenContext from "#context/ParcelLineItemChildrenContext" +import { defaultImgUrl } from "#utils/placeholderImages" + +describe("CustomerField", () => { + it("renders a customer attribute", () => { + render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + + ) + + expect(screen.getByText("a@b.com")).toBeDefined() + }) + + it("hands the value to a children function", () => { + render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + {({ attributeValue }) => {`<${attributeValue}>`}} + + + ) + + expect(screen.getByTestId("custom").textContent).toBe("") + }) +}) + +describe("ParcelField", () => { + it("renders a parcel attribute", () => { + render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + + ) + + expect(screen.getByText("P-1")).toBeDefined() + }) + + it("renders with a custom tag element", () => { + render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + + ) + + expect(screen.getByText("P-2").tagName).toBe("P") + }) +}) + +describe("ParcelLineItemField", () => { + it("renders a parcel line item attribute", () => { + render( + + + + ) + + expect(screen.getByText("T-shirt")).toBeDefined() + }) + + it("renders an image from the attribute value", () => { + const { container } = render( + + + + ) + + // The img is rendered with an empty alt, so it carries role="presentation". + expect(container.querySelector("img")?.getAttribute("src")).toBe("https://img.test/a.png") + }) +}) + +describe("GenericFieldComponent", () => { + it("falls back to the placeholder image when the attribute is empty", () => { + const { container } = render( + + + + ) + + expect(container.querySelector("img")?.getAttribute("src")).toBe(defaultImgUrl) + }) + + it("prefers a children function over the img branch", () => { + const { container } = render( + + + {({ attributeValue }) => {attributeValue}} + + + ) + + expect(screen.getByTestId("custom").textContent).toBe("https://img.test/a.png") + expect(container.querySelector("img")).toBeNull() + }) + + it("renders an empty value when the context holds no matching resource", () => { + const { container } = render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + + ) + + expect(container.textContent).toBe("") + }) + + it("defaults to a span when no tag element is given", () => { + render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + + ) + + expect(screen.getByText("P-3").tagName).toBe("SPAN") + }) +}) diff --git a/packages/react-components/specs/utils/pure-helpers.spec.tsx b/packages/react-components/specs/utils/pure-helpers.spec.tsx new file mode 100644 index 00000000..9b8ee649 --- /dev/null +++ b/packages/react-components/specs/utils/pure-helpers.spec.tsx @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest" +import customMessages from "#utils/customMessages" +import { getDomain } from "#utils/getDomain" +import isDate from "#utils/isDate" +import isJSON from "#utils/isJSON" +import omit from "#utils/omit" +import { pick } from "#utils/pick" + +describe("isDate", () => { + it.each(["2026-08-12", "2026-08-12T10:00:00.000Z", "Aug 12, 2026"])("accepts %s", (value) => { + expect(isDate(value)).toBe(true) + }) + + it.each(["", "not-a-date", "tomorrow"])("rejects %s", (value) => { + expect(isDate(value)).toBe(false) + }) +}) + +describe("isJSON", () => { + it.each(['{"a":1}', "[1,2]", '"str"', "42", "null"])("accepts %s", (value) => { + expect(isJSON(value)).toBe(true) + }) + + it.each(["", "{a:1}", "undefined", "{"])("rejects %s", (value) => { + expect(isJSON(value)).toBe(false) + }) +}) + +describe("omit", () => { + it("drops the named keys and keeps the rest", () => { + expect(omit({ a: 1, b: 2, c: 3 }, ["b"])).toEqual({ a: 1, c: 3 }) + }) + + it("returns an equal copy when nothing is omitted", () => { + const source = { a: 1, b: 2 } + const result = omit(source, []) + + expect(result).toEqual(source) + expect(result).not.toBe(source) + }) + + it("ignores keys the object does not have", () => { + expect(omit({ a: 1 }, ["missing" as "a"])).toEqual({ a: 1 }) + }) +}) + +describe("pick", () => { + it("keeps only the named keys", () => { + expect(pick({ a: 1, b: 2, c: 3 }, ["a", "c"])).toEqual({ a: 1, c: 3 }) + }) + + it("yields undefined for keys the object lacks", () => { + expect(pick({ a: 1 } as { a: number; b?: number }, ["b"])).toEqual({ b: undefined }) + }) +}) + +describe("getDomain", () => { + it("splits an org endpoint into slug and domain", () => { + expect(getDomain("https://alessani.commercelayer.io")).toEqual({ + slug: "alessani", + domain: "commercelayer.io", + }) + }) + + it("ignores path and protocol", () => { + expect(getDomain("http://demo-store.commercelayer.co/api/orders")).toEqual({ + slug: "demo-store", + domain: "commercelayer.co", + }) + }) + + it("handles a single-label host", () => { + expect(getDomain("http://localhost:3000")).toEqual({ slug: "localhost", domain: "localhost" }) + }) +}) + +describe("customMessages", () => { + const error = { + field: "email", + code: "VALIDATION_ERROR", + resource: "orders", + detail: "email is invalid", + } as const + + it("matches on field, code and resource together", () => { + const message = { field: "email", code: "VALIDATION_ERROR", resource: "orders" } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(customMessages([message] as any, error as any)).toEqual(message) + }) + + it("matches when the detail mentions the field", () => { + const message = { field: "email", code: "VALIDATION_ERROR", resource: "orders" } + const detailOnly = { code: "VALIDATION_ERROR", resource: "orders", detail: "email is invalid" } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(customMessages([message] as any, detailOnly as any)).toEqual(message) + }) + + it("matches a resource-wide message when neither side names a field", () => { + // `field: null` against an absent field: strict equality fails, so this falls + // through to the nullish-field case rather than the first one. + const message = { field: null, code: "EMPTY_ERROR", resource: "orders" } + const resourceError = { code: "EMPTY_ERROR", resource: "orders" } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(customMessages([message] as any, resourceError as any)).toEqual(message) + }) + + it("matches on identical undefined fields via the first case", () => { + const message = { code: "EMPTY_ERROR", resource: "orders" } + const resourceError = { code: "EMPTY_ERROR", resource: "orders" } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(customMessages([message] as any, resourceError as any)).toEqual(message) + }) + + it("does not match a nullish-field message when the error names a field", () => { + const message = { field: null, code: "VALIDATION_ERROR", resource: "orders" } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(customMessages([message] as any, error as any)).toBeNull() + }) + + it("returns null when nothing matches", () => { + const message = { field: "other", code: "OTHER", resource: "line_items" } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(customMessages([message] as any, error as any)).toBeNull() + }) + + it("returns null for an empty message list", () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + expect(customMessages(undefined, error as any)).toBeNull() + }) +}) + +describe("events", () => { + it("delivers published data to a subscriber and stops after unsubscribe", async () => { + const { publish, subscribe, unsubscribe } = await import("#utils/events") + const listener = vi.fn() + + subscribe("open-cart", listener) + publish("open-cart", { open: true }) + + expect(listener).toHaveBeenCalledTimes(1) + const received = listener.mock.calls[0]?.[0] as CustomEvent | undefined + expect(received?.detail).toEqual({ open: true }) + + unsubscribe("open-cart", listener) + publish("open-cart") + + expect(listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-components/specs/utils/token-and-misc.spec.tsx b/packages/react-components/specs/utils/token-and-misc.spec.tsx new file mode 100644 index 00000000..68eb40a1 --- /dev/null +++ b/packages/react-components/specs/utils/token-and-misc.spec.tsx @@ -0,0 +1,108 @@ +import { render, screen } from "@testing-library/react" +import { useContext } from "react" +import { describe, expect, it } from "vitest" +import { ExternalFunction } from "#components/ExternalFunction" +import { SubmitButton } from "#components/SubmitButton" +import ExternalFunctionContext from "#context/ExternalFunctionContext" +import { getCustomerIdByToken } from "#utils/getCustomerIdByToken" +import { sortAscIcon, sortDescIcon } from "#utils/icons" +import { isGuestToken } from "#utils/isGuestToken" + +/** Builds an unsigned JWT whose payload decodes to `payload`. */ +function makeToken(payload: Record): string { + const encode = (obj: Record) => + Buffer.from(JSON.stringify(obj)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") + + return `${encode({ alg: "RS512", typ: "JWT" })}.${encode(payload)}.signature` +} + +describe("getCustomerIdByToken", () => { + it("extracts the owner id from a customer token", () => { + const token = makeToken({ owner: { id: "cust_1", type: "Customer" } }) + + expect(getCustomerIdByToken(token)).toBe("cust_1") + }) + + it("returns undefined for a token with no owner", () => { + expect(getCustomerIdByToken(makeToken({}))).toBeUndefined() + }) +}) + +describe("isGuestToken", () => { + it("is true when the token carries no owner", () => { + expect(isGuestToken(makeToken({}))).toBe(true) + }) + + it("is false when the token has an owner", () => { + expect(isGuestToken(makeToken({ owner: { id: "cust_1", type: "Customer" } }))).toBe(false) + }) +}) + +describe("icons", () => { + it("renders the sort icons as svg elements", () => { + const { container } = render( +
+ {sortAscIcon} + {sortDescIcon} +
+ ) + + expect(container.querySelectorAll("svg")).toHaveLength(2) + }) +}) + +describe("SubmitButton", () => { + it("renders a submit button with the default label", () => { + render() + + const button = screen.getByRole("button") + expect(button.textContent).toBe("Submit") + expect(button.getAttribute("type")).toBe("submit") + }) + + it("renders a string label", () => { + render() + + expect(screen.getByRole("button").textContent).toBe("Place order") + }) + + it("calls a function label", () => { + render( Go} />) + + expect(screen.getByTestId("node").textContent).toBe("Go") + }) + + it("hands props to a children function", () => { + render( + + {({ label }) => } + + ) + + expect(screen.getByTestId("custom").textContent).toBe("Place order!") + }) +}) + +describe("ExternalFunction", () => { + it("publishes the url and caller on context", () => { + let captured: { url?: string; callExternalFunction?: unknown } = {} + + function Consumer() { + captured = useContext(ExternalFunctionContext) + return null + } + + render( + + + + ) + + expect(captured.url).toBe("https://fn.test/hook") + expect(typeof captured.callExternalFunction).toBe("function") + }) +}) diff --git a/packages/react-components/src/components/addresses/AddressStateSelector.tsx b/packages/react-components/src/components/addresses/AddressStateSelector.tsx index 3302cac2..983829e5 100644 --- a/packages/react-components/src/components/addresses/AddressStateSelector.tsx +++ b/packages/react-components/src/components/addresses/AddressStateSelector.tsx @@ -13,6 +13,7 @@ import BillingAddressFormContext from "#context/BillingAddressFormContext" import CustomerAddressFormContext from "#context/CustomerAddressFormContext" import ShippingAddressFormContext from "#context/ShippingAddressFormContext" import type { AddressStateSelectName, BaseSelectComponentProps, Option } from "#typings" +import { singleFormValue } from "#utils/addressFormUtils" import { getStateOfCountry, isValidState, type States } from "#utils/countryStateCity" import { isEmpty } from "#utils/isEmpty" @@ -105,11 +106,15 @@ export function AddressStateSelector(props: Props): JSX.Element { useEffect(() => { const billingCountryValue = billingAddress?.values?.[BILLING_COUNTRY_KEY] const billingCountryCode = - typeof billingCountryValue === "string" ? billingCountryValue : billingCountryValue?.value + typeof billingCountryValue === "string" + ? billingCountryValue + : singleFormValue(billingCountryValue?.value) if (billingCountryCode && billingCountryCode !== countryCode) setCountryCode(billingCountryCode) const shippingCountryValue = shippingAddress?.values?.[SHIPPING_COUNTRY_KEY] const shippingCountryCode = - typeof shippingCountryValue === "string" ? shippingCountryValue : shippingCountryValue?.value + typeof shippingCountryValue === "string" + ? shippingCountryValue + : singleFormValue(shippingCountryValue?.value) if (shippingCountryCode && shippingCountryCode !== countryCode) setCountryCode(shippingCountryCode) // True when this is the first time a country is detected (was empty before). diff --git a/packages/react-components/src/components/orders/HostedCart.tsx b/packages/react-components/src/components/orders/HostedCart.tsx index 10d8a46f..35aac8f2 100644 --- a/packages/react-components/src/components/orders/HostedCart.tsx +++ b/packages/react-components/src/components/orders/HostedCart.tsx @@ -1,5 +1,5 @@ import type { Order } from "@commercelayer/sdk" -import { iframeResizer } from "iframe-resizer" +import iframeResizer from "@iframe-resizer/parent" import { type CSSProperties, type JSX, diff --git a/packages/react-components/src/components/payment_gateways/PaymentGateway.tsx b/packages/react-components/src/components/payment_gateways/PaymentGateway.tsx index 3eb56117..e5606529 100644 --- a/packages/react-components/src/components/payment_gateways/PaymentGateway.tsx +++ b/packages/react-components/src/components/payment_gateways/PaymentGateway.tsx @@ -70,6 +70,16 @@ export function PaymentGateway({ const paymentResource = readonly ? currentPaymentMethodType : (payment?.payment_source_type as PaymentResource) + // Returning the loader instead of the gateway (see the render below) swaps the gateway out + // rather than overlaying the loader, so every flip of `loading` unmounts it — and with it a + // mounted Adyen Drop-in, which loses the shopper's selection and re-initializes on the way + // back. + // + // While the order is partially authorized the shopper is mid-payment in that very gateway, + // so it must stay mounted. `mismatched_amounts` is also expected in that window (we + // authorized only the gift-card balance against the full total), and "healing" it by + // recreating the payment source would throw the authorization away. + const isPartiallyAuthorized = order?.payment_status === "partially_authorized" // Non-reactive reconcile pass. It reads the *latest* `order`, `config`, `paymentSource`, // etc. on every invocation, so those objects are deliberately absent from the driving @@ -148,13 +158,13 @@ export function PaymentGateway({ setPaymentSources() } // @ts-expect-error no type - if (paymentSource?.mismatched_amounts && show) { + if (paymentSource?.mismatched_amounts && show && !isPartiallyAuthorized) { setPaymentSources() } if (order?.payment_source?.id != null) { setLoading(false) } - if (!paymentSource) { + if (!paymentSource && !isPartiallyAuthorized) { setLoading(true) } } @@ -182,6 +192,9 @@ export function PaymentGateway({ order?.payment_method?.id, order?.payment_method?.payment_source_type, order?.status, + // Read by the recreate guard and the loader branch, so it has to re-fire the effect — + // otherwise the pass keeps deciding from a stale partial-authorization state. + order?.payment_status, order?.payment_source?.id, // @ts-expect-error no type order?.payment_source?.mismatched_amounts, @@ -199,12 +212,15 @@ export function PaymentGateway({ ]) useEffect(() => { - if (status === "placing") setLoading(true) + // Not while partially authorized: a place-order attempt on such an order is refused + // anyway (see ), so raising the loader here only unmounts the gateway + // the shopper still needs in order to pay the remainder. + if (status === "placing" && !isPartiallyAuthorized) setLoading(true) if (status === "standby") setLoading(false) if (order?.status === "placed") setLoading(false) // No cleanup: setLoading(true) in cleanup + loading in deps caused an infinite // toggle loop (setLoading(false) → dep change → cleanup setLoading(true) → repeat). - }, [status, order?.status]) + }, [status, order?.status, isPartiallyAuthorized]) const gatewayConfig = { readonly, @@ -220,7 +236,25 @@ export function PaymentGateway({ ...p, } if (currentPaymentMethodType !== paymentResource) return null - if (loading) return loaderComponent + // Swapping the gateway out for the loader unmounts it. For a stateless gateway that costs + // nothing; the Adyen Drop-in, though, owns imperative state — the shopper's selected method + // and typed-in details — and unmounting it destroys that and forces a full re-initialization + // (fresh AdyenCheckout(), new Dropin().mount(), translations and analytics again). + // + // Guarding individual `loading` flips is not enough: `payment_response.status` and + // `payment_status` are populated by two different API calls, so there is a window where a + // flip looks legitimate. Keeping the Drop-in mounted removes the whole class of problem — + // and it manages its own loading UI anyway. + // The Adyen Drop-in owns imperative state — the shopper's selected method and typed-in + // details — so unmounting it destroys that and forces a full re-initialization (fresh + // AdyenCheckout(), new Dropin().mount(), translations and analytics again). For a stateless + // gateway the swap costs nothing, so it is kept. + // + // Guarding individual `loading` flips is not enough here: `payment_response.status` and + // `payment_status` are populated by two different API calls, so there is a window where a + // flip looks legitimate. Keeping the Drop-in mounted removes the whole class of problem, and + // it manages its own loading UI anyway. + if (loading && paymentResource !== "adyen_payments") return loaderComponent switch (paymentResource) { case "adyen_payments": return {children} diff --git a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx index 78fd6dca..2bba11f7 100644 --- a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx +++ b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx @@ -92,6 +92,8 @@ export function PaymentMethod({ const [paymentSelected, setPaymentSelected] = useState("") const [paymentSourceCreated, setPaymentSourceCreated] = useState(false) const loadingResourceRef = useRef(false) + /** Latches once the methods have rendered, so the loader can never unmount them again. */ + const hasRenderedMethodsRef = useRef(false) // Detect standalone mode: no parent has set _isProvided. const parentCtx = useContext(PaymentMethodContext) @@ -114,6 +116,12 @@ export function PaymentMethod({ const { order } = useContext(OrderContext) const { getCustomerPaymentSources } = useContext(CustomerContext) const { status } = useContext(PlaceOrderContext) + /** + * A partially-authorized order is mid-payment: part of the total is covered (an Adyen gift + * card, say) and the shopper still has to pay the remainder with another method, in the + * gateway that is already on screen. Raising the loader in that window unmounts it. + */ + const isPartiallyAuthorized = order?.payment_status === "partially_authorized" useEffect(() => { if (paymentMethods != null && !isEmpty(paymentMethods) && expressPayments) { const [paymentMethod] = getAvailableExpressPayments(paymentMethods) @@ -286,7 +294,16 @@ export function PaymentMethod({ // @ts-expect-error no type order?.payment_source?.payment_response?.status // If showLoader is undefined, we don't change the loading - if (showLoader && status) { + // + // `content` swaps the whole subtree for the loader rather than overlaying it, so raising + // `loading` here unmounts and with it any mounted Adyen Drop-in. A gift + // card authorization is exactly what populates `payment_response.status`, so without the + // partial-authorization guard this fires on the very update the shopper is mid-way + // through and reloads the Drop-in — repeatedly, as the order settles. + // + // A partially-authorized order is still mid-payment: the shopper has to cover the + // remainder in that same Drop-in, so the subtree has to stay mounted. + if (showLoader && status && !isPartiallyAuthorized) { if (status.toLowerCase() === "declined") { setLoading(false) } else { @@ -296,7 +313,7 @@ export function PaymentMethod({ setLoading(false) } // @ts-expect-error no type - }, [showLoader, order?.payment_source?.payment_response?.status]) + }, [showLoader, order?.payment_source?.payment_response?.status, isPartiallyAuthorized]) const sortedPaymentMethods = paymentMethods != null && sortBy != null ? sortPaymentMethods(paymentMethods, sortBy) @@ -359,7 +376,21 @@ export function PaymentMethod({ ) }) - const content = !loading ? <>{components} : getLoaderComponent(loader) + // Once the payment methods have rendered, never swap them back out for the loader. + // + // `content` replaces the whole subtree rather than overlaying the loader, so any later flip + // of `loading` unmounts every gateway below — including a mounted Adyen Drop-in, which owns + // the shopper's selected method and typed-in details and has to fully re-initialize on the + // way back. Guarding the individual flips cannot close this: `payment_response.status` and + // `payment_status` are populated by two different API calls, so there is a window where a + // flip looks legitimate. + // + // This makes `showLoader` mean "while first fetching the payment methods", which is what it + // documents ("Show loader while fetching payment methods"). Re-entering the loading state + // after that is the glitch, not a feature. + if (!loading) hasRenderedMethodsRef.current = true + const content = + !loading || hasRenderedMethodsRef.current ? <>{components} : getLoaderComponent(loader) // In standalone mode provide the context so that child components // (PaymentSource, PaymentGateway, etc.) can read payment state without diff --git a/packages/react-components/src/components/payment_source/AdyenPayment.tsx b/packages/react-components/src/components/payment_source/AdyenPayment.tsx index 9163ad8c..63aabf7d 100644 --- a/packages/react-components/src/components/payment_source/AdyenPayment.tsx +++ b/packages/react-components/src/components/payment_source/AdyenPayment.tsx @@ -8,6 +8,7 @@ import { type CoreConfiguration, Dropin, type DropinConfiguration, + type ICore, type OnChangeData, type PayPalConfiguration, type SubmitData, @@ -129,6 +130,11 @@ export function AdyenPayment({ const [loadAdyen, setLoadAdyen] = useState(false) const [checkout, setCheckout] = useState | undefined>() const [giftcardError, setGiftcardError] = useState(null) + // Set when the API rejects a call because Adyen's `order_data` expired. A new payment + // source alone cannot stand in for this: also creates one, with a new + // id, whenever the amount is mismatched, and rebuilding the Drop-in there is the reload + // loop that was fixed earlier. Only an expiry invalidates the session itself. + const [sessionExpiredAt, setSessionExpiredAt] = useState(null) const { setPaymentSource, paymentSource, @@ -143,6 +149,52 @@ export function AdyenPayment({ const { customers } = useContext(CustomerContext) const ref = useRef(null) const dropinRef = useRef(null) + // The Core instance, kept alongside the Drop-in: refreshing the amount after a partial + // authorization goes through Core, not the Drop-in. See the `onSubmit` handler below. + const checkoutRef = useRef(null) + // Latches the partial-authorization refresh: refreshing the Drop-in once, when the order + // becomes partially authorized, is intended — doing it again for the same authorization is + // the glitch. Keyed by payment source id so a genuinely new source can refresh again. + const refreshedForSourceRef = useRef(null) + // Which payment source the live Drop-in was initialized from. Adyen's session, including + // the `order_data` that expires, is baked into the instance at creation, so a replacement + // source leaves the instance on screen talking to a session the API already rejects. + const initializedForSourceRef = useRef(null) + // The Drop-in's `onSubmit` is installed once, so it closes over the payment source from + // the render that built it. recreates the source whenever the order has + // more than one payment method, and when that lands between the build and the shopper's + // click, the submit authorizes against a source the order no longer points at: the gift + // card is redeemed at Adyen, `gift_card_amount_cents` stays 0, and no amount ever shows. + const paymentSourceRef = useRef(paymentSource) + // An effect rather than a render-phase write, so StrictMode's discarded double render + // cannot leave a stale value behind. + useEffect(() => { + paymentSourceRef.current = paymentSource + }, [paymentSource]) + // A replacement payment source is created empty and filled a moment later, so the id + // alone is not enough to rebuild on: doing that yields a Drop-in with no payment + // methods. Counting them gives the effect below something to wait for. + const availablePaymentMethodsCount: number = + // @ts-expect-error no type + paymentSource?.payment_methods?.paymentMethods?.length ?? 0 + + // Tear the Adyen instance down on real unmount only, and clear the refs so a remounted + // component can initialize a fresh one (the init guard below is `!dropinRef.current`, so a + // leftover reference would leave the component wired to a destroyed Drop-in forever). + // + // Deliberately its own mount-scoped effect: the main effect below re-runs whenever the + // place-order `status` changes, and destroying the Drop-in on those passes would throw the + // shopper's selection away mid-checkout. + useEffect(() => { + return () => { + // `remove()` rather than `unmount()`: Adyen documents it as the "destroy" cleanup — it + // unmounts the element *and* drops it from `core.components`, so Core stops holding a + // reference to a dead element (which `triggerAmountUpdate()` would otherwise iterate). + dropinRef.current?.remove() + dropinRef.current = null + checkoutRef.current = null + } + }, []) const handleSubmit = async (e: FormEvent): Promise => { const savePaymentSourceToCustomerWallet: string = // @ts-expect-error no type @@ -153,7 +205,25 @@ export function AdyenPayment({ savePaymentSourceToCustomerWallet ) if (dropinRef.current) { - dropinRef.current.submit() + // `Dropin.submit()` throws synchronously when it has no `activePaymentMethod` — e.g. + // the shopper has not picked a method, or the Drop-in was re-rendered and lost the + // selection while `ref.current.onsubmit` stayed patched from an earlier `onChange`. + // Because this function is async the throw became a rejected promise that + // awaited without a catch, surfacing as an unhandledRejection that + // takes the page (and the e2e run) down instead of telling the shopper anything. + try { + dropinRef.current.submit() + } catch (error) { + setPaymentMethodErrors([ + { + code: "VALIDATION_ERROR", + resource: "payment_methods", + field: currentPaymentMethodType, + message: error instanceof Error ? error.message : String(error), + }, + ]) + return false + } } return false } @@ -180,10 +250,11 @@ export function AdyenPayment({ _details: 1, } try { + const latestPaymentSource = paymentSourceRef.current ?? paymentSource const pSource = - paymentSource && + latestPaymentSource && (await setPaymentSource({ - paymentSourceId: paymentSource.id, + paymentSourceId: latestPaymentSource.id, paymentResource: "adyen_payments", attributes, })) @@ -233,13 +304,18 @@ export function AdyenPayment({ paymentMethodType?: string message?: string paymentStatus?: Order["payment_status"] + /** Still to be covered by another payment method, in Adyen's `{ currency, value }` shape. */ + remainingAmount?: { currency: string; value: number } } > => { const url = cleanUrlBy() const { type: currentPaymentMethodType } = state.data.paymentMethod const shopperIp = await getPublicIP() + // Captured once for the whole submit rather than re-read per call: the expired-session + // path below deliberately reuses the id the reducer has just destroyed. + const currentPaymentSourceId = paymentSourceRef.current?.id ?? paymentSource?.id const control = await setPaymentSource({ - paymentSourceId: paymentSource?.id, + paymentSourceId: currentPaymentSourceId, paymentResource: "adyen_payments", }) // @ts-expect-error no type @@ -282,7 +358,7 @@ export function AdyenPayment({ delete attributes.payment_request_data.paymentMethod try { await setPaymentSource({ - paymentSourceId: paymentSource?.id, + paymentSourceId: currentPaymentSourceId, paymentResource: "adyen_payments", attributes, }) @@ -296,14 +372,36 @@ export function AdyenPayment({ if (currentPaymentMethodType === "giftcard") { // Request balance check if the gift card can cover the total amount const giftCardBalanceCheck = (await setPaymentSource({ - paymentSourceId: paymentSource?.id, + paymentSourceId: currentPaymentSourceId, paymentResource: "adyen_payments", attributes: { _balance: true, }, - })) as AdyenPaymentType - const currentBalance = giftCardBalanceCheck?.balance ?? 0 + })) as AdyenPaymentType | undefined const totalAmount = order?.total_amount_with_taxes_cents ?? 0 + // A missing response means the request itself failed, not that the card is empty. + // The usual cause is Adyen's `order_data` having expired, which makes the reducer + // tear the payment source down and the app build a new one. Folding that into the + // zero-balance branch below told the shopper to find a different gift card over + // what is really a stale session, and hid the retry they actually need. + if (giftCardBalanceCheck == null) { + setSessionExpiredAt(Date.now()) + const message = + "The payment session expired before the gift card could be redeemed. Please try again." + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message, + }, + ]) + return { + resultCode: "Refused", + message, + } + } + const currentBalance = giftCardBalanceCheck.balance ?? 0 if (currentBalance === 0) { const message = "The gift card has no balance. Please use a different one." setPaymentMethodErrors([ @@ -360,15 +458,40 @@ export function AdyenPayment({ message, } } + // What the shopper still has to cover with another method, in Adyen's + // `{ currency, value }` shape. + // + // Do NOT derive this from `gift_card_amount_cents`: that field is the sum of the + // Commerce Layer `gift_card` resources applied to the order, and an Adyen gift card + // authorized through `_authorization_amount_cents` never creates one — it stays 0, + // so the subtraction would hand the Drop-in back the full total. + // + // Adyen's own `remainingAmount` is authoritative when the account uses the + // partial-payments order flow (it already nets off every card authorized so far); + // otherwise fall back to what we just authorized ourselves: `currentBalance`, the + // amount sent as `_authorization_amount_cents` above. + const adyenRemainingAmount = + // @ts-expect-error no type + orderUpdated?.payment_source?.payment_response?.order?.remainingAmount + const currency = orderUpdated?.currency_code ?? order?.currency_code + const remainingValue = + typeof adyenRemainingAmount?.value === "number" + ? adyenRemainingAmount.value + : Math.max(totalAmount - currentBalance, 0) return { resultCode: "Authorised", paymentMethodType: currentPaymentMethodType, action, paymentStatus, + // Adyen validates the amount and silently cancels the update on an empty + // currency, so only report one when the currency is actually known. + ...(currency != null && remainingValue > 0 + ? { remainingAmount: { currency, value: remainingValue } } + : {}), } } const res = await setPaymentSource({ - paymentSourceId: paymentSource?.id, + paymentSourceId: currentPaymentSourceId, paymentResource: "adyen_payments", attributes: { _authorize: 1, @@ -532,7 +655,10 @@ export function AdyenPayment({ }, onSubmit: (state, element, actions) => { const handleSubmit = async (): Promise => { - const { resultCode, action, message, paymentStatus } = await onSubmit(state, element) + const { resultCode, action, message, paymentStatus, remainingAmount } = await onSubmit( + state, + element + ) if (["Cancelled", "Refused"].includes(resultCode)) { actions.reject() if (message) { @@ -544,8 +670,37 @@ export function AdyenPayment({ actions.resolve({ resultCode, }) - if (paymentStatus === "partially_authorized") { - dropinRef.current?.mount("#adyen-dropin") + const refreshKey = paymentSource?.id ?? "unknown" + if ( + paymentStatus === "partially_authorized" && + remainingAmount != null && + refreshedForSourceRef.current !== refreshKey + ) { + refreshedForSourceRef.current = refreshKey + // Refresh the Drop-in for the reduced amount. `shouldReinitializeCheckout: true` + // makes Core `setOptions(amount)`, re-`initialize()`, then `update()` every + // mounted component — and `BaseElement.update()` is `state = {}` plus + // `unmount().mount(this._node)`, i.e. a real refresh in place, with the payment + // method list consistent with what is left to pay. + // + // This replaces `dropinRef.current?.mount("#adyen-dropin")`, which re-rendered + // the Drop-in with the *old* amount: same lost selection, none of the benefit. + // + // Latched above, so it happens once per authorization and not on every pass. + checkoutRef.current?.update( + { amount: remainingAmount }, + { shouldReinitializeCheckout: true } + ) + // The refresh resets `activePaymentMethod`, so the form is genuinely not + // submittable until the shopper picks a method again — at which point + // `handleChange` re-patches `ref.current.onsubmit` and re-arms the ref. Dropping + // it here keeps honest; leaving it patched is what let it + // call `Dropin.submit()` on an empty Drop-in and throw "No active payment + // method.". + if (ref.current != null) { + ref.current.onsubmit = null + } + setPaymentRef({ ref: { current: null } }) } setGiftcardError(null) } @@ -554,9 +709,34 @@ export function AdyenPayment({ }, } satisfies CoreConfiguration if (!ref && clientKey) setCustomerOrderParam("_save_payment_source_to_customer_wallet", "false") - if (clientKey && !loadAdyen && window && !checkout) { + // An expired `order_data` makes the reducer destroy the payment source and a fresh one + // takes its place. The Drop-in bakes Adyen's session in at creation, so the instance on + // screen is still talking to the session the API now rejects, and the shopper's retry + // can never succeed. `update({ shouldReinitializeCheckout: true })`, used below for the + // partial-authorization refresh, re-initializes Core with that same dead session, so a + // genuine rebuild is the only way back. + // + // `checkout` is set once at initialization and never cleared, which is what latches the + // branch below shut for the rest of the component's life; hence the explicit override + // rather than another condition on the state. + const currentSourceId = paymentSource?.id + const sessionReplaced = + sessionExpiredAt != null && + dropinRef.current != null && + currentSourceId != null && + availablePaymentMethodsCount > 0 && + initializedForSourceRef.current !== currentSourceId + if (sessionReplaced) { + dropinRef.current?.remove() + dropinRef.current = null + checkoutRef.current = null + refreshedForSourceRef.current = null + setSessionExpiredAt(null) + } + if (clientKey && window && (sessionReplaced || (!loadAdyen && !checkout))) { const initializeAdyen = async (): Promise => { const checkout = await AdyenCheckout(options) + checkoutRef.current = checkout const dropin = new Dropin(checkout, { disableFinalAnimation: true, showRemovePaymentMethodButton: showStoredPaymentMethods, @@ -645,6 +825,7 @@ export function AdyenPayment({ }).mount("#adyen-dropin") if (dropin && checkout) { dropinRef.current = dropin + initializedForSourceRef.current = paymentSource?.id ?? null setCheckout(dropin) setLoadAdyen(true) } @@ -658,7 +839,15 @@ export function AdyenPayment({ setPaymentRef({ ref: { current: null } }) setLoadAdyen(false) } - }, [clientKey, ref != null, status, setPaymentMethodErrors != null]) + }, [ + clientKey, + ref != null, + status, + setPaymentMethodErrors != null, + paymentSource?.id, + availablePaymentMethodsCount, + sessionExpiredAt, + ]) return !clientKey && !loadAdyen && !checkout ? null : (
= { [Property in keyof Type]: { label?: string @@ -208,72 +216,97 @@ export function BraintreePayment({ } return false } + // `handleSubmitForm` is rebuilt on every render, so keeping it in the effect's + // dependencies re-ran the effect every render — and the cleanup's setState calls + // triggered the next render, looping forever. Hold the latest closure in a ref and + // read it at submit time instead. + const handleSubmitFormRef = useRef(handleSubmitForm) + handleSubmitFormRef.current = handleSubmitForm + // Guard initialisation with a ref rather than the `loadBraintree` state: that state + // is reset by this effect's own cleanup, so depending on it made the effect tear + // down and re-create the Braintree client in a cycle. + const initializedRef = useRef(false) useEffect(() => { if (!ref && authorization) setCustomerOrderParam("_save_payment_source_to_customer_wallet", "false") - if (authorization && !loadBraintree && !isEmpty(window)) { - const braintreeClient = require("braintree-web/client") - const hostedFields = require("braintree-web/hosted-fields") - const threeDSecure = require("braintree-web/three-d-secure") - braintreeClient.create( - { - authorization, - challengeRequested: config?.challengeRequested ?? true, - }, - (clientErr: any, clientInstance: any) => { - if (clientErr) { - console.error(clientErr) - return - } - hostedFields.create( - { - client: clientInstance, - fields: fields as HostedFieldFieldOptions, - styles, - }, - (hostedFieldsErr: any, hostedFieldsInstance: HostedFieldsHostedFieldsFieldName) => { - if (hostedFieldsErr) { - console.error(hostedFieldsErr) - return - } - setLoadBraintree(true) - threeDSecure.create( - { - authorization, - version: 2, - }, - (threeDSecureErr: any, threeDSInstance: ThreeDSecure) => { - if (threeDSecureErr) { - // Handle error in 3D Secure component creation - console.error("3DSecure error", threeDSecureErr) - setPaymentMethodErrors([ - { - code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", - resource: "payment_methods", - field: currentPaymentMethodType, - message: threeDSecureErr.message as string, - }, - ]) - } - if (ref.current) { - ref.current.onsubmit = async (paymentSource: any) => { - return await handleSubmitForm({ - event: ref.current as any, - hostedFieldsInstance, - threeDSInstance, - paymentSource, - }) + let cancelled = false + if (authorization && !initializedRef.current && !isEmpty(window)) { + initializedRef.current = true + void (async () => { + // Load the SDK through dynamic import rather than `require`: braintree-web is + // CommonJS, and a bare require() survived into the browser bundle, where it + // resolved to a shim that throws as soon as mounts. Dynamic + // import is bundler-safe and keeps the SDK out of the main chunk. + const [braintreeClient, hostedFields, threeDSecure] = ( + await Promise.all([ + import("braintree-web/client"), + import("braintree-web/hosted-fields"), + import("braintree-web/three-d-secure"), + ]) + ).map(interopDefault) + if (cancelled) return + braintreeClient.create( + { + authorization, + challengeRequested: config?.challengeRequested ?? true, + }, + (clientErr: any, clientInstance: any) => { + if (clientErr) { + console.error(clientErr) + return + } + hostedFields.create( + { + client: clientInstance, + fields: fields as HostedFieldFieldOptions, + styles, + }, + (hostedFieldsErr: any, hostedFieldsInstance: HostedFieldsHostedFieldsFieldName) => { + if (hostedFieldsErr) { + console.error(hostedFieldsErr) + return + } + setLoadBraintree(true) + threeDSecure.create( + { + authorization, + version: 2, + }, + (threeDSecureErr: any, threeDSInstance: ThreeDSecure) => { + if (threeDSecureErr) { + // Handle error in 3D Secure component creation + console.error("3DSecure error", threeDSecureErr) + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message: threeDSecureErr.message as string, + }, + ]) + } + if (ref.current) { + ref.current.onsubmit = async (paymentSource: any) => { + return await handleSubmitFormRef.current({ + event: ref.current as any, + hostedFieldsInstance, + threeDSInstance, + paymentSource, + }) + } + setPaymentRef({ ref }) } - setPaymentRef({ ref }) } - } - ) - } - ) - } - ) + ) + } + ) + } + ) + })() } return () => { + cancelled = true + initializedRef.current = false setPaymentRef({ ref: { current: null } }) setLoadBraintree(false) } @@ -281,11 +314,8 @@ export function BraintreePayment({ authorization, setPaymentMethodErrors, styles, - loadBraintree, currentPaymentMethodType, setPaymentRef, - // biome-ignore lint/correctness/useExhaustiveDependencies: handleSubmitForm is recreated each render; its deps are already tracked - handleSubmitForm, fields, config?.challengeRequested, ]) diff --git a/packages/react-components/src/components/payment_source/CheckoutComPayment.tsx b/packages/react-components/src/components/payment_source/CheckoutComPayment.tsx index a16abed1..6e0702f8 100644 --- a/packages/react-components/src/components/payment_source/CheckoutComPayment.tsx +++ b/packages/react-components/src/components/payment_source/CheckoutComPayment.tsx @@ -130,6 +130,26 @@ export function CheckoutComPayment({ publicKey, options, ...p }: Props): JSX.Ele const { order } = useContext(OrderContext) const { setPlaceOrderStatus } = useContext(PlaceOrderContext) const { containerClassName, templateCustomerSaveToWallet, show, ...divProps } = p + // Everything below is read at mount time only. Keeping any of it in the effect's + // dependencies re-ran the effect on unrelated re-renders and re-created the Flow + // component, which wiped whatever the shopper had typed into the card field: + // `onChange` calls `setPaymentRef` as soon as the card is valid, that context update + // re-renders the consumer, the consumer rebuilds its `options` object literal, and the + // fresh `options.appearance` identity looked like a real dependency change. + const optionsRef = useRef(options) + optionsRef.current = options + const setPaymentRefRef = useRef(setPaymentRef) + setPaymentRefRef.current = setPaymentRef + const setPaymentSourceRef = useRef(setPaymentSource) + setPaymentSourceRef.current = setPaymentSource + const setPlaceOrderStatusRef = useRef(setPlaceOrderStatus) + setPlaceOrderStatusRef.current = setPlaceOrderStatus + // Guards against mounting the Flow twice for the same payment source. + const mountedForRef = useRef(null) + // Depending on the `order.payment_source` object rather than its id remounts the Flow + // on every order refetch and wipes the shopper's card input. The id is the only part + // that should re-trigger; everything else is read through the refs above. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — see above useEffect(() => { const ps = order?.payment_source if (loaded && window && ps && accessToken) { @@ -145,7 +165,7 @@ export function CheckoutComPayment({ publicKey, options, ...p }: Props): JSX.Ele // @ts-expect-error no type const checkout = await window.CheckoutWebComponents({ appearance: { - ...options?.appearance, + ...optionsRef.current?.appearance, }, showPayButton: false, publicKey, @@ -172,7 +192,7 @@ export function CheckoutComPayment({ publicKey, options, ...p }: Props): JSX.Ele ) const { data } = await component.tokenize() const token = data?.token - const paymentSource = await setPaymentSource({ + const paymentSource = await setPaymentSourceRef.current?.({ paymentSourceId: ps.id, paymentResource: "checkout_com_payments", attributes: { @@ -195,7 +215,7 @@ export function CheckoutComPayment({ publicKey, options, ...p }: Props): JSX.Ele } return false } - setPaymentRef?.({ ref }) + setPaymentRefRef.current?.({ ref }) } } }, @@ -204,7 +224,7 @@ export function CheckoutComPayment({ publicKey, options, ...p }: Props): JSX.Ele }, onPaymentCompleted: async (_component, paymentResponse) => { if (paymentResponse.status.toLowerCase() === "approved") { - await setPaymentSource({ + await setPaymentSourceRef.current?.({ paymentSourceId: ps.id, paymentResource: "checkout_com_payments", attributes: { @@ -212,7 +232,7 @@ export function CheckoutComPayment({ publicKey, options, ...p }: Props): JSX.Ele _authorize: true, }, }) - setPlaceOrderStatus?.({ + setPlaceOrderStatusRef.current?.({ status: "placing", }) } @@ -221,20 +241,18 @@ export function CheckoutComPayment({ publicKey, options, ...p }: Props): JSX.Ele const flowComponent = checkout.create("flow") flowComponent.mount(document.getElementById("flow-container")) } - loadFlow() + // Belt and braces: even if the effect were re-entered for the same payment + // source, don't build and mount a second Flow over the first. + if (mountedForRef.current !== ps.id) { + mountedForRef.current = ps.id + loadFlow() + } } } - }, [ - loaded, - order?.payment_source?.id, - accessToken, - setPaymentRef, - options?.appearance, - setPaymentSource, - setPlaceOrderStatus, - order?.payment_source, - order?.language_code, - ]) + // Only primitives here. Object identities (options.appearance, the payment_source + // object, the context setters) change on unrelated re-renders and would remount the + // Flow mid-typing; they are read through refs above instead. + }, [loaded, order?.payment_source?.id, accessToken, order?.language_code]) return loaded && show ? (
diff --git a/packages/react-components/src/components/shipments/Shipment.tsx b/packages/react-components/src/components/shipments/Shipment.tsx index a2e0d36b..5225d059 100644 --- a/packages/react-components/src/components/shipments/Shipment.tsx +++ b/packages/react-components/src/components/shipments/Shipment.tsx @@ -85,9 +85,12 @@ export function Shipment({ if (autoSelectSingleShippingMethod) { const autoSelect = async (): Promise => { for (const shipment of shipments) { - const isSingle = shipment?.available_shipping_methods?.length === 1 + // Normalise once: `isSingle` below already proves the array is present, so + // a second fallback at the destructuring site would be unreachable. + const availableShippingMethods = shipment?.available_shipping_methods ?? [] + const isSingle = availableShippingMethods.length === 1 if (!shipment?.shipping_method && isSingle) { - const [shippingMethod] = shipment?.available_shipping_methods || [] + const [shippingMethod] = availableShippingMethods if (shippingMethod && setShippingMethodRef.current != null) { const { success, order } = await setShippingMethodRef.current( shipment.id, diff --git a/packages/react-components/src/components/shipments/Shipments.tsx b/packages/react-components/src/components/shipments/Shipments.tsx index 99d5b45e..4ee99f0e 100644 --- a/packages/react-components/src/components/shipments/Shipments.tsx +++ b/packages/react-components/src/components/shipments/Shipments.tsx @@ -1,6 +1,6 @@ import { useShipments } from "@commercelayer/react-hooks-components" import type { Order } from "@commercelayer/sdk" -import { type JSX, useCallback, useContext, useEffect, useMemo, useState } from "react" +import { type JSX, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react" import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" import ShipmentContext from "#context/ShipmentContext" @@ -27,11 +27,35 @@ export function Shipments({ children, loader = "Loading..." }: Props): JSX.Eleme shipments, deliveryLeadTimes, isLoading, + reload, setShippingMethod: hookSetShippingMethod, } = useShipments({ accessToken, orderId }) const [errors, setErrors] = useState([]) + // The shipments cache is keyed on (accessToken, orderId) alone, so it never + // revalidates on its own. The API, though, re-evaluates shipments whenever the + // order changes: applying a coupon clears `shipment.shipping_method` server-side, + // because shipping method availability depends on the order totals. Left stale, + // the cached shipment keeps a shipping method the order no longer has, so + // `` stays checked on it — and re-clicking a checked + // radio fires no change event, leaving the user unable to re-select. Refetch + // whenever the order moves on. + const syncedOrderUpdatedAt = useRef(undefined) + + useEffect(() => { + const updatedAt = order?.updated_at + if (updatedAt == null) return + // First order we see: the initial shipments fetch is already in step with it. + if (syncedOrderUpdatedAt.current == null) { + syncedOrderUpdatedAt.current = updatedAt + return + } + if (syncedOrderUpdatedAt.current === updatedAt) return + syncedOrderUpdatedAt.current = updatedAt + void reload() + }, [order?.updated_at, reload]) + useEffect(() => { const nextErrors: BaseError[] = [] @@ -99,6 +123,12 @@ export function Shipments({ children, loader = "Loading..." }: Props): JSX.Eleme await hookSetShippingMethod(shipmentId, shippingMethodId) if (getOrder != null && orderId != null) { const currentOrder = await getOrder(orderId) + // `hookSetShippingMethod` has already revalidated the shipments cache, + // so this order revision is in step — stamp it so the effect above + // doesn't refetch shipments a second time for our own update. + if (currentOrder?.updated_at != null) { + syncedOrderUpdatedAt.current = currentOrder.updated_at + } return { success: true, order: currentOrder } } return { success: true } diff --git a/packages/react-components/src/hooks/useAddressFormFields.ts b/packages/react-components/src/hooks/useAddressFormFields.ts index 6b7a53a5..a24225ec 100644 --- a/packages/react-components/src/hooks/useAddressFormFields.ts +++ b/packages/react-components/src/hooks/useAddressFormFields.ts @@ -14,7 +14,12 @@ import type { } from "#reducers/OrderReducer" import type { TCustomerAddress } from "#typings/customers" import type { BaseError, CodeErrorType } from "#typings/errors" -import { type FormErrors, type FormValue, getFormElement } from "#utils/addressFormUtils" +import { + type FormErrors, + type FormValue, + getFormElement, + singleFormValue, +} from "#utils/addressFormUtils" interface UseAddressFormFieldsParams { resource: AddressResource @@ -61,7 +66,10 @@ export function useAddressFormFields({ const setFormRef = useCallback( (node: HTMLFormElement | null) => { formRef.current = node - refValidation(node) + // Track fields that carry no `required` attribute as well. The "save this address + // in your account" checkbox is optional, so without this rapid-form never reports + // it, the effect below never sees it, and ticking it was silently discarded. + refValidation(node, { trackUnvalidatedFields: true }) }, [refValidation] ) @@ -174,15 +182,30 @@ export function useAddressFormFields({ const addressValues: Record = {} for (const [name, field] of Object.entries(formValues)) { if (field == null) continue - if ( - field.value != null && - (field.value || field.required === false) && - field.type !== "checkbox" - ) { - addressValues[name.replace(prefix, "")] = field.value + + // rapid-form v5 reports every tracked field as `{ name, value }` only — no `type` + // or `checked` — and encodes a checkbox as the string "true"/"false". A v5 checkbox + // is therefore indistinguishable from a text field by shape alone, so recognise + // the save-to-address-book field by its known name and fall back to the DOM for any + // other checkbox. Either way a checkbox must never reach `addressValues`: "false" + // is a truthy string and would be PATCHed onto the address as a bogus attribute. + const isSaveToAddressBookField = name === checkboxFieldName + const element = getFormElement(formRef.current, name) + if (isSaveToAddressBookField || element?.type === "checkbox" || field.type === "checkbox") { + if (isSaveToAddressBookField) { + // The live element is authoritative; fall back to the reported field for + // environments without a real input, then to v5's "true"/"false" string. + const checked = + (element as HTMLInputElement | null)?.checked ?? + field.checked ?? + singleFormValue(field.value as string | string[] | undefined) === "true" + saveAddressToCustomerAddressBook?.({ type: resource, value: checked }) + } + continue } - if (field.type === "checkbox") { - saveAddressToCustomerAddressBook?.({ type: resource, value: field.checked ?? false }) + + if (field.value != null && (field.value || field.required === false)) { + addressValues[name.replace(prefix, "")] = field.value } } @@ -220,6 +243,7 @@ export function useAddressFormFields({ prefix, errorMode, hasValidated, + checkboxFieldName, ]) useEffect(() => { @@ -228,7 +252,9 @@ export function useAddressFormFields({ ) const checked = checkbox?.checked || getSaveToAddressBook() if (checked) { - checkbox?.setAttribute("checked", "true") + // Assign the property, not the attribute: the attribute only seeds `defaultChecked`, + // so on a re-opened step it left the box visually unticked. + if (checkbox != null && !checkbox.checked) checkbox.checked = true saveAddressToCustomerAddressBook?.({ type: resource, value: true }) } }, [saveAddressToCustomerAddressBook, checkboxFieldName, getSaveToAddressBook, resource]) diff --git a/packages/react-components/src/reducers/PaymentMethodReducer.ts b/packages/react-components/src/reducers/PaymentMethodReducer.ts index 979cc7f6..1a860832 100644 --- a/packages/react-components/src/reducers/PaymentMethodReducer.ts +++ b/packages/react-components/src/reducers/PaymentMethodReducer.ts @@ -397,7 +397,9 @@ async function runSetPaymentSource({ const expiredErrors = errors.filter((v) => v?.meta?.error === "expired") if (expiredErrors.length > 0 && order && config) { console.error("Set payment source - expired:", expiredErrors) - destroyPaymentSource({ + // Awaited for ordering: today this only dispatches, but the caller's result + // depends on the source being gone, so it should not be left in flight. + await destroyPaymentSource({ paymentSourceId: order.payment_source?.id || "", paymentResource, dispatch, diff --git a/packages/react-components/src/utils/addressFormUtils.ts b/packages/react-components/src/utils/addressFormUtils.ts index 48ec2ce2..3273ecc6 100644 --- a/packages/react-components/src/utils/addressFormUtils.ts +++ b/packages/react-components/src/utils/addressFormUtils.ts @@ -19,6 +19,17 @@ export type FormValue = Value & { value?: string | number | readonly string[] } +/** + * rapid-form v5 widened a tracked field's `value` to `string | string[]` so it can + * carry multi-value controls (checkbox groups, `