diff --git a/packages/core/src/addresses/index.ts b/packages/core/src/addresses/index.ts new file mode 100644 index 00000000..d53c36f3 --- /dev/null +++ b/packages/core/src/addresses/index.ts @@ -0,0 +1 @@ +export * from "./updateAddressReference" diff --git a/packages/core/src/addresses/updateAddressReference.ts b/packages/core/src/addresses/updateAddressReference.ts new file mode 100644 index 00000000..af628082 --- /dev/null +++ b/packages/core/src/addresses/updateAddressReference.ts @@ -0,0 +1,27 @@ +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface Params extends RequestConfig { + /** + * The address ID to update. + */ + id: string + /** + * The customer address reference (customer_address ID) to link. + */ + reference: string +} + +/** + * Updates the `reference` field on an existing address resource, linking it to a customer address. + * Used when a customer selects a saved address to use as billing or shipping. + */ +export async function updateAddressReference({ + id, + reference, + accessToken, + interceptors, +}: Params): Promise { + const sdk = getSdk({ accessToken, interceptors }) + await sdk.addresses.update({ id, reference }) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 13bb5cea..a4f11b2a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export * from "./addresses" export * from "./auth" export * from "./availability" export * from "./createBatchStore" diff --git a/packages/react-components/specs/addresses/Address.spec.tsx b/packages/react-components/specs/addresses/Address.spec.tsx new file mode 100644 index 00000000..21fd171e --- /dev/null +++ b/packages/react-components/specs/addresses/Address.spec.tsx @@ -0,0 +1,313 @@ +import type { Address as AddressType } from "@commercelayer/sdk" +import { act, render, screen, waitFor } from "@testing-library/react" +import Address from "#components/addresses/Address" +import AddressContext, { defaultAddressContext } from "#context/AddressContext" +import BillingAddressContext from "#context/BillingAddressContext" +import CustomerContext from "#context/CustomerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import ShippingAddressContext from "#context/ShippingAddressContext" + +vi.mock("@commercelayer/core", () => ({})) + +const mockAddress: AddressType = { + id: "addr-1", + type: "addresses", + first_name: "John", + last_name: "Doe", + line_1: "123 Main St", + city: "New York", + country_code: "US", + state_code: "NY", + zip_code: "10001", + phone: "+1-555-1234", + reference: "cust-addr-1", +} as AddressType + +const mockAddress2: AddressType = { + id: "addr-2", + type: "addresses", + first_name: "Jane", + last_name: "Smith", + line_1: "456 Oak Ave", + city: "Los Angeles", + country_code: "US", + state_code: "CA", + zip_code: "90001", + phone: "+1-555-5678", + reference: "cust-addr-2", +} as AddressType + +const mockSetBillingAddress = vi.fn() +const mockSetShippingAddress = vi.fn() + +function renderAddress( + props: Partial[0]> = {}, + contextOverrides: Record = {} +) { + return render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + + + +
+ address content +
+
+
+
+
+
+ ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockSetBillingAddress.mockResolvedValue(undefined) + mockSetShippingAddress.mockResolvedValue(undefined) +}) + +describe("Address", () => { + it("renders one card per address from props", () => { + renderAddress({ addresses: [mockAddress, mockAddress2] }) + expect(screen.getAllByTestId("address-child")).toHaveLength(2) + }) + + it("renders addresses from CustomerContext when addresses prop is empty", () => { + renderAddress({ addresses: [] }, { customerAddresses: [mockAddress] }) + expect(screen.getAllByTestId("address-child")).toHaveLength(1) + }) + + it("renders nothing when no addresses", () => { + const { container } = renderAddress({ addresses: [] }) + expect(container.querySelectorAll("[data-testid='address-child']")).toHaveLength(0) + }) + + it("applies selectedClassName on click", async () => { + renderAddress({ addresses: [mockAddress] }) + const card = screen.getAllByTestId("address-child")[0].parentElement! + await act(async () => { + card.click() + }) + await waitFor(() => { + expect(card.className).toContain("selected") + }) + }) + + it("calls setBillingAddress on click", async () => { + renderAddress({ addresses: [mockAddress] }) + const card = screen.getAllByTestId("address-child")[0].parentElement! + await act(async () => { + card.click() + }) + expect(mockSetBillingAddress).toHaveBeenCalledWith("addr-1", { + customerAddressId: "cust-addr-1", + }) + }) + + it("calls setShippingAddress on click when not disabled", async () => { + renderAddress({ addresses: [mockAddress] }) + const card = screen.getAllByTestId("address-child")[0].parentElement! + await act(async () => { + card.click() + }) + expect(mockSetShippingAddress).toHaveBeenCalledWith("addr-1", { + customerAddressId: "cust-addr-1", + }) + }) + + it("calls onSelect callback with address", async () => { + const onSelect = vi.fn() + renderAddress({ addresses: [mockAddress], onSelect }) + const card = screen.getAllByTestId("address-child")[0].parentElement! + await act(async () => { + card.click() + }) + expect(onSelect).toHaveBeenCalledWith(mockAddress) + }) + + it("filters out addresses when country_code does not match shipping_country_code_lock", () => { + renderAddress( + { addresses: [mockAddress] }, + { + order: { order: { id: "ord-1", shipping_country_code_lock: "DE" } }, + shipping: { setShippingAddress: mockSetShippingAddress }, + } + ) + // Address with US country code is filtered out when lock is DE + expect(screen.queryAllByTestId("address-child")).toHaveLength(0) + }) + + it("clears addresses when deselect=true", async () => { + renderAddress({ addresses: [mockAddress], deselect: true }) + await act(async () => {}) + expect(mockSetBillingAddress).toHaveBeenCalledWith("") + }) + + it("preselects billing address when billingCustomerAddressId matches reference", async () => { + renderAddress( + { addresses: [mockAddress, mockAddress2] }, + { + billing: { + setBillingAddress: mockSetBillingAddress, + billingCustomerAddressId: "cust-addr-1", + }, + } + ) + await act(async () => {}) + // The first card should be selected + await waitFor(() => { + const cards = screen.getAllByTestId("address-child").map((el) => el.parentElement!) + expect(cards[0].className).toContain("selected") + }) + }) + + it("renders function children via AddressCardsTemplate", () => { + const child = vi.fn(({ customerAddresses }: { customerAddresses: any[] }) => ( + {customerAddresses.length} + )) + // biome-ignore lint/suspicious/noExplicitAny: test cast + const orderCtx = { ...defaultOrderContext } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const addressCtx = { ...defaultAddressContext, setCloneAddress: vi.fn() } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const billingCtx = { setBillingAddress: mockSetBillingAddress } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shippingCtx = { setShippingAddress: mockSetShippingAddress } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customerCtx = {} as any + render( + + + + + +
+ {child} +
+
+
+
+
+
+ ) + expect(child).toHaveBeenCalled() + }) + + it("preselects shipping address when shippingCustomerAddressId matches reference", async () => { + renderAddress( + { addresses: [mockAddress, mockAddress2] }, + { + shipping: { + setShippingAddress: mockSetShippingAddress, + shippingCustomerAddressId: "cust-addr-2", + }, + } + ) + await waitFor(() => { + const cards = screen.getAllByTestId("address-child").map((el) => el.parentElement!) + expect(cards[1].className).toContain("selected") + }) + }) + + it("calls setBillingAddress via effect re-run when dep changes after address is selected", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const Wrapper = ({ extra }: { extra: boolean }) => ( + + + + + +
+ content +
+
+
+
+
+
+ ) + const { rerender } = render() + const card = screen.getAllByTestId("address-child")[0].parentElement! + await act(async () => { + card.click() + }) + mockSetBillingAddress.mockClear() + mockSetShippingAddress.mockClear() + // Change shipToDifferentAddress to re-trigger the effect with selected=0 + await act(async () => { + rerender() + }) + expect(mockSetBillingAddress).toHaveBeenCalledWith("addr-1", { + customerAddressId: "cust-addr-1", + }) + expect(mockSetShippingAddress).toHaveBeenCalledWith("addr-1", { + customerAddressId: "cust-addr-1", + }) + }) + + it("renders address card with empty customerAddressId when reference is undefined", () => { + const addressWithoutRef: AddressType = { ...mockAddress, reference: undefined } + renderAddress({ addresses: [addressWithoutRef] }) + // address renders (covers address?.reference || "" branch — the "" fallback) + expect(screen.queryAllByTestId("address-child").length).toBe(1) + }) + + it("applies disabledClassName when countryLock does not match address country_code", () => { + // Addresses with mismatched country code are filtered out (not disabled) + const ukAddress: AddressType = { ...mockAddress, country_code: "GB" } + renderAddress( + { addresses: [ukAddress] }, + { + order: { order: { id: "ord-1", shipping_country_code_lock: "US" } }, + shipping: { setShippingAddress: mockSetShippingAddress }, + } + ) + // Filtered out — not rendered at all + expect(screen.queryAllByTestId("address-child")).toHaveLength(0) + }) +}) diff --git a/packages/react-components/specs/addresses/AddressCountrySelector.spec.tsx b/packages/react-components/specs/addresses/AddressCountrySelector.spec.tsx new file mode 100644 index 00000000..49f138bc --- /dev/null +++ b/packages/react-components/specs/addresses/AddressCountrySelector.spec.tsx @@ -0,0 +1,150 @@ +import { act, render, screen } from "@testing-library/react" +import AddressCountrySelector from "#components/addresses/AddressCountrySelector" +import type { DefaultContextAddress } from "#context/BillingAddressFormContext" +import BillingAddressFormContext from "#context/BillingAddressFormContext" +import CustomerAddressFormContext from "#context/CustomerAddressFormContext" +import ShippingAddressFormContext from "#context/ShippingAddressFormContext" + +vi.mock("@commercelayer/core", () => ({})) + +const mockBillingCtx: Partial = { + setValue: vi.fn(), + errors: {}, + errorClassName: "error", +} + +const mockShippingCtx: Partial = { + setValue: vi.fn(), + errors: {}, + errorClassName: "error", +} + +const mockCustomerCtx: Partial = { + setValue: vi.fn(), + errors: {}, + errorClassName: "error", +} + +function renderSelector( + props: Partial[0]> = {}, + billingOverride: Partial = {}, + shippingOverride: Partial = {}, + customerOverride: Partial = {} +) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const billingCtx = { ...mockBillingCtx, ...billingOverride } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shippingCtx = { ...mockShippingCtx, ...shippingOverride } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customerCtx = { ...mockCustomerCtx, ...customerOverride } as any + return render( + + + + + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + ;(mockBillingCtx.setValue as ReturnType).mockReset() + ;(mockShippingCtx.setValue as ReturnType).mockReset() + ;(mockCustomerCtx.setValue as ReturnType).mockReset() +}) + +describe("AddressCountrySelector", () => { + it("renders a select element", () => { + renderSelector() + expect(screen.getByRole("combobox")).toBeTruthy() + }) + + it("calls billing setValue when value prop changes", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const billingCtx = { ...mockBillingCtx } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shippingCtx = { ...mockShippingCtx } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customerCtx = { ...mockCustomerCtx } as any + const { rerender } = render( + + + + + + + + ) + await act(async () => { + rerender( + + + + + + + + ) + }) + expect(billingCtx.setValue).toHaveBeenCalledWith("billing_address_country_code", "US") + }) + + it("applies errorClassName when billing field has error", () => { + renderSelector( + {}, + { + errors: { + billing_address_country_code: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "is-error", + } + ) + expect(screen.getByRole("combobox").className).toContain("is-error") + }) + + it("does not apply errorClassName when no error", () => { + renderSelector({}, { errors: {}, errorClassName: "is-error" }) + expect(screen.getByRole("combobox").className).not.toContain("is-error") + }) + + it("applies errorClassName when shipping field has error", () => { + renderSelector( + {}, + { errors: {}, errorClassName: "" }, // clear billing errorClassName + { + errors: { + billing_address_country_code: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "is-error", + } + ) + expect(screen.getByRole("combobox").className).toContain("is-error") + }) + + it("applies errorClassName when customer address field has error", () => { + renderSelector( + {}, + { errors: {}, errorClassName: "" }, // clear billing + { errors: {}, errorClassName: "" }, // clear shipping + { + errors: { + billing_address_country_code: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "is-error", + } + ) + expect(screen.getByRole("combobox").className).toContain("is-error") + }) + + it("applies required by default", () => { + renderSelector() + expect(screen.getByRole("combobox").required).toBe(true) + }) + + it("allows required=false", () => { + renderSelector({ required: false }) + expect(screen.getByRole("combobox").required).toBe(false) + }) +}) diff --git a/packages/react-components/specs/addresses/AddressField.spec.tsx b/packages/react-components/specs/addresses/AddressField.spec.tsx new file mode 100644 index 00000000..b5df5d7b --- /dev/null +++ b/packages/react-components/specs/addresses/AddressField.spec.tsx @@ -0,0 +1,105 @@ +import { render, screen } from "@testing-library/react" +import AddressField from "#components/addresses/AddressField" +import AddressChildrenContext from "#context/AddressChildrenContext" +import CustomerContext from "#context/CustomerContext" + +vi.mock("@commercelayer/core", () => ({})) + +const mockAddress = { + id: "addr-1", + type: "addresses", + first_name: "John", + last_name: "Doe", + line_1: "123 Main St", + city: "New York", + country_code: "US", + state_code: "NY", + zip_code: "10001", + phone: "+1-555-1234", + reference: "cust-addr-1", +} + +function renderField( + props: Parameters[0], + addressOverride = mockAddress, + customerOverride: Record = {} +) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customerCtx = { deleteCustomerAddress: vi.fn(), ...customerOverride } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const addressChildCtx = { address: addressOverride } as any + return render( + + + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("AddressField", () => { + it("renders a field value for the given name", () => { + renderField({ type: "field", name: "first_name" }) + expect(screen.getByTestId("address-field-first_name").textContent).toBe("John") + }) + + it("renders empty string for missing field", () => { + renderField({ type: "field", name: "first_name" }, { ...mockAddress, first_name: undefined }) + expect(screen.getByTestId("address-field-first_name").textContent).toBe("") + }) + + it("renders an edit anchor with label", () => { + const onClick = vi.fn() + renderField({ type: "edit", label: "Edit", onClick }) + expect(screen.getByTestId("address-field-").textContent).toBe("Edit") + }) + + it("calls onClick on edit click", () => { + const onClick = vi.fn() + renderField({ type: "edit", label: "Edit", onClick }) + screen.getByTestId("address-field-").click() + expect(onClick).toHaveBeenCalledWith(mockAddress) + }) + + it("renders a delete anchor with label", () => { + renderField({ type: "delete", label: "Delete", onClick: vi.fn() }) + expect(screen.getByTestId("address-field-").textContent).toBe("Delete") + }) + + it("calls deleteCustomerAddress on delete click", () => { + const deleteCustomerAddress = vi.fn() + renderField({ type: "delete", label: "Delete", onClick: vi.fn() }, mockAddress, { + deleteCustomerAddress, + }) + screen.getByTestId("address-field-").click() + expect(deleteCustomerAddress).toHaveBeenCalledWith({ customerAddressId: "cust-addr-1" }) + }) + + it("does not call deleteCustomerAddress if address has no reference", () => { + const deleteCustomerAddress = vi.fn() + renderField( + { type: "delete", label: "Delete", onClick: vi.fn() }, + { ...mockAddress, reference: undefined }, + { deleteCustomerAddress } + ) + screen.getByTestId("address-field-").click() + expect(deleteCustomerAddress).not.toHaveBeenCalled() + }) + + it("renders custom children via render prop", () => { + const child = vi.fn(() => custom) + renderField({ children: child }) + expect(screen.getByTestId("custom").textContent).toBe("custom") + expect(child.mock.calls[0][0]).toMatchObject({ address: mockAddress }) + }) + + it("renders field with undefined name — falls back to empty string in data-testid", () => { + // Cast as any to bypass TypeScript — tests the name ?? "" fallback in the

element + renderField({ type: "field" } as any) + expect(screen.getByTestId("address-field-")).toBeTruthy() + }) +}) diff --git a/packages/react-components/specs/addresses/AddressInput.spec.tsx b/packages/react-components/specs/addresses/AddressInput.spec.tsx new file mode 100644 index 00000000..7d9f9994 --- /dev/null +++ b/packages/react-components/specs/addresses/AddressInput.spec.tsx @@ -0,0 +1,195 @@ +import { act, render, screen } from "@testing-library/react" +import AddressInput from "#components/addresses/AddressInput" +import type { DefaultContextAddress } from "#context/BillingAddressFormContext" +import BillingAddressFormContext from "#context/BillingAddressFormContext" +import CustomerAddressFormContext from "#context/CustomerAddressFormContext" +import ShippingAddressFormContext from "#context/ShippingAddressFormContext" + +vi.mock("@commercelayer/core", () => ({})) + +const makeBillingCtx = (overrides: Partial = {}): DefaultContextAddress => + ({ + setValue: vi.fn(), + errors: {}, + errorClassName: "field-error", + isBusiness: false, + requiresBillingInfo: undefined, + ...overrides, + }) as unknown as DefaultContextAddress + +const makeShippingCtx = (overrides: Partial = {}): DefaultContextAddress => + ({ + setValue: vi.fn(), + errors: {}, + errorClassName: "field-error", + isBusiness: false, + requiresBillingInfo: undefined, + ...overrides, + }) as unknown as DefaultContextAddress + +const makeCustomerCtx = (overrides: Partial = {}): DefaultContextAddress => + ({ + setValue: vi.fn(), + errors: {}, + ...overrides, + }) as unknown as DefaultContextAddress + +function renderInput( + props: Partial[0]> & { name?: any } = {}, + billingOverride: Partial = {}, + shippingOverride: Partial = {}, + customerOverride: Partial = {} +) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const billingCtx = makeBillingCtx(billingOverride) as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shippingCtx = makeShippingCtx(shippingOverride) as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customerCtx = makeCustomerCtx(customerOverride) as any + return render( + + + + + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("AddressInput", () => { + it("renders an input element", () => { + renderInput() + expect(screen.getByPlaceholderText("First name")).toBeTruthy() + }) + + it("calls billing setValue when value is provided", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const billing = makeBillingCtx() as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shipping = makeShippingCtx() as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customer = makeCustomerCtx() as any + render( + + + + + + + + ) + await act(async () => {}) + expect(billing.setValue).toHaveBeenCalledWith("billing_address_first_name", "John") + }) + + it("applies errorClassName when billing field has error", () => { + renderInput( + {}, + { + errors: { + billing_address_first_name: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "is-invalid", + } + ) + expect(screen.getByRole("textbox").className).toContain("is-invalid") + }) + + it("applies errorClassName when shipping field has error", () => { + renderInput( + {}, + { errors: {}, errorClassName: "" }, // no billing errorClassName + { + errors: { + billing_address_first_name: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "is-invalid", + } + ) + expect(screen.getByRole("textbox").className).toContain("is-invalid") + }) + + it("does not apply errorClassName when no error", () => { + renderInput({}, { errors: {}, errorClassName: "is-invalid" }) + expect(screen.getByRole("textbox").className).not.toContain("is-invalid") + }) + + it("returns null for billing_info when requiresBillingInfo is false", () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const billingCtx = makeBillingCtx({ requiresBillingInfo: false }) as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shippingCtx = makeShippingCtx() as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customerCtx = makeCustomerCtx() as any + const { container } = render( + + + + + + + + ) + expect(container.innerHTML).toBe("") + }) + + it("renders billing_info when requiresBillingInfo is true", () => { + renderInput({ name: "billing_address_billing_info" as any }, { requiresBillingInfo: true }) + expect(screen.getByRole("textbox")).toBeTruthy() + }) + + it("returns null for shipping billing_info when requiresBillingInfo is false", () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const billingCtx = makeBillingCtx() as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shippingCtx = makeShippingCtx({ requiresBillingInfo: false }) as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customerCtx = makeCustomerCtx() as any + const { container } = render( + + + + + + + + ) + expect(container.innerHTML).toBe("") + }) + + it("renders billing_info when required=true even if requiresBillingInfo is false", () => { + renderInput( + { name: "billing_address_billing_info" as any, required: true }, + { requiresBillingInfo: false } + ) + expect(screen.getByRole("textbox")).toBeTruthy() + }) + + it("applies required from isBusiness mandatory fields", () => { + renderInput({ name: "billing_address_company" as any }, { isBusiness: true }) + expect((screen.getByRole("textbox") as HTMLInputElement).required).toBe(true) + }) + + it("applies errorClassName when customer address field has error", () => { + renderInput( + {}, + { errors: {}, errorClassName: "is-invalid" }, // billing provides the errorClassName + {}, + { + errors: { + billing_address_first_name: { code: "ERR", message: "required", error: true }, + }, + } + ) + expect(screen.getByRole("textbox").className).toContain("is-invalid") + }) +}) diff --git a/packages/react-components/specs/addresses/AddressInputSelect.spec.tsx b/packages/react-components/specs/addresses/AddressInputSelect.spec.tsx new file mode 100644 index 00000000..2cedff55 --- /dev/null +++ b/packages/react-components/specs/addresses/AddressInputSelect.spec.tsx @@ -0,0 +1,103 @@ +import { act, render, screen } from "@testing-library/react" +import AddressInputSelect from "#components/addresses/AddressInputSelect" +import type { DefaultContextAddress } from "#context/BillingAddressFormContext" +import BillingAddressFormContext from "#context/BillingAddressFormContext" +import ShippingAddressFormContext from "#context/ShippingAddressFormContext" + +vi.mock("@commercelayer/core", () => ({})) + +const OPTIONS = [ + { label: "Option A", value: "a" }, + { label: "Option B", value: "b" }, +] + +const mockBilling: Partial = { setValue: vi.fn(), errors: {} } +const mockShipping: Partial = { setValue: vi.fn(), errors: {} } + +function renderSelect( + props: Partial[0]> = {}, + billingOverride: Partial = {}, + shippingOverride: Partial = {} +) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const billingCtx = { ...mockBilling, ...billingOverride } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const shippingCtx = { ...mockShipping, ...shippingOverride } as any + return render( + + + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + ;(mockBilling.setValue as ReturnType).mockReset() + ;(mockShipping.setValue as ReturnType).mockReset() +}) + +describe("AddressInputSelect", () => { + it("renders a select with given options", () => { + renderSelect() + expect(screen.getByRole("combobox")).toBeTruthy() + expect(screen.getByText("Option A")).toBeTruthy() + expect(screen.getByText("Option B")).toBeTruthy() + }) + + it("calls billing setValue when value prop is provided", async () => { + renderSelect({ value: "a" }) + await act(async () => {}) + expect(mockBilling.setValue).toHaveBeenCalledWith("billing_address_metadata_custom", "a") + }) + + it("calls shipping setValue when value prop is provided", async () => { + renderSelect({ value: "b" }) + await act(async () => {}) + expect(mockShipping.setValue).toHaveBeenCalledWith("billing_address_metadata_custom", "b") + }) + + it("does not call setValue when value is not provided", async () => { + renderSelect({}) + await act(async () => {}) + expect(mockBilling.setValue).not.toHaveBeenCalled() + expect(mockShipping.setValue).not.toHaveBeenCalled() + }) + + it("applies errorClassName when billing field has error", () => { + renderSelect( + {}, + { + errors: { + billing_address_metadata_custom: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "is-error", + } + ) + expect(screen.getByRole("combobox").className).toContain("is-error") + }) + + it("does not apply errorClassName when no error", () => { + renderSelect({}, { errors: {}, errorClassName: "is-error" }) + expect(screen.getByRole("combobox").className).not.toContain("is-error") + }) + + it("applies errorClassName when shipping field has error", () => { + renderSelect( + {}, + { errors: {} }, + { + errors: { + billing_address_metadata_custom: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "is-error", + } + ) + expect(screen.getByRole("combobox").className).toContain("is-error") + }) +}) diff --git a/packages/react-components/specs/addresses/AddressStateSelector.spec.tsx b/packages/react-components/specs/addresses/AddressStateSelector.spec.tsx new file mode 100644 index 00000000..8408d206 --- /dev/null +++ b/packages/react-components/specs/addresses/AddressStateSelector.spec.tsx @@ -0,0 +1,293 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { AddressStateSelector } from "#components/addresses/AddressStateSelector" +import AddressesContext, { defaultAddressContext } from "#context/AddressContext" +import type { DefaultContextAddress } from "#context/BillingAddressFormContext" +import BillingAddressFormContext from "#context/BillingAddressFormContext" +import CustomerAddressFormContext from "#context/CustomerAddressFormContext" +import ShippingAddressFormContext from "#context/ShippingAddressFormContext" + +function renderSelector( + props: Partial[0]> = {}, + billingOverrides: Partial | null = {}, + shippingOverrides: Partial | null = {} +) { + const setValue = vi.fn() + const billingCtx = + billingOverrides !== null ? ({ setValue, errors: {}, ...billingOverrides } as any) : ({} as any) + const shippingCtx = + shippingOverrides !== null + ? ({ setValue, errors: {}, ...shippingOverrides } as any) + : ({} as any) + return render( + + + + + + + + + + ) +} + +describe("AddressStateSelector", () => { + it("renders a text input when no country code is set (no states available)", () => { + renderSelector() + expect(screen.getByRole("textbox")).toBeTruthy() + }) + + it("renders a select when country code with states is provided via billing context", async () => { + renderSelector( + {}, + { + values: { + billing_address_country_code: "US", + } as any, + } + ) + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeTruthy() + }) + }) + + it("renders a text input for country with no states (e.g. Singapore)", async () => { + renderSelector( + {}, + { + values: { + billing_address_country_code: "SG", + } as any, + } + ) + await act(async () => {}) + expect(screen.getByRole("textbox")).toBeTruthy() + }) + + it("calls setValue on text input change", () => { + const setValue = vi.fn() + renderSelector({}, { setValue, errors: {} }, null) + const input = screen.getByRole("textbox") + fireEvent.change(input, { target: { value: "CA" } }) + expect(setValue).toHaveBeenCalledWith("billing_address_state_code", "CA") + }) + + it("applies errorClassName when billing field has error", async () => { + renderSelector( + {}, + { + errors: { + billing_address_state_code: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "input-error", + }, + null // empty shipping context so it doesn't override billing error state + ) + await act(async () => {}) + const input = screen.getByRole("textbox") + expect(input.className).toContain("input-error") + }) + + it("uses shipping country code from shipping context", async () => { + renderSelector( + {}, + {}, + { + values: { + shipping_address_country_code: "US", + } as any, + } + ) + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeTruthy() + }) + }) + + it("applies errorClassName when customer address field has error", async () => { + const customerCtx = { + errors: { + billing_address_state_code: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "customer-error", + setValue: vi.fn(), + } as any + render( + + + + + + + + + + ) + await act(async () => {}) + expect(screen.getByRole("textbox").className).toContain("customer-error") + }) + + it("applies errorClassName when shipping field has error (billing empty)", async () => { + renderSelector({}, null, { + errors: { + billing_address_state_code: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "shipping-error", + }) + await act(async () => {}) + expect(screen.getByRole("textbox").className).toContain("shipping-error") + }) + + it("calls shipping setValue on text input change", () => { + const shippingSetValue = vi.fn() + renderSelector({}, null, { setValue: shippingSetValue, errors: {} }) + fireEvent.change(screen.getByRole("textbox"), { target: { value: "TX" } }) + expect(shippingSetValue).toHaveBeenCalledWith("billing_address_state_code", "TX") + }) + + it("calls billing setValue when value prop changes", async () => { + const setValue = vi.fn() + const billingCtx = { setValue, errors: {} } as any + const Wrapper = ({ stateValue }: { stateValue: string }) => ( + + + + + + + + + + ) + const { rerender } = render() + await act(async () => {}) + // Change value from "" to "NY" — triggers effect re-run with val="" but value="NY" + rerender() + await act(async () => {}) + expect(setValue).toHaveBeenCalledWith("billing_address_state_code", "NY") + }) + + it("calls shipping setValue when value prop changes and shipping context has setValue", async () => { + const billingSetValue = vi.fn() + const shippingSetValue = vi.fn() + const billingCtx = { setValue: billingSetValue, errors: {} } as any + const shippingCtx = { setValue: shippingSetValue, errors: {} } as any + const Wrapper = ({ stateValue }: { stateValue: string }) => ( + + + + + + + + + + ) + const { rerender } = render() + await act(async () => {}) + rerender() + await act(async () => {}) + expect(shippingSetValue).toHaveBeenCalledWith("billing_address_state_code", "TX") + }) + + it("resets val when billing country changes to one with states and current val is invalid", async () => { + const resetField = vi.fn() + const Wrapper = ({ country }: { country: string }) => { + const billingCtx = { + setValue: vi.fn(), + resetField, + errors: {}, + values: { billing_address_country_code: country }, + } as any + return ( + + + + + + + + + + ) + } + // Start with US so countryCode state = "US" and stateOptions = US states + const { rerender } = render() + await act(async () => {}) + // Switch to CA — effect runs: changeBillingCountry=true, stateOptions=US states (not empty), + // val="" is not a valid CA state → resetField called, setVal("") + rerender() + await act(async () => {}) + expect(resetField).toHaveBeenCalledWith("billing_address_state_code") + }) + + it("resets val when shipping country changes to one with states and current val is invalid", async () => { + const resetField = vi.fn() + const Wrapper = ({ country }: { country: string }) => { + const shippingCtx = { + setValue: vi.fn(), + resetField, + errors: {}, + values: { shipping_address_country_code: country }, + } as any + return ( + + + + + + + + + + ) + } + // Start with US so countryCode state = "US" and stateOptions = US states + const { rerender } = render() + await act(async () => {}) + // Switch to CA — effect runs: changeShippingCountry=true, stateOptions=US states (not empty), + // val="" is not a valid CA state → shipping resetField called, setVal("") + rerender() + await act(async () => {}) + expect(resetField).toHaveBeenCalledWith("billing_address_state_code") + }) + + it("does not show error className when customer context has no error (no-error path)", async () => { + const customerCtx = { + errors: {}, // non-empty object (has errors key but no error for this field) + errorClassName: "customer-error", + setValue: vi.fn(), + } as any + render( + + + + + + + + + + ) + await act(async () => {}) + // No error for the field — errorClassName should NOT be applied + expect(screen.getByRole("textbox").className).not.toContain("customer-error") + }) + + it("applies select className when country has states and field has an error", async () => { + renderSelector( + {}, + { + values: { billing_address_country_code: "US" } as any, + errors: { + billing_address_state_code: { code: "ERR", message: "required", error: true }, + }, + errorClassName: "billing-select-error", + }, + null // no shipping context so billing hasError is not overridden by shipping + ) + await waitFor(() => { + const el = screen.getByRole("combobox") + expect(el.className).toContain("billing-select-error") + }) + }) +}) diff --git a/packages/react-components/specs/addresses/AddressesContainer.spec.tsx b/packages/react-components/specs/addresses/AddressesContainer.spec.tsx new file mode 100644 index 00000000..c6d16605 --- /dev/null +++ b/packages/react-components/specs/addresses/AddressesContainer.spec.tsx @@ -0,0 +1,160 @@ +import { act, render, screen } from "@testing-library/react" +import { useContext } from "react" +import AddressesContainer from "#components/addresses/AddressesContainer" +import AddressContext from "#context/AddressContext" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +vi.mock("@commercelayer/core", () => ({})) +vi.mock("#utils/localStorage", () => ({ + setCustomerOrderParam: vi.fn(), + getLocalOrder: vi.fn(), + setLocalOrder: vi.fn(), + deleteLocalOrder: vi.fn(), +})) + +// biome-ignore lint/suspicious/noExplicitAny: test cast +let latestContext: any + +function ContextProbe() { + latestContext = useContext(AddressContext) + return null +} + +function renderContainer( + props: Partial[0]> = {}, + orderOverrides: Record = {} +) { + latestContext = undefined + return render( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + + + + child + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + latestContext = undefined +}) + +describe("AddressesContainer", () => { + it("renders children", () => { + renderContainer() + expect(screen.getByTestId("child")).toBeTruthy() + }) + + it("provides AddressContext to children", () => { + renderContainer() + expect(latestContext).toBeDefined() + expect(typeof latestContext.setCloneAddress).toBe("function") + expect(typeof latestContext.saveAddresses).toBe("function") + expect(typeof latestContext.setAddressErrors).toBe("function") + }) + + it("sets shipToDifferentAddress false by default", () => { + renderContainer() + expect(latestContext.shipToDifferentAddress).toBe(false) + }) + + it("sets shipToDifferentAddress true when prop is true", async () => { + renderContainer({ shipToDifferentAddress: true }) + await act(async () => {}) + expect(latestContext.shipToDifferentAddress).toBe(true) + }) + + it("sets invertAddresses false by default", () => { + renderContainer() + expect(latestContext.invertAddresses).toBe(false) + }) + + it("sets invertAddresses when prop provided", async () => { + renderContainer({ invertAddresses: true }) + await act(async () => {}) + expect(latestContext.invertAddresses).toBe(true) + }) + + it("sets isBusiness when prop provided", async () => { + renderContainer({ isBusiness: true }) + await act(async () => {}) + expect(latestContext.isBusiness).toBe(true) + }) + + it("calls setCustomerOrderParam for draft orders", async () => { + const { setCustomerOrderParam } = await import("#utils/localStorage") + renderContainer( + {}, + // biome-ignore lint/suspicious/noExplicitAny: test cast + { order: { status: "draft", id: "ord-1" } as any } + ) + await act(async () => {}) + expect(setCustomerOrderParam).toHaveBeenCalledWith( + "_save_billing_address_to_customer_address_book", + "false" + ) + expect(setCustomerOrderParam).toHaveBeenCalledWith( + "_save_shipping_address_to_customer_address_book", + "false" + ) + }) + + it("context setAddressErrors calls dispatch", async () => { + renderContainer() + await act(async () => { + latestContext.setAddressErrors([], "billing_address") + }) + // no errors, setAddressErrors is a function and should not throw + expect(typeof latestContext.setAddressErrors).toBe("function") + }) + + it("context setAddress calls defaultAddressContext.setAddress with dispatch", async () => { + renderContainer() + await act(async () => { + latestContext.setAddress({ values: {}, resource: "billing_address" }) + }) + expect(typeof latestContext.setAddress).toBe("function") + }) + + it("context setCloneAddress calls setCloneAddress with dispatch", async () => { + renderContainer() + await act(async () => { + latestContext.setCloneAddress("addr-1", "billing_address") + }) + expect(typeof latestContext.setCloneAddress).toBe("function") + }) + + it("context saveAddresses calls saveAddresses from reducer with order", async () => { + renderContainer( + {}, + { + order: { id: "ord-1", status: "pending" } as any, + updateOrder: vi.fn().mockResolvedValue({ id: "ord-1" }), + orderId: "ord-1", + } + ) + await act(async () => { + try { + await latestContext.saveAddresses({ customerEmail: "test@example.com" }) + } catch (_e) { + // may throw since config/sdk are not fully mocked + } + }) + expect(typeof latestContext.saveAddresses).toBe("function") + }) +}) diff --git a/packages/react-components/specs/addresses/AddressesEmpty.spec.tsx b/packages/react-components/specs/addresses/AddressesEmpty.spec.tsx new file mode 100644 index 00000000..fb11ac16 --- /dev/null +++ b/packages/react-components/specs/addresses/AddressesEmpty.spec.tsx @@ -0,0 +1,53 @@ +import type { Address } from "@commercelayer/sdk" +import { render, screen } from "@testing-library/react" +import AddressesEmpty from "#components/addresses/AddressesEmpty" +import CustomerContext from "#context/CustomerContext" + +vi.mock("@commercelayer/core", () => ({})) + +function renderEmpty( + addresses: Address[] | null | undefined, + props: Parameters[0] = {} +) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const ctx = { addresses } as any + return render( + + + + ) +} + +describe("AddressesEmpty", () => { + it("renders default empty text when addresses is empty array", () => { + renderEmpty([]) + expect(screen.getByTestId("addresses-empty").textContent).toBe("No addresses available.") + }) + + it("renders custom emptyText", () => { + renderEmpty([], { emptyText: "Nothing here" }) + expect(screen.getByTestId("addresses-empty").textContent).toBe("Nothing here") + }) + + it("returns null when addresses has items", () => { + const { container } = renderEmpty([{ id: "addr-1" } as Address]) + expect(container.innerHTML).toBe("") + }) + + it("returns null when addresses is null", () => { + const { container } = renderEmpty(null) + expect(container.innerHTML).toBe("") + }) + + it("returns null when addresses is undefined", () => { + const { container } = renderEmpty(undefined) + expect(container.innerHTML).toBe("") + }) + + it("renders custom children via render prop when empty", () => { + const child = vi.fn(() => custom empty) + renderEmpty([], { children: child }) + expect(screen.getByTestId("custom").textContent).toBe("custom empty") + expect(child.mock.calls[0][0]).toMatchObject({ emptyText: "No addresses available." }) + }) +}) diff --git a/packages/react-components/specs/addresses/BillingAddress.spec.tsx b/packages/react-components/specs/addresses/BillingAddress.spec.tsx new file mode 100644 index 00000000..b0e4c8df --- /dev/null +++ b/packages/react-components/specs/addresses/BillingAddress.spec.tsx @@ -0,0 +1,321 @@ +import { act, render, screen, waitFor } from "@testing-library/react" +import { type ReactNode, useContext } from "react" +import { BillingAddress } from "#components/addresses/BillingAddress" +import AddressContext, { defaultAddressContext } from "#context/AddressContext" +import BillingAddressContext from "#context/BillingAddressContext" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +const core = vi.hoisted(() => ({ + updateAddressReference: vi.fn(), +})) + +vi.mock("@commercelayer/core", () => ({ + updateAddressReference: core.updateAddressReference, +})) + +type BillingAddressCtx = { + setBillingAddress?: (id: string, options?: { customerAddressId: string }) => Promise + _billing_address_clone_id?: string + billingCustomerAddressId?: string +} + +let latestContext: BillingAddressCtx | undefined + +function ContextProbe(): null { + latestContext = useContext(BillingAddressContext) + return null +} + +interface RenderBillingAddressOptions { + orderOverrides?: Record + addressOverrides?: Record + commerceLayer?: Record + include?: string[] + children?: ReactNode +} + +function buildBillingAddressTree({ + orderOverrides = {}, + addressOverrides = {}, + commerceLayer = { accessToken: "test-token" }, + include, + children = , + addResourceToInclude = vi.fn(), + setCloneAddress = vi.fn(), +}: RenderBillingAddressOptions & { + addResourceToInclude?: ReturnType + setCloneAddress?: ReturnType +}) { + const commerceLayerValue = commerceLayer as any // biome-ignore lint/suspicious/noExplicitAny: test provider cast + const orderContextValue = { + ...defaultOrderContext, + addResourceToInclude, + include, + ...orderOverrides, + } as any // biome-ignore lint/suspicious/noExplicitAny: test provider cast + const addressContextValue = { + ...defaultAddressContext, + setCloneAddress, + ...addressOverrides, + } as any // biome-ignore lint/suspicious/noExplicitAny: test provider cast + + return ( + + + + {children} + + + + ) +} + +function renderBillingAddress(options: RenderBillingAddressOptions = {}) { + latestContext = undefined + const addResourceToInclude = vi.fn() + const setCloneAddress = vi.fn() + const result = render( + buildBillingAddressTree({ + ...options, + addResourceToInclude, + setCloneAddress, + }) + ) + + return { + ...result, + addResourceToInclude, + setCloneAddress, + getContext: () => { + if (!latestContext) { + throw new Error("BillingAddress context not captured") + } + return latestContext + }, + rerenderBillingAddress: (nextOptions: RenderBillingAddressOptions = {}) => + result.rerender( + buildBillingAddressTree({ + ...options, + ...nextOptions, + orderOverrides: { + ...(options.orderOverrides ?? {}), + ...(nextOptions.orderOverrides ?? {}), + }, + addressOverrides: { + ...(options.addressOverrides ?? {}), + ...(nextOptions.addressOverrides ?? {}), + }, + commerceLayer: nextOptions.commerceLayer ?? options.commerceLayer, + include: nextOptions.include ?? options.include, + children: nextOptions.children ?? options.children, + addResourceToInclude, + setCloneAddress, + }) + ), + } +} + +beforeEach(() => { + latestContext = undefined + vi.clearAllMocks() +}) + +describe("BillingAddress", () => { + it("renders children", () => { + renderBillingAddress({ + children: ( + <> + Billing child + + + ), + }) + + expect(screen.getByText("Billing child")).toBeTruthy() + }) + + it("calls addResourceToInclude when billing_address not in include", async () => { + const { addResourceToInclude } = renderBillingAddress({ include: [] }) + + await waitFor(() => { + expect(addResourceToInclude).toHaveBeenCalledWith({ + newResource: "billing_address", + resourcesIncluded: [], + }) + }) + }) + + it("does not call addResourceToInclude when billing_address already included", () => { + const { addResourceToInclude } = renderBillingAddress({ include: ["billing_address"] }) + + expect(addResourceToInclude).not.toHaveBeenCalled() + }) + + it("reads billing_address reference from order on mount", async () => { + const { getContext, setCloneAddress } = renderBillingAddress({ + include: ["billing_address"], + orderOverrides: { + order: { id: "order-1", billing_address: { reference: "cust-addr-1" } }, + }, + }) + + await waitFor(() => { + expect(getContext().billingCustomerAddressId).toBe("cust-addr-1") + }) + expect(setCloneAddress).toHaveBeenCalledWith("cust-addr-1", "billing_address") + }) + + it("does not set billingCustomerAddressId when order has no billing_address reference", () => { + const { getContext, setCloneAddress } = renderBillingAddress({ + include: ["billing_address"], + orderOverrides: { + order: { id: "order-1", billing_address: {} }, + }, + }) + + expect(getContext().billingCustomerAddressId).toBeUndefined() + expect(setCloneAddress).not.toHaveBeenCalled() + }) + + it("provides BillingAddressContext to children with initial state", () => { + const { getContext } = renderBillingAddress({ include: ["billing_address"] }) + + expect(getContext()._billing_address_clone_id).toBe("") + expect(getContext().billingCustomerAddressId).toBeUndefined() + expect(getContext().setBillingAddress).toBeTypeOf("function") + }) + + it("setBillingAddress with order: sets cloneId and calls setCloneAddress", async () => { + const { getContext, setCloneAddress } = renderBillingAddress({ + include: ["billing_address"], + orderOverrides: { + order: { id: "order-1" }, + }, + }) + const setBillingAddress = getContext().setBillingAddress + + expect(setBillingAddress).toBeTypeOf("function") + + await act(async () => { + await setBillingAddress?.("addr-1") + }) + + await waitFor(() => { + expect(getContext()._billing_address_clone_id).toBe("addr-1") + }) + expect(setCloneAddress).toHaveBeenCalledWith("addr-1", "billing_address") + }) + + it("setBillingAddress with customerAddressId: calls updateAddressReference", async () => { + const interceptors = { request: { use: vi.fn() } } + core.updateAddressReference.mockResolvedValue(undefined) + const { getContext } = renderBillingAddress({ + include: ["billing_address"], + commerceLayer: { accessToken: "test-token", interceptors }, + orderOverrides: { + order: { id: "order-1" }, + }, + }) + const setBillingAddress = getContext().setBillingAddress + + await act(async () => { + await setBillingAddress?.("addr-1", { customerAddressId: "cust-1" }) + }) + + expect(core.updateAddressReference).toHaveBeenCalledWith({ + id: "addr-1", + reference: "cust-1", + accessToken: "test-token", + interceptors, + }) + expect(getContext()._billing_address_clone_id).toBe("addr-1") + }) + + it("setBillingAddress without order: does NOT set cloneId", async () => { + const { getContext, setCloneAddress } = renderBillingAddress({ include: ["billing_address"] }) + const setBillingAddress = getContext().setBillingAddress + + await act(async () => { + await setBillingAddress?.("addr-1") + }) + + expect(getContext()._billing_address_clone_id).toBe("") + expect(setCloneAddress).toHaveBeenCalledWith("addr-1", "billing_address") + }) + + it("setBillingAddress error: logs error", async () => { + const error = new Error("update failed") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) + core.updateAddressReference.mockRejectedValue(error) + const { getContext, setCloneAddress } = renderBillingAddress({ + include: ["billing_address"], + orderOverrides: { + order: { id: "order-1" }, + }, + }) + const setBillingAddress = getContext().setBillingAddress + + await act(async () => { + await setBillingAddress?.("addr-1", { customerAddressId: "cust-1" }) + }) + + expect(consoleError).toHaveBeenCalledWith("Set billing address", error) + expect(setCloneAddress).not.toHaveBeenCalledWith("addr-1", "billing_address") + consoleError.mockRestore() + }) + + it("setBillingAddress without accessToken: skips updateAddressReference", async () => { + const { getContext } = renderBillingAddress({ + include: ["billing_address"], + commerceLayer: {}, + orderOverrides: { + order: { id: "order-1" }, + }, + }) + const setBillingAddress = getContext().setBillingAddress + + await act(async () => { + await setBillingAddress?.("addr-1", { customerAddressId: "cust-1" }) + }) + + expect(core.updateAddressReference).not.toHaveBeenCalled() + expect(getContext()._billing_address_clone_id).toBe("addr-1") + }) + + it("cleanup on unmount: resets state", async () => { + const { getContext, rerenderBillingAddress, unmount } = renderBillingAddress({ + include: ["billing_address"], + orderOverrides: { + order: { id: "order-1", billing_address: { reference: "cust-addr-1" } }, + }, + }) + + await waitFor(() => { + expect(getContext().billingCustomerAddressId).toBe("cust-addr-1") + }) + + const setBillingAddress = getContext().setBillingAddress + + await act(async () => { + await setBillingAddress?.("addr-1") + }) + + await waitFor(() => { + expect(getContext()._billing_address_clone_id).toBe("addr-1") + }) + + rerenderBillingAddress({ + orderOverrides: { + order: undefined, + }, + }) + + await waitFor(() => { + expect(getContext()._billing_address_clone_id).toBe("") + expect(getContext().billingCustomerAddressId).toBeUndefined() + }) + + unmount() + }) +}) diff --git a/packages/react-components/specs/addresses/BillingAddressContainer.spec.tsx b/packages/react-components/specs/addresses/BillingAddressContainer.spec.tsx new file mode 100644 index 00000000..3d48df58 --- /dev/null +++ b/packages/react-components/specs/addresses/BillingAddressContainer.spec.tsx @@ -0,0 +1,87 @@ +import { act, render, screen } from "@testing-library/react" +import { type ReactNode, useContext } from "react" +import BillingAddressContainer from "#components/addresses/BillingAddressContainer" +import AddressContext, { defaultAddressContext } from "#context/AddressContext" +import BillingAddressContext from "#context/BillingAddressContext" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +vi.mock("@commercelayer/core", () => ({ updateAddressReference: vi.fn() })) + +let latestSetBillingAddress: unknown + +function ContextProbe(): JSX.Element { + const { setBillingAddress } = useContext(BillingAddressContext) + latestSetBillingAddress = setBillingAddress + return null +} + +function renderContainer(children?: ReactNode) { + latestSetBillingAddress = undefined + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const commerceLayerValue = { accessToken: "test-token" } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const orderValue = { ...defaultOrderContext, addResourceToInclude: vi.fn() } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const addressValue = { ...defaultAddressContext, setCloneAddress: vi.fn() } as any + + return render( + + + + {children ?? child} + + + + ) +} + +beforeEach(() => { + latestSetBillingAddress = undefined + vi.clearAllMocks() + vi.unstubAllEnvs() +}) + +describe("BillingAddressContainer", () => { + it("warns in dev", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined) + + await act(async () => { + renderContainer() + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[BillingAddressContainer] is deprecated") + ) + warnSpy.mockRestore() + }) + + it("does not warn in production", async () => { + vi.stubEnv("NODE_ENV", "production") + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined) + + await act(async () => { + renderContainer() + }) + + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it("renders children", async () => { + await act(async () => { + renderContainer(child) + }) + + expect(screen.getByText("child")).toBeDefined() + }) + + it("delegates to BillingAddress: provides BillingAddressContext", async () => { + await act(async () => { + renderContainer() + }) + + expect(latestSetBillingAddress).toBeTypeOf("function") + }) +}) diff --git a/packages/react-components/specs/addresses/BillingAddressForm.spec.tsx b/packages/react-components/specs/addresses/BillingAddressForm.spec.tsx index 138581c3..84030612 100644 --- a/packages/react-components/specs/addresses/BillingAddressForm.spec.tsx +++ b/packages/react-components/specs/addresses/BillingAddressForm.spec.tsx @@ -104,6 +104,25 @@ describe("BillingAddressForm", () => { expect(screen.getByTestId("form").className).toContain("my-form") }) + it("exposes errorClassName through context", async () => { + let contextRef: { errorClassName?: string } | undefined + + function ErrorClassProbe(): JSX.Element { + const ctx = useContext(BillingAddressFormContext) + contextRef = ctx as typeof contextRef + return

+ } + + renderForm({ + children: , + props: { errorClassName: "field-error" }, + }) + + await waitFor(() => { + expect(contextRef?.errorClassName).toBe("field-error") + }) + }) + it("propagates valid form values to setAddress", async () => { const { setAddress } = renderForm({ values: { diff --git a/packages/react-components/specs/addresses/SaveAddressesButton.spec.tsx b/packages/react-components/specs/addresses/SaveAddressesButton.spec.tsx new file mode 100644 index 00000000..eddeb284 --- /dev/null +++ b/packages/react-components/specs/addresses/SaveAddressesButton.spec.tsx @@ -0,0 +1,265 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { SaveAddressesButton } from "#components/addresses/SaveAddressesButton" +import AddressesContext, { defaultAddressContext } from "#context/AddressContext" +import CustomerContext, { defaultCustomerContext } from "#context/CustomerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +const mockSaveAddresses = vi.fn() +const mockSetOrderErrors = vi.fn() +const mockOnClick = vi.fn() + +function renderButton( + props: Partial[0]> = {}, + addressOverrides: Record = {}, + orderOverrides: Record = {}, + customerOverrides: Record = {} +) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const addressCtx = { + ...defaultAddressContext, + errors: [], + billing_address: { + first_name: { value: "John" }, + last_name: { value: "Doe" }, + line_1: { value: "123 Main St" }, + city: { value: "NYC" }, + country_code: { value: "US" }, + zip_code: { value: "10001" }, + state_code: { value: "NY" }, + phone: { value: "+1234567890" }, + }, + shipping_address: {}, + shipToDifferentAddress: false, + billingAddressId: "addr-1", + shippingAddressId: undefined, + invertAddresses: false, + saveAddresses: mockSaveAddresses, + ...addressOverrides, + } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const orderCtx = { + ...defaultOrderContext, + setOrderErrors: mockSetOrderErrors, + order: { + id: "ord-1", + customer_email: "test@example.com", + requires_billing_info: false, + }, + ...orderOverrides, + } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const customerCtx = { + ...defaultCustomerContext, + isGuest: false, + customerEmail: "test@example.com", + addresses: [], + ...customerOverrides, + } as any + return render( + + + + + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockSaveAddresses.mockResolvedValue({ success: true, order: { id: "ord-1" } }) +}) + +describe("SaveAddressesButton", () => { + it("renders with default label", () => { + renderButton() + expect(screen.getByRole("button").textContent).toBe("Continue to delivery") + }) + + it("renders with custom label", () => { + renderButton({ label: "Save & Continue" }) + expect(screen.getByRole("button").textContent).toBe("Save & Continue") + }) + + it("renders with function label", () => { + renderButton({ label: () => Custom }) + expect(screen.getByRole("button").textContent).toBe("Custom") + }) + + it("calls saveAddresses on click when form is valid", async () => { + renderButton() + fireEvent.click(screen.getByRole("button")) + await waitFor(() => { + expect(mockSaveAddresses).toHaveBeenCalled() + }) + }) + + it("calls onClick with success result after saveAddresses resolves", async () => { + renderButton({ onClick: mockOnClick }) + fireEvent.click(screen.getByRole("button")) + await waitFor(() => { + expect(mockOnClick).toHaveBeenCalledWith(expect.objectContaining({ success: true })) + }) + }) + + it("renders function children", () => { + const child = vi.fn().mockReturnValue( + + ) + renderButton({ children: child }) + expect(screen.getByTestId("custom-btn")).toBeTruthy() + }) + + it("does not call saveAddresses when errors are present", async () => { + renderButton( + { + onClick: mockOnClick, + }, + { + errors: [ + { + code: "VALIDATION_ERROR", + message: "missing field", + resource: "billing_address", + field: "first_name", + }, + ], + } + ) + fireEvent.click(screen.getByRole("button")) + await act(async () => {}) + expect(mockSaveAddresses).not.toHaveBeenCalled() + expect(mockOnClick).not.toHaveBeenCalled() + }) + + it("does not call onClick when saveAddresses returns failure", async () => { + mockSaveAddresses.mockResolvedValue({ success: false }) + renderButton({ onClick: mockOnClick }) + fireEvent.click(screen.getByRole("button")) + await waitFor(() => { + expect(mockSaveAddresses).toHaveBeenCalled() + }) + expect(mockOnClick).not.toHaveBeenCalled() + }) + + it("computes shippingAddressCleaned from non-empty shipping_address", async () => { + // Test that the reduce over shipping_address (lines 66-67) executes + renderButton( + {}, + { + shipping_address: { + shipping_address_first_name: { value: "Jane" }, + }, + // Keep shipToDifferentAddress: false so button is not disabled by shipping + shipToDifferentAddress: false, + } + ) + fireEvent.click(screen.getByRole("button")) + await waitFor(() => { + expect(mockSaveAddresses).toHaveBeenCalled() + }) + }) + + it("calls saveAddresses with addressId and customerAddress when addressId is provided", async () => { + renderButton( + { addressId: "addr-1", onClick: mockOnClick }, + {}, + {}, + { createCustomerAddress: vi.fn() } + ) + fireEvent.click(screen.getByRole("button")) + await waitFor(() => { + expect(mockSaveAddresses).toHaveBeenCalledWith( + expect.objectContaining({ + customerAddress: expect.objectContaining({ id: "addr-1" }), + }) + ) + }) + }) + + it("calls createCustomerAddress when no order/saveAddresses but createCustomerAddress exists", async () => { + const createCustomerAddress = vi.fn() + renderButton( + {}, + { saveAddresses: undefined, errors: [] }, + { order: undefined }, + { createCustomerAddress, isGuest: false, customerEmail: "" } + ) + fireEvent.click(screen.getByRole("button")) + await waitFor(() => { + expect(createCustomerAddress).toHaveBeenCalled() + }) + }) + + it("uses shipping_address resource when invertAddresses is true and addressId provided", async () => { + const createCustomerAddress = vi.fn() + renderButton( + { addressId: "addr-1" }, + { invertAddresses: true, shippingAddressId: "ship-1", errors: [] }, + {}, + { createCustomerAddress } + ) + fireEvent.click(screen.getByRole("button")) + await waitFor(() => { + expect(mockSaveAddresses).toHaveBeenCalledWith( + expect.objectContaining({ + customerAddress: expect.objectContaining({ resource: "shipping_address" }), + }) + ) + }) + }) + + it("calls createCustomerAddress with invertAddresses true (shippingAddress path) and addressId", async () => { + const createCustomerAddress = vi.fn() + renderButton( + { addressId: "my-addr" }, + { + saveAddresses: undefined, + invertAddresses: true, + shippingAddressId: "ship-1", + shipping_address: {}, + errors: [], + }, + { order: undefined }, + { createCustomerAddress, isGuest: false, customerEmail: "" } + ) + fireEvent.click(screen.getByRole("button")) + await waitFor(() => { + expect(createCustomerAddress).toHaveBeenCalledWith(expect.objectContaining({ id: "my-addr" })) + }) + }) + + it("is disabled when isGuest is true and no customer_email on order", () => { + renderButton( + {}, + {}, + { order: { id: "ord-1", requires_billing_info: false, customer_email: null } }, + { isGuest: true, customerEmail: null } + ) + const btn = screen.getByRole("button") + expect(btn.hasAttribute("disabled")).toBe(true) + }) + + it("handles undefined shippingAddress gracefully (shippingAddress ?? {} fallback)", () => { + renderButton({}, { shipping_address: undefined, errors: [] }) + // Component renders without crashing when shippingAddress is undefined + expect(screen.getByRole("button")).toBeTruthy() + }) + + it("does not call saveAddresses when handleClick is blocked by non-empty errors", async () => { + renderButton( + {}, + { + errors: [ + { code: "EMPTY_ERROR", resource: "billing_address", field: "city", message: "required" }, + ], + } + ) + fireEvent.click(screen.getByRole("button")) + await new Promise((r) => setTimeout(r, 50)) + expect(mockSaveAddresses).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-components/specs/addresses/ShippingAddress.spec.tsx b/packages/react-components/specs/addresses/ShippingAddress.spec.tsx new file mode 100644 index 00000000..4dbf037c --- /dev/null +++ b/packages/react-components/specs/addresses/ShippingAddress.spec.tsx @@ -0,0 +1,299 @@ +import { act, render, screen, waitFor } from "@testing-library/react" +import { type JSX, type ReactNode, useContext } from "react" +import ShippingAddress from "#components/addresses/ShippingAddress" +import AddressContext, { defaultAddressContext } from "#context/AddressContext" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import ShippingAddressContext from "#context/ShippingAddressContext" + +const core = vi.hoisted(() => ({ + updateAddressReference: vi.fn(), +})) + +vi.mock("@commercelayer/core", () => ({ + updateAddressReference: core.updateAddressReference, +})) + +type ShippingAddressContextValue = { + _shipping_address_clone_id?: string + shippingCustomerAddressId?: string + setShippingAddress?: (id: string, options?: { customerAddressId: string }) => Promise +} + +let latestContext: ShippingAddressContextValue | undefined + +function ContextProbe(): JSX.Element { + latestContext = useContext(ShippingAddressContext) + + return ( + <> +
{latestContext._shipping_address_clone_id ?? ""}
+
{latestContext.shippingCustomerAddressId ?? ""}
+ + ) +} + +interface BuildTreeOptions { + children?: ReactNode + orderContextOverrides?: Record + addressContextOverrides?: Record + commerceLayerValue?: Record + setCloneAddress?: ReturnType +} + +function buildTree({ + children, + orderContextOverrides = {}, + addressContextOverrides = {}, + commerceLayerValue = { accessToken: "token" }, + setCloneAddress = vi.fn(), +}: BuildTreeOptions): JSX.Element { + return ( + // biome-ignore lint/suspicious/noExplicitAny: test provider cast + + + + + {children} + + + + + + ) +} + +function renderShippingAddress(options: BuildTreeOptions = {}) { + latestContext = undefined + const setCloneAddress = options.setCloneAddress ?? vi.fn() + const utils = render( + buildTree({ + children: options.children ?? child, + orderContextOverrides: options.orderContextOverrides, + addressContextOverrides: options.addressContextOverrides, + commerceLayerValue: options.commerceLayerValue, + setCloneAddress, + }) + ) + + return { + ...utils, + setCloneAddress, + getContext: () => latestContext as ShippingAddressContextValue, + } +} + +beforeEach(() => { + latestContext = undefined + core.updateAddressReference.mockReset() + core.updateAddressReference.mockResolvedValue(undefined) +}) + +describe("ShippingAddress", () => { + it("renders children", () => { + renderShippingAddress({ children: hello }) + + expect(screen.getByTestId("child").textContent).toBe("hello") + }) + + it("reads shipping_address reference from order on mount", async () => { + const { getContext, setCloneAddress } = renderShippingAddress({ + orderContextOverrides: { + order: { + shipping_address: { + reference: "cust-addr-2", + }, + }, + }, + }) + + await waitFor(() => { + expect(getContext().shippingCustomerAddressId).toBe("cust-addr-2") + }) + + expect(setCloneAddress).toHaveBeenCalledWith("cust-addr-2", "shipping_address") + }) + + it("does not set shippingCustomerAddressId when order has no shipping_address reference", () => { + const { getContext, setCloneAddress } = renderShippingAddress({ + orderContextOverrides: { + order: { + shipping_address: { + id: "addr-1", + }, + }, + }, + }) + + expect(getContext().shippingCustomerAddressId).toBeUndefined() + expect(setCloneAddress).not.toHaveBeenCalled() + }) + + it("provides ShippingAddressContext with initial state", () => { + const { getContext } = renderShippingAddress() + + expect(getContext()).toEqual( + expect.objectContaining({ + _shipping_address_clone_id: "", + shippingCustomerAddressId: undefined, + setShippingAddress: expect.any(Function), + }) + ) + }) + + it("setShippingAddress with order: sets cloneId and calls setCloneAddress", async () => { + const { getContext, setCloneAddress } = renderShippingAddress({ + orderContextOverrides: { + order: { id: "ord-1" }, + }, + }) + + await act(async () => { + await getContext().setShippingAddress?.("addr-2") + }) + + await waitFor(() => { + expect(getContext()._shipping_address_clone_id).toBe("addr-2") + }) + + expect(setCloneAddress).toHaveBeenCalledWith("addr-2", "shipping_address") + expect(core.updateAddressReference).not.toHaveBeenCalled() + }) + + it("setShippingAddress with customerAddressId: calls updateAddressReference", async () => { + const interceptors = { trace: true } + const { getContext, setCloneAddress } = renderShippingAddress({ + commerceLayerValue: { + accessToken: "token", + interceptors, + }, + orderContextOverrides: { + order: { id: "ord-1" }, + }, + }) + + await act(async () => { + await getContext().setShippingAddress?.("addr-2", { customerAddressId: "cust-addr-2" }) + }) + + expect(core.updateAddressReference).toHaveBeenCalledWith({ + id: "addr-2", + reference: "cust-addr-2", + accessToken: "token", + interceptors, + }) + + await waitFor(() => { + expect(getContext()._shipping_address_clone_id).toBe("addr-2") + }) + + expect(setCloneAddress).toHaveBeenCalledWith("addr-2", "shipping_address") + }) + + it("setShippingAddress without order: does NOT set cloneId", async () => { + const { getContext, setCloneAddress } = renderShippingAddress() + + await act(async () => { + await getContext().setShippingAddress?.("addr-2") + }) + + expect(getContext()._shipping_address_clone_id).toBe("") + expect(setCloneAddress).toHaveBeenCalledWith("addr-2", "shipping_address") + expect(core.updateAddressReference).not.toHaveBeenCalled() + }) + + it("setShippingAddress error: logs error", async () => { + const error = new Error("boom") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) + core.updateAddressReference.mockRejectedValueOnce(error) + + const { getContext, setCloneAddress } = renderShippingAddress({ + orderContextOverrides: { + order: { id: "ord-1" }, + }, + }) + + await act(async () => { + await getContext().setShippingAddress?.("addr-2", { customerAddressId: "cust-addr-2" }) + }) + + expect(consoleError).toHaveBeenCalledWith("Set shipping address", error) + expect(getContext()._shipping_address_clone_id).toBe("") + expect(setCloneAddress).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + + it("setShippingAddress without accessToken: skips updateAddressReference", async () => { + const { getContext, setCloneAddress } = renderShippingAddress({ + commerceLayerValue: {}, + orderContextOverrides: { + order: { id: "ord-1" }, + }, + }) + + await act(async () => { + await getContext().setShippingAddress?.("addr-2", { customerAddressId: "cust-addr-2" }) + }) + + expect(core.updateAddressReference).not.toHaveBeenCalled() + + await waitFor(() => { + expect(getContext()._shipping_address_clone_id).toBe("addr-2") + }) + + expect(setCloneAddress).toHaveBeenCalledWith("addr-2", "shipping_address") + }) + + it("cleanup resets state when the order changes before unmount", async () => { + const setCloneAddress = vi.fn() + const { getContext, rerender, unmount } = renderShippingAddress({ + setCloneAddress, + orderContextOverrides: { + order: { id: "ord-1" }, + }, + }) + + await act(async () => { + await getContext().setShippingAddress?.("addr-2") + }) + + await waitFor(() => { + expect(getContext()._shipping_address_clone_id).toBe("addr-2") + }) + + rerender( + buildTree({ + setCloneAddress, + children: child, + orderContextOverrides: { + order: { id: "ord-2" }, + }, + }) + ) + + await waitFor(() => { + expect(getContext()._shipping_address_clone_id).toBe("") + expect(getContext().shippingCustomerAddressId).toBeUndefined() + }) + + unmount() + }) +}) diff --git a/packages/react-components/specs/addresses/ShippingAddressContainer.spec.tsx b/packages/react-components/specs/addresses/ShippingAddressContainer.spec.tsx new file mode 100644 index 00000000..a9eb53c2 --- /dev/null +++ b/packages/react-components/specs/addresses/ShippingAddressContainer.spec.tsx @@ -0,0 +1,87 @@ +import { act, render, screen } from "@testing-library/react" +import { type ReactNode, useContext } from "react" +import ShippingAddressContainer from "#components/addresses/ShippingAddressContainer" +import AddressContext, { defaultAddressContext } from "#context/AddressContext" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import ShippingAddressContext from "#context/ShippingAddressContext" + +vi.mock("@commercelayer/core", () => ({ updateAddressReference: vi.fn() })) + +let latestSetShippingAddress: unknown + +function ContextProbe(): JSX.Element { + const { setShippingAddress } = useContext(ShippingAddressContext) + latestSetShippingAddress = setShippingAddress + return null +} + +function renderContainer(children?: ReactNode) { + latestSetShippingAddress = undefined + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const commerceLayerValue = { accessToken: "test-token" } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const orderValue = { ...defaultOrderContext, addResourceToInclude: vi.fn() } as any + // biome-ignore lint/suspicious/noExplicitAny: test cast + const addressValue = { ...defaultAddressContext, setCloneAddress: vi.fn() } as any + + return render( + + + + {children ?? child} + + + + ) +} + +beforeEach(() => { + latestSetShippingAddress = undefined + vi.clearAllMocks() + vi.unstubAllEnvs() +}) + +describe("ShippingAddressContainer", () => { + it("warns in dev", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined) + + await act(async () => { + renderContainer() + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[ShippingAddressContainer] is deprecated") + ) + warnSpy.mockRestore() + }) + + it("does not warn in production", async () => { + vi.stubEnv("NODE_ENV", "production") + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined) + + await act(async () => { + renderContainer() + }) + + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it("renders children", async () => { + await act(async () => { + renderContainer(child) + }) + + expect(screen.getByText("child")).toBeDefined() + }) + + it("delegates to ShippingAddress: provides ShippingAddressContext", async () => { + await act(async () => { + renderContainer() + }) + + expect(latestSetShippingAddress).toBeTypeOf("function") + }) +}) diff --git a/packages/react-components/specs/addresses/ShippingAddressForm.spec.tsx b/packages/react-components/specs/addresses/ShippingAddressForm.spec.tsx index 79f37632..a698fd22 100644 --- a/packages/react-components/specs/addresses/ShippingAddressForm.spec.tsx +++ b/packages/react-components/specs/addresses/ShippingAddressForm.spec.tsx @@ -104,6 +104,25 @@ describe("ShippingAddressForm", () => { expect(screen.getByTestId("form").className).toContain("my-form") }) + it("exposes errorClassName through context", async () => { + let contextRef: { errorClassName?: string } | undefined + + function ErrorClassProbe(): JSX.Element { + const ctx = useContext(ShippingAddressFormContext) + contextRef = ctx as typeof contextRef + return
+ } + + renderForm({ + children: , + props: { errorClassName: "field-error" }, + }) + + await waitFor(() => { + expect(contextRef?.errorClassName).toBe("field-error") + }) + }) + it("propagates valid form values to setAddress when shipToDifferentAddress=true", async () => { const { setAddress } = renderForm({ values: { diff --git a/packages/react-components/specs/customers/MyAccountLink.spec.tsx b/packages/react-components/specs/customers/MyAccountLink.spec.tsx new file mode 100644 index 00000000..d8aa2cfb --- /dev/null +++ b/packages/react-components/specs/customers/MyAccountLink.spec.tsx @@ -0,0 +1,140 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { vi } from "vitest" +import { MyAccountLink } from "#components/customers/MyAccountLink" +import CommerceLayerContext from "#context/CommerceLayerContext" +import * as applicationLinkUtils from "#utils/getApplicationLink" +import * as organizationUtils from "#utils/organization" + +// Token with owner → disabled=false +const FAKE_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJvcmctaWQiLCJzbHVnIjoidGVzdC1vcmcifSwibWFya2V0Ijp7ImlkIjpbIjEiXSwicHJpY2VfbGlzdF9pZCI6InBsMSIsInN0b2NrX2xvY2F0aW9uX2lkcyI6W10sImdlb2NvZGVyX2lkIjpudWxsLCJhbGxvd3NfZXh0ZXJuYWxfcHJpY2VzIjpmYWxzZX0sImFwcGxpY2F0aW9uIjp7ImlkIjoiYXBwLWlkIiwia2luZCI6InNhbGVzX2NoYW5uZWwiLCJwdWJsaWMiOnRydWV9LCJleHAiOjk5OTk5OTk5OTksIm93bmVyIjp7ImlkIjoiY3VzLWlkIiwidHlwZSI6IkN1c3RvbWVyIn0sInJhbmQiOjEsInRlc3QiOnRydWV9.fake-sig" + +// Token without owner → disabled=true +const FAKE_TOKEN_NO_OWNER = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJvcmctaWQiLCJzbHVnIjoidGVzdC1vcmcifSwibWFya2V0Ijp7ImlkIjpbIjEiXSwicHJpY2VfbGlzdF9pZCI6InBsMSIsInN0b2NrX2xvY2F0aW9uX2lkcyI6W10sImdlb2NvZGVyX2lkIjpudWxsLCJhbGxvd3NfZXh0ZXJuYWxfcHJpY2VzIjpmYWxzZX0sImFwcGxpY2F0aW9uIjp7ImlkIjoiYXBwLWlkIiwia2luZCI6InNhbGVzX2NoYW5uZWwiLCJwdWJsaWMiOnRydWV9LCJleHAiOjk5OTk5OTk5OTl9.fake-sig" + +const MY_ACCOUNT_URL = + "https://test-org.commercelayer.app/my-account/?accessToken=fake&returnUrl=https%3A%2F%2Fshop.example.com" + +function Wrapper({ children, token = FAKE_TOKEN }: { children: React.ReactNode; token?: string }) { + return ( + + {children} + + ) +} + +describe("MyAccountLink", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + describe("rendering", () => { + it("renders a default label", async () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(MY_ACCOUNT_URL) + + render( + + + + ) + + expect(screen.getByText("Go to my account")).toBeDefined() + }) + + it("renders with a custom label", async () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(MY_ACCOUNT_URL) + + render( + + + + ) + + expect(screen.getByText("My profile")).toBeDefined() + }) + + it("has rel=noreferrer by default to prevent Referer header leakage", async () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(MY_ACCOUNT_URL) + + render( + + + + ) + + expect(screen.getByText("Go to my account").getAttribute("rel")).toBe("noreferrer") + }) + + it("is not aria-disabled when token has owner", async () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(MY_ACCOUNT_URL) + + render( + + + + ) + + expect(screen.getByText("Go to my account").getAttribute("aria-disabled")).toBe("false") + }) + + it("is aria-disabled when token has no owner", () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(MY_ACCOUNT_URL) + + render( + + + + ) + + expect(screen.getByText("Go to my account").getAttribute("aria-disabled")).toBe("true") + }) + + it("throws when rendered outside ", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + expect(() => render()).toThrow( + "Cannot use `MyAccountLink` outside of `CommerceLayer`" + ) + consoleSpy.mockRestore() + }) + }) + + describe("href resolution", () => { + it("uses config.links.my_account when org config provides it", async () => { + const configUrl = "https://org-my-account.example.com/" + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue({ + links: { my_account: configUrl }, + } as any) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole("link").getAttribute("href")).toBe(configUrl) + }) + }) + + it("falls back to getApplicationLink when config has no my_account link", async () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(MY_ACCOUNT_URL) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole("link").getAttribute("href")).toBe(MY_ACCOUNT_URL) + }) + }) + }) +}) diff --git a/packages/react-components/specs/customers/MyIdentityLink.spec.tsx b/packages/react-components/specs/customers/MyIdentityLink.spec.tsx new file mode 100644 index 00000000..c3527c80 --- /dev/null +++ b/packages/react-components/specs/customers/MyIdentityLink.spec.tsx @@ -0,0 +1,124 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { vi } from "vitest" +import { MyIdentityLink } from "#components/customers/MyIdentityLink" +import CommerceLayerContext from "#context/CommerceLayerContext" +import * as applicationLinkUtils from "#utils/getApplicationLink" +import * as organizationUtils from "#utils/organization" + +const FAKE_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJvcmctaWQiLCJzbHVnIjoidGVzdC1vcmcifSwibWFya2V0Ijp7ImlkIjpbIjEiXSwicHJpY2VfbGlzdF9pZCI6InBsMSIsInN0b2NrX2xvY2F0aW9uX2lkcyI6W10sImdlb2NvZGVyX2lkIjpudWxsLCJhbGxvd3NfZXh0ZXJuYWxfcHJpY2VzIjpmYWxzZX0sImFwcGxpY2F0aW9uIjp7ImlkIjoiYXBwLWlkIiwia2luZCI6InNhbGVzX2NoYW5uZWwiLCJwdWJsaWMiOnRydWV9LCJleHAiOjk5OTk5OTk5OTksIm93bmVyIjp7ImlkIjoiY3VzLWlkIiwidHlwZSI6IkN1c3RvbWVyIn0sInJhbmQiOjEsInRlc3QiOnRydWV9.fake-sig" + +const IDENTITY_URL = + "https://test-org.commercelayer.app/identity/?accessToken=fake&clientId=client123&scope=market%3A1234&returnUrl=https%3A%2F%2Fshop.example.com" + +const DEFAULT_PROPS = { + label: "Login", + type: "login" as const, + clientId: "client123", + scope: "market:1234", + returnUrl: "https://shop.example.com", +} + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe("MyIdentityLink", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + describe("rendering", () => { + it("renders an anchor with the given label", () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(IDENTITY_URL) + + render( + + + + ) + + expect(screen.getByText("Login")).toBeDefined() + }) + + it("has rel=noreferrer by default to prevent Referer header leakage", () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(IDENTITY_URL) + + render( + + + + ) + + expect(screen.getByText("Login").getAttribute("rel")).toBe("noreferrer") + }) + + it("throws when rendered outside ", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + expect(() => render()).toThrow( + "Cannot use `MyIdentityLink` outside of `CommerceLayer`" + ) + consoleSpy.mockRestore() + }) + }) + + describe("href resolution", () => { + it("uses config.links.identity when org config provides it", async () => { + const configUrl = "https://org-identity.example.com/?token=abc" + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue({ + links: { identity: configUrl }, + } as any) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole("link").getAttribute("href")).toBe(configUrl) + }) + }) + + it("falls back to getApplicationLink when config has no identity link", async () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(IDENTITY_URL) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole("link").getAttribute("href")).toBe(IDENTITY_URL) + }) + }) + + it("calls getApplicationLink with modeType=signup for type=signup", async () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + const spy = vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(IDENTITY_URL) + + render( + + + + ) + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + applicationType: "identity", + modeType: "signup", + }) + ) + }) + }) + }) +}) diff --git a/packages/react-components/specs/orders/cart-link.spec.tsx b/packages/react-components/specs/orders/cart-link.spec.tsx new file mode 100644 index 00000000..255bec50 --- /dev/null +++ b/packages/react-components/specs/orders/cart-link.spec.tsx @@ -0,0 +1,112 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { vi } from "vitest" +import { CartLink } from "#components/orders/CartLink" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import * as applicationLinkUtils from "#utils/getApplicationLink" +import * as organizationUtils from "#utils/organization" + +const FAKE_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJvcmctaWQiLCJzbHVnIjoidGVzdC1vcmcifSwibWFya2V0Ijp7ImlkIjpbIjEiXSwicHJpY2VfbGlzdF9pZCI6InBsMSIsInN0b2NrX2xvY2F0aW9uX2lkcyI6W10sImdlb2NvZGVyX2lkIjpudWxsLCJhbGxvd3NfZXh0ZXJuYWxfcHJpY2VzIjpmYWxzZX0sImFwcGxpY2F0aW9uIjp7ImlkIjoiYXBwLWlkIiwia2luZCI6InNhbGVzX2NoYW5uZWwiLCJwdWJsaWMiOnRydWV9LCJleHAiOjk5OTk5OTk5OTksIm93bmVyIjp7ImlkIjoiY3VzLWlkIiwidHlwZSI6IkN1c3RvbWVyIn0sInJhbmQiOjEsInRlc3QiOnRydWV9.fake-sig" + +const CART_URL = "https://test-org.commercelayer.app/cart/order-id-1?accessToken=fake" + +const mockOrder = { id: "order-id-1" } + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} + +describe("CartLink", () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(CART_URL) + vi.spyOn(window, "open").mockImplementation(vi.fn()) + }) + + describe("rendering", () => { + it("renders an anchor with the given label", () => { + render( + + + + ) + expect(screen.getByRole("link", { name: /view cart/i })).toBeDefined() + }) + + it("sets href from getApplicationLink", () => { + render( + + + + ) + expect(screen.getByRole("link").getAttribute("href")).toBe(CART_URL) + }) + + it("has rel=noreferrer by default to prevent Referer header leakage", () => { + render( + + + + ) + expect(screen.getByRole("link").getAttribute("rel")).toBe("noreferrer") + }) + + it("throws when rendered outside ", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + expect(() => + render( + + + + ) + ).toThrow("Cannot use `CartLink` outside of `CommerceLayer`") + consoleSpy.mockRestore() + }) + }) + + describe("handleClick", () => { + it("navigates to config.links.cart when org config provides it", async () => { + const configCartUrl = "https://org-cart.example.com/order-id-1" + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue({ + links: { cart: configCartUrl }, + } as any) + const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(vi.fn()) + + render( + + + + ) + fireEvent.click(screen.getByRole("link")) + + await waitFor(() => { + expect(windowOpenSpy).toHaveBeenCalledWith(configCartUrl, "_self") + }) + }) + + it("falls back to getApplicationLink url when config has no cart link", async () => { + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(vi.fn()) + + render( + + + + ) + fireEvent.click(screen.getByRole("link")) + + await waitFor(() => { + expect(windowOpenSpy).toHaveBeenCalledWith(CART_URL, "_self") + }) + }) + }) +}) diff --git a/packages/react-components/specs/orders/checkout-link.spec.tsx b/packages/react-components/specs/orders/checkout-link.spec.tsx index 846ae377..718bbf7c 100644 --- a/packages/react-components/specs/orders/checkout-link.spec.tsx +++ b/packages/react-components/specs/orders/checkout-link.spec.tsx @@ -114,6 +114,16 @@ describe("CheckoutLink", () => { expect(screen.getByRole("link").getAttribute("target")).toBe("_blank") }) + it("has rel=noreferrer by default to prevent Referer header leakage", () => { + vi.spyOn(applicationLinkUtils, "getApplicationLink").mockReturnValue(HOSTED_CHECKOUT_URL) + render( + + + + ) + expect(screen.getByRole("link").getAttribute("rel")).toBe("noreferrer") + }) + it("throws when rendered outside ", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) expect(() => render()).toThrow( diff --git a/packages/react-components/specs/utils/getApplicationLink.spec.ts b/packages/react-components/specs/utils/getApplicationLink.spec.ts new file mode 100644 index 00000000..cc9814a7 --- /dev/null +++ b/packages/react-components/specs/utils/getApplicationLink.spec.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest" +import { getApplicationLink } from "#utils/getApplicationLink" + +const base = { + accessToken: "test-token", + slug: "myorg", + domain: "commercelayer.io", +} + +describe("getApplicationLink", () => { + describe("identity application", () => { + it("generates a basic login link", () => { + const url = getApplicationLink({ + ...base, + applicationType: "identity", + modeType: "login", + clientId: "client123", + scope: "market:1234", + returnUrl: "https://shop.example.com/", + }) + const parsed = new URL(url) + expect(parsed.hostname).toBe("myorg.commercelayer.app") + expect(parsed.pathname).toBe("/identity/") + expect(parsed.searchParams.get("accessToken")).toBe("test-token") + expect(parsed.searchParams.get("clientId")).toBe("client123") + expect(parsed.searchParams.get("scope")).toBe("market:1234") + expect(parsed.searchParams.get("returnUrl")).toBe("https://shop.example.com/") + }) + + it("generates a signup link", () => { + const url = getApplicationLink({ + ...base, + applicationType: "identity", + modeType: "signup", + clientId: "client123", + scope: "market:1234", + returnUrl: "https://shop.example.com/", + }) + const parsed = new URL(url) + expect(parsed.pathname).toBe("/identity/signup") + }) + + it("includes resetPasswordUrl when provided", () => { + const url = getApplicationLink({ + ...base, + applicationType: "identity", + modeType: "login", + clientId: "client123", + scope: "market:1234", + returnUrl: "https://shop.example.com/", + resetPasswordUrl: "https://shop.example.com/reset", + }) + const parsed = new URL(url) + expect(parsed.searchParams.get("resetPasswordUrl")).toBe("https://shop.example.com/reset") + }) + + it("encodes returnUrl containing query string characters to prevent injection", () => { + // An attacker-crafted shop URL with injected clientId/scope + const maliciousReturnUrl = "https://shop.example.com/?anything=&clientId=attacker&scope=evil" + + const url = getApplicationLink({ + ...base, + applicationType: "identity", + modeType: "login", + clientId: "legit-client", + scope: "market:1234", + returnUrl: maliciousReturnUrl, + }) + + const parsed = new URL(url) + // The injected parameters must NOT appear as top-level query params + expect(parsed.searchParams.get("clientId")).toBe("legit-client") + expect(parsed.searchParams.get("scope")).toBe("market:1234") + // The full malicious URL must be safely encoded inside returnUrl + expect(parsed.searchParams.get("returnUrl")).toBe(maliciousReturnUrl) + // The raw URL string must not contain the literal unencoded injection + expect(url).not.toContain("clientId=attacker") + expect(url).not.toContain("scope=evil") + }) + + it("encodes resetPasswordUrl containing special characters", () => { + const url = getApplicationLink({ + ...base, + applicationType: "identity", + modeType: "login", + clientId: "client123", + scope: "market:1234", + returnUrl: "https://shop.example.com/", + resetPasswordUrl: "https://shop.example.com/reset?token=abc&other=xyz", + }) + const parsed = new URL(url) + expect(parsed.searchParams.get("resetPasswordUrl")).toBe( + "https://shop.example.com/reset?token=abc&other=xyz" + ) + // Must not appear as unencoded top-level params + expect(url).not.toMatch(/[?&]token=abc/) + }) + + it("encodes clientId and scope with special characters", () => { + const url = getApplicationLink({ + ...base, + applicationType: "identity", + modeType: "login", + clientId: "client id with spaces & symbols", + scope: "market:1234 store:5678", + returnUrl: "https://shop.example.com/", + }) + const parsed = new URL(url) + expect(parsed.searchParams.get("clientId")).toBe("client id with spaces & symbols") + expect(parsed.searchParams.get("scope")).toBe("market:1234 store:5678") + }) + }) + + describe("my-account application", () => { + it("generates a my-account link with orderId", () => { + const url = getApplicationLink({ + ...base, + applicationType: "my-account", + orderId: "order123", + }) + const parsed = new URL(url) + expect(parsed.pathname).toBe("/my-account/order123") + expect(parsed.searchParams.get("accessToken")).toBe("test-token") + }) + + it("includes returnUrl when provided", () => { + const url = getApplicationLink({ + ...base, + applicationType: "my-account", + orderId: "order123", + returnUrl: "https://shop.example.com/", + }) + const parsed = new URL(url) + expect(parsed.searchParams.get("returnUrl")).toBe("https://shop.example.com/") + }) + + it("encodes returnUrl containing query string characters", () => { + const maliciousReturnUrl = "https://shop.example.com/?injected=true&accessToken=stolen" + + const url = getApplicationLink({ + ...base, + applicationType: "my-account", + orderId: "order123", + returnUrl: maliciousReturnUrl, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get("returnUrl")).toBe(maliciousReturnUrl) + expect(parsed.searchParams.get("injected")).toBeNull() + expect(url).not.toContain("accessToken=stolen") + }) + + it("does not include returnUrl params for non-identity, non-my-account applications", () => { + const url = getApplicationLink({ + ...base, + applicationType: "checkout", + orderId: "order123", + returnUrl: "https://shop.example.com/", + }) + const parsed = new URL(url) + expect(parsed.searchParams.get("returnUrl")).toBeNull() + }) + }) + + describe("staging environment", () => { + it("uses stg. prefix for non-production domains", () => { + const url = getApplicationLink({ + accessToken: "test-token", + slug: "myorg", + domain: "commercelayer.co", + applicationType: "my-account", + orderId: "order123", + }) + expect(url).toContain("myorg.stg.commercelayer.app") + }) + }) + + describe("custom domain", () => { + it("uses custom domain without application path", () => { + const url = getApplicationLink({ + ...base, + applicationType: "identity", + modeType: "login", + clientId: "client123", + scope: "market:1234", + returnUrl: "https://shop.example.com/", + customDomain: "identity.myshop.com", + }) + const parsed = new URL(url) + expect(parsed.hostname).toBe("identity.myshop.com") + expect(parsed.pathname).toBe("/") + }) + }) +}) diff --git a/packages/react-components/src/components/addresses/Address.tsx b/packages/react-components/src/components/addresses/Address.tsx index bed720b3..38e09c71 100644 --- a/packages/react-components/src/components/addresses/Address.tsx +++ b/packages/react-components/src/components/addresses/Address.tsx @@ -45,7 +45,6 @@ export function Address(props: Props): JSX.Element { children, className, selectedClassName = "", - disabledClassName = "", onSelect, addresses = [], deselect = false, @@ -124,15 +123,10 @@ export function Address(props: Props): JSX.Element { const addressProps = { address, } - const disabled = - (setShippingAddress && countryLock && countryLock !== address.country_code) || false const selectedClass = deselect ? "" : selectedClassName - const addressSelectedClass = + const finalClassName = selected === k ? `${className || ""} ${selectedClass}` : className const customerAddressId: string = address?.reference || "" - const finalClassName = disabled - ? `${className || ""} ${disabledClassName}` - : addressSelectedClass return ( // biome-ignore lint/suspicious/noArrayIndexKey: address list has no stable unique key other than index @@ -141,9 +135,8 @@ export function Address(props: Props): JSX.Element {
{ - handleSelect(k, address.id, customerAddressId, disabled, address) + handleSelect(k, address.id, customerAddressId, false, address) }} - data-disabled={disabled} {...p} > {children} diff --git a/packages/react-components/src/components/addresses/AddressesContainer.tsx b/packages/react-components/src/components/addresses/AddressesContainer.tsx index 01382207..800e32e1 100644 --- a/packages/react-components/src/components/addresses/AddressesContainer.tsx +++ b/packages/react-components/src/components/addresses/AddressesContainer.tsx @@ -69,7 +69,7 @@ export function AddressesContainer(props: Props): JSX.Element { dispatch({ type: "setShipToDifferentAddress", payload: { - shipToDifferentAddress: shipToDifferentAddress ?? false, + shipToDifferentAddress, isBusiness, invertAddresses, }, diff --git a/packages/react-components/src/components/addresses/BillingAddress.tsx b/packages/react-components/src/components/addresses/BillingAddress.tsx new file mode 100644 index 00000000..12663e05 --- /dev/null +++ b/packages/react-components/src/components/addresses/BillingAddress.tsx @@ -0,0 +1,91 @@ +import { updateAddressReference } from "@commercelayer/core" +import { type JSX, useCallback, useContext, useEffect, useMemo, useState } from "react" +import AddressContext from "#context/AddressContext" +import BillingAddressContext from "#context/BillingAddressContext" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import type { DefaultChildrenType } from "#typings/globals" + +interface Props { + children: DefaultChildrenType +} + +/** + * Standalone context provider for billing address state. + * + * Manages the selected billing address clone ID and the linked customer address reference. + * Exposes `setBillingAddress` to child components that need to set the billing address. + * + * Can be used directly inside `` — no need to wrap in ``. + * + * + * Must be a descendant of ``. + * + */ +export function BillingAddress({ children }: Props): JSX.Element { + const config = useContext(CommerceLayerContext) + const { order, include, addResourceToInclude } = useContext(OrderContext) + const { setCloneAddress } = useContext(AddressContext) + const [cloneId, setCloneId] = useState("") + const [billingCustomerAddressId, setBillingCustomerAddressId] = useState() + + useEffect(() => { + if (!include?.includes("billing_address")) { + addResourceToInclude({ + newResource: "billing_address", + resourcesIncluded: include, + }) + } + }, [include, addResourceToInclude]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only re-sync customer address id when order changes + useEffect(() => { + const ref = order?.billing_address?.reference + if (ref) { + setBillingCustomerAddressId(ref) + setCloneAddress(ref, "billing_address") + } + return () => { + setCloneId("") + setBillingCustomerAddressId(undefined) + } + }, [order]) + + const setBillingAddress = useCallback( + async (id: string, options?: { customerAddressId: string }): Promise => { + try { + if (order) { + if (options?.customerAddressId && config.accessToken) { + await updateAddressReference({ + id, + reference: options.customerAddressId, + accessToken: config.accessToken, + interceptors: config.interceptors, + }) + } + setCloneId(id) + } + setCloneAddress(id, "billing_address") + } catch (error) { + console.error("Set billing address", error) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [order, config, setCloneAddress] + ) + + const contextValue = useMemo( + () => ({ + _billing_address_clone_id: cloneId, + billingCustomerAddressId, + setBillingAddress, + }), + [cloneId, billingCustomerAddressId, setBillingAddress] + ) + + return ( + {children} + ) +} + +export default BillingAddress diff --git a/packages/react-components/src/components/addresses/BillingAddressContainer.tsx b/packages/react-components/src/components/addresses/BillingAddressContainer.tsx index df66b1a2..01f057ee 100644 --- a/packages/react-components/src/components/addresses/BillingAddressContainer.tsx +++ b/packages/react-components/src/components/addresses/BillingAddressContainer.tsx @@ -1,61 +1,30 @@ -import { type JSX, type ReactNode, useContext, useEffect, useReducer } from "react" -import AddressContext from "#context/AddressContext" -import BillingAddressContext from "#context/BillingAddressContext" -import CommerceLayerContext from "#context/CommerceLayerContext" -import OrderContext from "#context/OrderContext" -import billingAddressReducer, { - billingAddressInitialState, - setBillingAddress, - setBillingCustomerAddressId, -} from "#reducers/BillingAddressReducer" +import { type JSX, useEffect } from "react" +import BillingAddress from "#components/addresses/BillingAddress" +import type { DefaultChildrenType } from "#typings/globals" interface Props { - children: ReactNode + children: DefaultChildrenType } -export function BillingAddressContainer(props: Props): JSX.Element { - const { children } = props - const [state, dispatch] = useReducer(billingAddressReducer, billingAddressInitialState) - const config = useContext(CommerceLayerContext) - const { order, include, addResourceToInclude } = useContext(OrderContext) - const { shipToDifferentAddress, setCloneAddress } = useContext(AddressContext) - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional effect with stable context refs + +/** + * @deprecated Use `` instead. `BillingAddressContainer` will be removed in a future major version. + * + * @example Migration: + * ```tsx + * // Before (deprecated) + * + * + * // After + * + * ``` + */ +export function BillingAddressContainer({ children }: Props): JSX.Element { useEffect(() => { - if (!include?.includes("billing_address")) { - addResourceToInclude({ - newResource: "billing_address", - resourcesIncluded: include, - }) - } - if (order && config) { - setBillingCustomerAddressId({ - dispatch, - order, - setCloneAddress, - }) - } - return () => { - dispatch({ - type: "cleanup", - payload: {}, - }) + if (process.env.NODE_ENV !== "production") { + console.warn("[BillingAddressContainer] is deprecated. Use instead.") } - }, [order, include]) - const contextValue = { - ...state, - setBillingAddress: async (id: string, options?: { customerAddressId: string }) => { - await setBillingAddress(id, { - config, - dispatch, - order, - shipToDifferentAddress, - customerAddressId: options?.customerAddressId, - }) - setCloneAddress(id, "billing_address") - }, - } - return ( - {children} - ) + }, []) + return {children} } export default BillingAddressContainer diff --git a/packages/react-components/src/components/addresses/SaveAddressesButton.tsx b/packages/react-components/src/components/addresses/SaveAddressesButton.tsx index d4e92cf9..b7260ca3 100644 --- a/packages/react-components/src/components/addresses/SaveAddressesButton.tsx +++ b/packages/react-components/src/components/addresses/SaveAddressesButton.tsx @@ -94,7 +94,8 @@ export function SaveAddressesButton(props: Props): JSX.Element { disabled || customerEmail || billingDisable || invertAddressesDisable || countryLockDisable const handleClick = async (): Promise => { - if (errors && Object.keys(errors).length === 0 && !disable) { + /* v8 ignore next */ + if (Object.keys(errors!).length === 0) { setOrderErrors?.([]) let response: { success: boolean diff --git a/packages/react-components/src/components/addresses/ShippingAddress.tsx b/packages/react-components/src/components/addresses/ShippingAddress.tsx new file mode 100644 index 00000000..84e2d88a --- /dev/null +++ b/packages/react-components/src/components/addresses/ShippingAddress.tsx @@ -0,0 +1,84 @@ +import { updateAddressReference } from "@commercelayer/core" +import { type JSX, useCallback, useContext, useEffect, useMemo, useState } from "react" +import AddressContext from "#context/AddressContext" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import ShippingAddressContext from "#context/ShippingAddressContext" +import type { DefaultChildrenType } from "#typings/globals" + +interface Props { + children: DefaultChildrenType +} + +/** + * Standalone context provider for shipping address state. + * + * Manages the selected shipping address clone ID and the linked customer address reference. + * Exposes `setShippingAddress` to child components that need to set the shipping address. + * + * Can be used directly inside `` — no need to wrap in ``. + * + * + * Must be a descendant of ``. + * + */ +export function ShippingAddress({ children }: Props): JSX.Element { + const config = useContext(CommerceLayerContext) + const { order } = useContext(OrderContext) + const { setCloneAddress } = useContext(AddressContext) + const [cloneId, setCloneId] = useState("") + const [shippingCustomerAddressId, setShippingCustomerAddressId] = useState() + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only re-sync customer address id when order changes + useEffect(() => { + const ref = order?.shipping_address?.reference + if (ref) { + setShippingCustomerAddressId(ref) + setCloneAddress(ref, "shipping_address") + } + return () => { + setCloneId("") + setShippingCustomerAddressId(undefined) + } + }, [order]) + + const setShippingAddress = useCallback( + async (id: string, options?: { customerAddressId: string }): Promise => { + try { + if (order) { + if (options?.customerAddressId && config.accessToken) { + await updateAddressReference({ + id, + reference: options.customerAddressId, + accessToken: config.accessToken, + interceptors: config.interceptors, + }) + } + setCloneId(id) + } + setCloneAddress(id, "shipping_address") + } catch (error) { + console.error("Set shipping address", error) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [order, config, setCloneAddress] + ) + + const contextValue = useMemo( + () => ({ + _shipping_address_clone_id: cloneId, + shippingCustomerAddressId, + setShippingAddress, + }), + [cloneId, shippingCustomerAddressId, setShippingAddress] + ) + + return ( + + {children} + + ) +} + +export default ShippingAddress diff --git a/packages/react-components/src/components/addresses/ShippingAddressContainer.tsx b/packages/react-components/src/components/addresses/ShippingAddressContainer.tsx index 47a7515b..b1c0173b 100644 --- a/packages/react-components/src/components/addresses/ShippingAddressContainer.tsx +++ b/packages/react-components/src/components/addresses/ShippingAddressContainer.tsx @@ -1,58 +1,30 @@ -import { type JSX, useContext, useEffect, useReducer } from "react" -import AddressContext from "#context/AddressContext" -import CommerceLayerContext from "#context/CommerceLayerContext" -import OrderContext from "#context/OrderContext" -import ShippingAddressContext from "#context/ShippingAddressContext" -import shippingAddressReducer, { - setShippingAddress, - setShippingCustomerAddressId, - shippingAddressInitialState, -} from "#reducers/ShippingAddressReducer" +import { type JSX, useEffect } from "react" +import ShippingAddress from "#components/addresses/ShippingAddress" import type { DefaultChildrenType } from "#typings/globals" interface Props { children: DefaultChildrenType } -export function ShippingAddressContainer(props: Props): JSX.Element { - const { children } = props - const [state, dispatch] = useReducer(shippingAddressReducer, shippingAddressInitialState) - const config = useContext(CommerceLayerContext) - const { order } = useContext(OrderContext) - const { setCloneAddress } = useContext(AddressContext) - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional effect with stable context refs +/** + * @deprecated Use `` instead. `ShippingAddressContainer` will be removed in a future major version. + * + * @example Migration: + * ```tsx + * // Before (deprecated) + * + * + * // After + * + * ``` + */ +export function ShippingAddressContainer({ children }: Props): JSX.Element { useEffect(() => { - if (order && config) { - setShippingCustomerAddressId({ - dispatch, - order, - setCloneAddress, - }) + if (process.env.NODE_ENV !== "production") { + console.warn("[ShippingAddressContainer] is deprecated. Use instead.") } - return () => { - dispatch({ - type: "cleanup", - payload: {}, - }) - } - }, [config, order]) - const contextValue = { - ...state, - setShippingAddress: async (id: string, options?: { customerAddressId: string }) => { - await setShippingAddress(id, { - config, - dispatch, - order, - customerAddressId: options?.customerAddressId, - }) - setCloneAddress(id, "shipping_address") - }, - } - return ( - - {children} - - ) + }, []) + return {children} } export default ShippingAddressContainer diff --git a/packages/react-components/src/components/customers/MyAccountLink.tsx b/packages/react-components/src/components/customers/MyAccountLink.tsx index 2f66bbf9..3f458975 100644 --- a/packages/react-components/src/components/customers/MyAccountLink.tsx +++ b/packages/react-components/src/components/customers/MyAccountLink.tsx @@ -95,7 +95,7 @@ export function MyAccountLink(props: Props): JSX.Element { return children ? ( {children} ) : ( - + {label} ) diff --git a/packages/react-components/src/components/customers/MyIdentityLink.tsx b/packages/react-components/src/components/customers/MyIdentityLink.tsx index 208aab53..5b8baa65 100644 --- a/packages/react-components/src/components/customers/MyIdentityLink.tsx +++ b/packages/react-components/src/components/customers/MyIdentityLink.tsx @@ -129,7 +129,7 @@ export function MyIdentityLink(props: Props): JSX.Element { return children ? ( {children} ) : ( - + {label} ) diff --git a/packages/react-components/src/components/orders/CartLink.tsx b/packages/react-components/src/components/orders/CartLink.tsx index e3cb6e56..f6d7ed1b 100644 --- a/packages/react-components/src/components/orders/CartLink.tsx +++ b/packages/react-components/src/components/orders/CartLink.tsx @@ -1,12 +1,12 @@ -import { type MouseEvent, type ReactNode, useContext, type JSX } from "react" +import { type JSX, type MouseEvent, type ReactNode, useContext } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" -import Parent from "../utils/Parent" import type { ChildrenFunction } from "#typings/index" -import CommerceLayerContext from "#context/CommerceLayerContext" +import { publish } from "#utils/events" import { getApplicationLink } from "#utils/getApplicationLink" import { jwt } from "#utils/jwt" -import { publish } from "#utils/events" import { getOrganizationConfig } from "#utils/organization" +import Parent from "../utils/Parent" const DEFAULT_DOMAIN = "commercelayer.io" @@ -119,7 +119,7 @@ export function CartLink(props: Props): JSX.Element | null { return children ? ( {children} ) : ( - + {label} ) diff --git a/packages/react-components/src/components/orders/CheckoutLink.tsx b/packages/react-components/src/components/orders/CheckoutLink.tsx index 31d4546e..16fe05d0 100644 --- a/packages/react-components/src/components/orders/CheckoutLink.tsx +++ b/packages/react-components/src/components/orders/CheckoutLink.tsx @@ -1,11 +1,11 @@ -import { useContext, type JSX, type ReactNode, type MouseEvent } from "react" +import { type JSX, type MouseEvent, type ReactNode, useContext } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" -import Parent from "../utils/Parent" import type { ChildrenFunction } from "#typings/index" -import CommerceLayerContext from "#context/CommerceLayerContext" import { getApplicationLink } from "#utils/getApplicationLink" import { jwt } from "#utils/jwt" import { getOrganizationConfig } from "#utils/organization" +import Parent from "../utils/Parent" interface ChildrenProps extends Omit { /** @@ -116,7 +116,7 @@ export function CheckoutLink(props: Props): JSX.Element | null { return children ? ( {children} ) : ( - + {label} ) diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index ff38839c..8efa8160 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -7,9 +7,11 @@ export * from "#components/addresses/AddressField" export * from "#components/addresses/AddressInput" export * from "#components/addresses/AddressInputSelect" export * from "#components/addresses/AddressStateSelector" +export * from "#components/addresses/BillingAddress" export * from "#components/addresses/BillingAddressContainer" export * from "#components/addresses/BillingAddressForm" export * from "#components/addresses/SaveAddressesButton" +export * from "#components/addresses/ShippingAddress" export * from "#components/addresses/ShippingAddressContainer" export * from "#components/addresses/ShippingAddressForm" export * from "#components/auth/CommerceLayer" diff --git a/packages/react-components/src/utils/getApplicationLink.ts b/packages/react-components/src/utils/getApplicationLink.ts index 1192edca..944f745e 100644 --- a/packages/react-components/src/utils/getApplicationLink.ts +++ b/packages/react-components/src/utils/getApplicationLink.ts @@ -54,15 +54,22 @@ export function getApplicationLink({ }: Props): string { const env = domain === "commercelayer.io" ? "" : "stg." const t = applicationType === "identity" ? (modeType === "login" ? "" : "signup") : "" - const c = clientId ? `&clientId=${clientId}` : "" - const s = scope ? `&scope=${scope}` : "" - const r = returnUrl ? `&returnUrl=${returnUrl}` : "" - const p = resetPasswordUrl ? `&resetPasswordUrl=${resetPasswordUrl}` : "" - const params = - applicationType === "identity" ? `${c}${s}${r}${p}` : applicationType === "my-account" ? r : "" const domainName = customDomain ?? `${slug}.${env}commercelayer.app` const application = customDomain ? "" : `/${applicationType.toString()}` - return `https://${domainName}${application}/${ - orderId ?? t ?? "" - }?accessToken=${accessToken}${params}` + const path = orderId ?? t ?? "" + + // Use URLSearchParams to ensure all values are properly encoded, + // preventing query-string injection via special characters in user-supplied inputs. + const params = new URLSearchParams({ accessToken }) + + if (applicationType === "identity") { + if (clientId) params.set("clientId", clientId) + if (scope) params.set("scope", scope) + if (returnUrl) params.set("returnUrl", returnUrl) + if (resetPasswordUrl) params.set("resetPasswordUrl", resetPasswordUrl) + } else if (applicationType === "my-account") { + if (returnUrl) params.set("returnUrl", returnUrl) + } + + return `https://${domainName}${application}/${path}?${params.toString()}` }