diff --git a/lerna.json b/lerna.json index 1a9ee8f7..4358a6fb 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useNx": false, "npmClient": "pnpm", - "version": "4.29.6", + "version": "4.29.7-beta.3", "command": { "version": { "preid": "beta" diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 99cf9a70..96701cf5 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -1,6 +1,6 @@ { "name": "@commercelayer/react-components", - "version": "4.29.6", + "version": "4.29.7-beta.3", "description": "The Official Commerce Layer React Components", "main": "lib/cjs/index.js", "module": "lib/esm/index.js", @@ -200,7 +200,7 @@ "homepage": "https://github.com/commercelayer/commercelayer-react-components#readme", "dependencies": { "@adyen/adyen-web": "^6.28.0", - "@commercelayer/organization-config": "^2.4.0", + "@commercelayer/organization-config": "^2.8.4", "@commercelayer/sdk": "^6.46.0", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.1", 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/customers/MyIdentityLink.tsx b/packages/react-components/src/components/customers/MyIdentityLink.tsx index 3b4e0312..bd79f80e 100644 --- a/packages/react-components/src/components/customers/MyIdentityLink.tsx +++ b/packages/react-components/src/components/customers/MyIdentityLink.tsx @@ -1,19 +1,19 @@ -import { useContext, useEffect, useState, type JSX } from 'react'; -import Parent from '../utils/Parent' -import type { ChildrenFunction } from '#typings/index' -import CommerceLayerContext from '#context/CommerceLayerContext' -import { getApplicationLink } from '#utils/getApplicationLink' -import { getDomain } from '#utils/getDomain' -import { getOrganizationConfig } from '#utils/organization' +import { type JSX, useContext, useEffect, useState } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" +import type { ChildrenFunction } from "#typings/index" +import { getApplicationLink } from "#utils/getApplicationLink" +import { getDomain } from "#utils/getDomain" +import { getOrganizationConfig } from "#utils/organization" +import Parent from "../utils/Parent" -interface ChildrenProps extends Omit { +interface ChildrenProps extends Omit { /** * The link href */ href: string } -interface Props extends Omit { +interface Props extends Omit { /** * A render function to render your own custom component */ @@ -25,7 +25,7 @@ interface Props extends Omit { /** * The type of the link */ - type: 'login' | 'signup' + type: "login" | "signup" /** * The client id of the Commerce Layer application */ @@ -78,17 +78,20 @@ export function MyIdentityLink(props: Props): JSX.Element { const { accessToken, endpoint } = useContext(CommerceLayerContext) const [href, setHref] = useState(undefined) if (accessToken == null || endpoint == null) - throw new Error('Cannot use `MyIdentityLink` outside of `CommerceLayer`') - const { domain, slug } = getDomain(endpoint) + throw new Error("Cannot use `MyIdentityLink` outside of `CommerceLayer`") useEffect(() => { if (accessToken && endpoint) { + const { domain, slug } = getDomain(endpoint) getOrganizationConfig({ accessToken, endpoint, params: { accessToken, - slug - } + slug, identityType: type, + clientId, + scope, + returnUrl: returnUrl ?? window.location.href, + resetPasswordUrl, }, }).then((config) => { if (config?.links?.identity) { setHref(config.links.identity) @@ -96,14 +99,14 @@ export function MyIdentityLink(props: Props): JSX.Element { const link = getApplicationLink({ slug, accessToken, - applicationType: 'identity', + applicationType: "identity", domain, modeType: type, clientId, scope, returnUrl: returnUrl ?? window.location.href, resetPasswordUrl, - customDomain + customDomain, }) setHref(link) } @@ -112,14 +115,14 @@ export function MyIdentityLink(props: Props): JSX.Element { return () => { setHref(undefined) } - }, [accessToken, endpoint]) + }, [accessToken, endpoint, type, clientId, scope, returnUrl, resetPasswordUrl, customDomain]) const parentProps = { label, href, clientId, scope, - ...p + ...p, } return children ? ( {children} diff --git a/packages/react-components/src/components/orders/AddToCartButton.tsx b/packages/react-components/src/components/orders/AddToCartButton.tsx index 709e0f06..1fe764a6 100644 --- a/packages/react-components/src/components/orders/AddToCartButton.tsx +++ b/packages/react-components/src/components/orders/AddToCartButton.tsx @@ -220,6 +220,8 @@ export function AddToCartButton(props: Props): JSX.Element { orderId, accessToken, slug, + skuListId, + skuId: sku?.id, }, }) location.href = diff --git a/packages/react-components/src/components/orders/HostedCart.tsx b/packages/react-components/src/components/orders/HostedCart.tsx index 0511bff4..9ebe2549 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 type { Order } from "@commercelayer/sdk" +import { iframeResizer } from "iframe-resizer" +import { + type CSSProperties, + type JSX, + useContext, + useEffect, + useRef, + useState, +} from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import OrderStorageContext from "#context/OrderStorageContext" +import { subscribe, unsubscribe } from "#utils/events" +import { getApplicationLink } from "#utils/getApplicationLink" +import { getDomain } from "#utils/getDomain" +import useCustomContext from "#utils/hooks/useCustomContext" +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,41 @@ export function HostedCart({ } return (): void => { ignore = true - if (openAdd && type === 'mini') { - // biome-ignore lint/suspicious/noEmptyBlockStatements: - unsubscribe('open-cart', () => {}) + if (openAdd && type === "mini") { + 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" ? ( <>