From 1c5acb3b61d766ba875052108a28645881df2797 Mon Sep 17 00:00:00 2001 From: Alessandro Casazza Date: Wed, 8 Apr 2026 11:04:58 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=90=9B=20fix(HostedCart):=20refresh?= =?UTF-8?q?=20minicart=20URL=20when=20persistKey=20changes=20(#685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/orders/hosted-cart.spec.tsx | 96 ++++++++ .../src/components/orders/HostedCart.tsx | 217 +++++++++--------- 2 files changed, 209 insertions(+), 104 deletions(-) create mode 100644 packages/react-components/specs/orders/hosted-cart.spec.tsx diff --git a/packages/react-components/specs/orders/hosted-cart.spec.tsx b/packages/react-components/specs/orders/hosted-cart.spec.tsx new file mode 100644 index 00000000..eab43a86 --- /dev/null +++ b/packages/react-components/specs/orders/hosted-cart.spec.tsx @@ -0,0 +1,96 @@ +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import OrderStorageContext from "#context/OrderStorageContext" +import { HostedCart } from "#components/orders/HostedCart" +import * as organizationUtils from "#utils/organization" +import * as applicationLinkUtils from "#utils/getApplicationLink" +import { render, waitFor } from "@testing-library/react" +import { vi } from "vitest" + +vi.mock("iframe-resizer", () => ({ + iframeResizer: vi.fn(), +})) + +describe("HostedCart component", () => { + beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() + }) + + it("updates minicart url when persistKey changes", async () => { + localStorage.setItem("cart-key-1", "order-id-1") + localStorage.setItem("cart-key-2", "order-id-2") + + vi.spyOn(organizationUtils, "getOrganizationConfig").mockResolvedValue(null) + + const getApplicationLinkSpy = vi + .spyOn(applicationLinkUtils, "getApplicationLink") + .mockImplementation( + ({ orderId }) => `https://test-cart.local/cart/${orderId}`, + ) + + const orderContextValue = { + ...defaultOrderContext, + createOrder: vi.fn().mockResolvedValue("created-order-id"), + } + + const commonProps = { + clearWhenPlaced: true, + getLocalOrder: vi.fn(), + setLocalOrder: vi.fn(), + deleteLocalOrder: vi.fn(), + } + + const { rerender } = render( + + + + + + + , + ) + + await waitFor(() => { + expect(getApplicationLinkSpy).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "order-id-1" }), + ) + }) + + rerender( + + + + + + + , + ) + + await waitFor(() => { + expect(getApplicationLinkSpy).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "order-id-2" }), + ) + }) + }) +}) diff --git a/packages/react-components/src/components/orders/HostedCart.tsx b/packages/react-components/src/components/orders/HostedCart.tsx index 0511bff4..c889d98d 100644 --- a/packages/react-components/src/components/orders/HostedCart.tsx +++ b/packages/react-components/src/components/orders/HostedCart.tsx @@ -1,26 +1,33 @@ -import CommerceLayerContext from '#context/CommerceLayerContext' -import OrderContext from '#context/OrderContext' -import OrderStorageContext from '#context/OrderStorageContext' -import { getApplicationLink } from '#utils/getApplicationLink' -import { getDomain } from '#utils/getDomain' -import useCustomContext from '#utils/hooks/useCustomContext' -import { type CSSProperties, useContext, useEffect, useState, useRef, type JSX } from 'react'; -import { iframeResizer } from 'iframe-resizer' -import type { Order } from '@commercelayer/sdk' -import { subscribe, unsubscribe } from '#utils/events' -import { getOrganizationConfig } from '#utils/organization' +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import OrderStorageContext from "#context/OrderStorageContext" +import { getApplicationLink } from "#utils/getApplicationLink" +import { getDomain } from "#utils/getDomain" +import useCustomContext from "#utils/hooks/useCustomContext" +import { + type CSSProperties, + useContext, + useEffect, + useState, + useRef, + type JSX, +} from "react" +import { iframeResizer } from "iframe-resizer" +import type { Order } from "@commercelayer/sdk" +import { subscribe, unsubscribe } from "#utils/events" +import { getOrganizationConfig } from "#utils/organization" interface IframeData { message: | { - type: 'update' + type: "update" payload?: Order } | { - type: 'close' + type: "close" } | { - type: 'blur' + type: "blur" } } @@ -33,50 +40,50 @@ interface Styles { } const defaultIframeStyle = { - width: '1px', - minWidth: '100%', - minHeight: '100%', - border: 'none', - paddingLeft: '20px', - paddingRight: '20px' + width: "1px", + minWidth: "100%", + minHeight: "100%", + border: "none", + paddingLeft: "20px", + paddingRight: "20px", } satisfies CSSProperties const defaultContainerStyle = { - position: 'fixed', - top: '0', - right: '-25rem', - height: '100%', - width: '23rem', - transition: 'right 0.5s ease-in-out', + position: "fixed", + top: "0", + right: "-25rem", + height: "100%", + width: "23rem", + transition: "right 0.5s ease-in-out", // zIndex: '0', - pointerEvents: 'none', - overflow: 'auto' + pointerEvents: "none", + overflow: "auto", } satisfies CSSProperties const defaultBackgroundStyle = { - opacity: '0', - position: 'fixed', - top: '0', - left: '0', - height: '100%', - width: '100vw', - transition: 'opacity 0.5s ease-in-out', + opacity: "0", + position: "fixed", + top: "0", + left: "0", + height: "100%", + width: "100vw", + transition: "opacity 0.5s ease-in-out", // zIndex: '-10', - pointerEvents: 'none', - backgroundColor: 'black' + pointerEvents: "none", + backgroundColor: "black", } satisfies CSSProperties const defaultIconStyle = { - width: '1.25rem', - height: '1.25rem' + width: "1.25rem", + height: "1.25rem", } satisfies CSSProperties const defaultIconContainer = { - textAlign: 'left', - paddingLeft: '20px', - paddingTop: '20px', - background: '#ffffff', - color: '#686E6E' + textAlign: "left", + paddingLeft: "20px", + paddingTop: "20px", + background: "#ffffff", + color: "#686E6E", } satisfies CSSProperties const defaultStyle = { @@ -84,11 +91,11 @@ const defaultStyle = { container: defaultContainerStyle, background: defaultBackgroundStyle, icon: defaultIconStyle, - iconContainer: defaultIconContainer + iconContainer: defaultIconContainer, } satisfies Styles interface Props - extends Omit { + extends Omit { /** * The style of the cart. */ @@ -100,7 +107,7 @@ interface Props /** * The type of the cart. Defaults to undefined. */ - type?: 'mini' + type?: "mini" /** * If true, the cart will open when a line item is added to the order clicking the add to cart button. Defaults to false. * Works only with the `type` prop set to `mini`. @@ -148,11 +155,12 @@ export function HostedCart({ }: Props): JSX.Element | null { const [isOpen, setOpen] = useState(false) const ref = useRef(null) + const loadedOrderIdRef = useRef(null) const { accessToken, endpoint } = useCustomContext({ context: CommerceLayerContext, - contextComponentName: 'CommerceLayer', - currentComponentName: 'HostedCart', - key: 'accessToken' + contextComponentName: "CommerceLayer", + currentComponentName: "HostedCart", + key: "accessToken", }) const [src, setSrc] = useState() if (accessToken == null || endpoint == null) return null @@ -166,11 +174,12 @@ export function HostedCart({ accessToken, endpoint, params: { - orderId: order?.id, + orderId: order?.id ?? orderId, accessToken, - slug - } + slug, + }, }) + loadedOrderIdRef.current = orderId setSrc( config?.links?.cart ?? getApplicationLink({ @@ -178,9 +187,9 @@ export function HostedCart({ orderId, accessToken, domain, - applicationType: 'cart', - customDomain - }) + applicationType: "cart", + customDomain, + }), ) if (openCart) { setTimeout(() => { @@ -192,35 +201,35 @@ export function HostedCart({ } function onMessage(data: IframeData): void { switch (data.message.type) { - case 'update': + case "update": if (data.message.payload != null) { getOrder(data.message.payload.id) } break - case 'close': - if (type === 'mini') { + case "close": + if (type === "mini") { if (handleOpen != null) handleOpen() else setOpen(false) } break - case 'blur': - if (type === 'mini' && isOpen) { + case "blur": + if (type === "mini" && isOpen) { ref.current?.focus() } break } } useEffect(() => { - const orderId = localStorage.getItem(persistKey) + const resolvedOrderId = order?.id ?? localStorage.getItem(persistKey) let ignore = false if (open != null && open !== isOpen) { setOpen(open) } - if (openAdd && type === 'mini') { - subscribe('open-cart', () => { - window.document.body.style.overflow = 'hidden' - if (src == null && order?.id == null && orderId == null) { + if (openAdd && type === "mini") { + subscribe("open-cart", () => { + window.document.body.style.overflow = "hidden" + if (src == null && resolvedOrderId == null) { setOrder(true) } else { if (src != null && ref.current != null) { @@ -235,36 +244,36 @@ export function HostedCart({ } if ( src == null && - order?.id == null && - orderId == null && + resolvedOrderId == null && accessToken != null && !ignore && isOpen ) { setOrder() } else if ( - src == null && - (order?.id != null || orderId != null) && - accessToken + resolvedOrderId != null && + accessToken && + (src == null || loadedOrderIdRef.current !== resolvedOrderId) ) { getOrganizationConfig({ accessToken, endpoint, params: { - orderId: order?.id, + orderId: resolvedOrderId, accessToken, - slug - } + slug, + }, }).then((config) => { + loadedOrderIdRef.current = resolvedOrderId setSrc( config?.links?.cart ?? getApplicationLink({ slug, - orderId: order?.id ?? orderId ?? '', + orderId: resolvedOrderId, accessToken, domain, - applicationType: 'cart' - }) + applicationType: "cart", + }), ) }) } @@ -273,42 +282,42 @@ export function HostedCart({ } return (): void => { ignore = true - if (openAdd && type === 'mini') { + if (openAdd && type === "mini") { // biome-ignore lint/suspicious/noEmptyBlockStatements: - unsubscribe('open-cart', () => {}) + unsubscribe("open-cart", () => {}) } } - }, [src, open, order?.id, accessToken]) + }, [src, open, order?.id, accessToken, persistKey]) useEffect(() => { if (ref.current == null) return iframeResizer( { checkOrigin: false, // @ts-expect-error No types available - onMessage + onMessage, }, - ref.current + ref.current, ) }, [ref.current != null]) /** * Close the cart. */ function onCloseCart(): void { - window.document.body.style.removeProperty('overflow') + window.document.body.style.removeProperty("overflow") if (handleOpen != null) handleOpen() else setOpen(false) } - return src == null ? null : type === 'mini' ? ( + return src == null ? null : type === "mini" ? ( <>