import React, {useEffect, useLayoutEffect, useRef, useState} from "react"; import { ComponentDataType, findTestable, NodeData, NodesStore, OfferData, RenderedNode, TestableCompositeData, TestableData, useNodesStore } from "./store"; import {CardRenderer, cardRenderers} from "./CardRenderers"; import {CardRendererCategoryType, CardRendererFromCardComponentDataDecorator} from "./CardRenderer"; import {useAtom, useAtomValue, useSetAtom} from "jotai"; import { AddNewTreePopupWindowIsOpen, atomsStore, BuyXGetYNotInCartNotificationWindowStateAtom, BuyXGetYSpecificProductWindowStateAtom, DocumentDimensionsAtom, ExamplesContext, ExamplesPopupWindowStateAtom, getDefaultClosedPopupWindowState, HorizontalOffsetAtom, leftSidebarWidthAtom, PopupWindowStateContext, rightSidebarWidthAtom, SelectActionContext, SelectActionPopupWindowStateAtom, SelectMenuPopupWindowsAtom, sidebarEdgeOffsetAtom, TestableGroupModePopupWindowStateAtom, TestablesPopupWindowsAtom, TreeContextData, VerticalOffsetAtom } from "./atoms"; import {Testables} from "./testables"; import {cloneDeep, merge} from "lodash"; import {Tree} from "./Tree"; import {Grove} from "./Grove"; import {Offers} from "./Offers"; import {ParentType} from "./components"; import {useOpenTestablesPopupWindow} from "./useOpenTestablesPopupWindow"; import {TestablesSaver} from "./TestablesSaver"; import {suggestedScopes} from "./suggestedScopes"; import type {ConditionColorPalette} from "./Colors"; export const useHover = ({onMouseEnter, onMouseLeave}) => { const ref = useRef(null) useEffect(() => { const element = ref.current; if (!element) return; element.addEventListener('mouseenter', onMouseEnter); element.addEventListener('mouseleave', onMouseLeave); return () => { element.removeEventListener('mouseenter', onMouseEnter); element.removeEventListener('mouseleave', onMouseLeave); }; }, [ref.current]); return ref; } export const useCardRenderer: (id: string | TestableData, where?: keyof NodesStore) => CardRenderer | undefined = (id, where) => { //const data = useNodesStore(findNode(id, where)) const testables = useLazyTestables() const offers = useLazyOffers() let testableData: NodeData | undefined let partialIdToUse: string | undefined let isLocalData = typeof id == 'object'; if (isLocalData) { testableData = id as TestableData } else if (where === 'testablePartials') { testableData = testables.getParent(id) partialIdToUse = id // @ts-ignore id = testableData?.id } else if (where === 'testables') { testableData = testables.getNode(id) } else if (where === 'offers') { testableData = offers.get(id) } if (!testableData) { return; } const [type, testableType, cardRenderer, resolvedId, partialId] = getRendererForContextTestable( id, testableData as TestableData, partialIdToUse ) return new CardRendererFromCardComponentDataDecorator(cardRenderer, testableType, resolvedId, partialId, isLocalData ? id as TestableData | OfferData : undefined) } const getRendererForContextTestable = (id: TestableCompositeData['id'] | TestableData, data: TestableData | (OfferData & ParentType), partialId?: string): [string, ComponentDataType, CardRenderer, string, string | undefined] => { const {type} = data const parentType: ComponentDataType = ('parentType' in data ? (data.parentType as string).replace(/s$/, '') : data.testableType) as ComponentDataType const cardRenderer = cardRenderers[`${parentType}s`][type] return [ type, parentType, cardRenderer, typeof id === 'object' ? id.id : id, partialId ] }; const getRendererForRenderedNode = (tree: Tree, id: string, data: RenderedNode): [string, CardRendererCategoryType, CardRenderer, string, string?] => { const {type} = data const renderedNode = data! if (type === 'tiered-base-node') { const testable = tree.testables.getNode(renderedNode.data.testableID!) as TestableData return getRendererForContextTestable(tree, testable.id, testable); } else /*if(type === 'tiered-subchild-node')*/ { // this is the tier subchild node const testablePartialId = renderedNode.data.testablePartialId; const baseTestable = tree.testables.getParent(testablePartialId) as TestableData; return getRendererForContextTestable(tree, baseTestable?.id, baseTestable, testablePartialId); } }; export const useDashboardDimensions = () => { const verticalOffset = useAtomValue(VerticalOffsetAtom) const horizontalOffset = useAtomValue(HorizontalOffsetAtom) const documentDimensions = useAtomValue(DocumentDimensionsAtom) return { y: verticalOffset, x: horizontalOffset, width: documentDimensions.width - horizontalOffset, // for some reason the height already includes the vertical offset height: documentDimensions.height, } } export type UseOnHeightCallback = (height: number) => void; export const useOnHeight = (onHeight?: UseOnHeightCallback) => { const ref = useRef(null) useEffect(() => { if (ref.current) { onHeight && onHeight(ref.current.getBoundingClientRect().height) } }, [ref.current]) return ref } export const useScreenNavigation = (defaultScreenId?: number) => { const screensNavigation = useState(defaultScreenId || 1) return [ ...screensNavigation, useNavigationActions(screensNavigation[0], screensNavigation[1]) ] } export const useNavigationActions = (currentScreenId: number, setCurrentScreenId: (newScreenId: number) => void) => { return { next: () => { if (currentScreenId) { setCurrentScreenId(currentScreenId + 1) } else { setCurrentScreenId(1) } }, prev: (): number => { let targetScreenId: number = currentScreenId; if (currentScreenId && currentScreenId > 1) { targetScreenId = currentScreenId - 1 } else if (currentScreenId !== 1) { targetScreenId = 1 } setCurrentScreenId(Math.max(1, targetScreenId - 1)) return targetScreenId; }, }; } export const useTestableData = (id: string) => { return useNodesStore(findTestable(id)) } // will not cause a rerender when the store changes // READ ONLY! export const useLazyTestables = (): Testables => { return getLazyTestables(); } export const getLazyTestables = (): Testables => { return new Testables(cloneDeep(useNodesStore.getState())) } export const useLazyOffers = (): Offers => { return new Offers(useNodesStore.getState()); } export const useLazyGrove = () => { return Grove.fromState(useNodesStore.getState()) } // will not cause a rerender when the store changes // READ ONLY! /*export const useLazyTree = (): Tree => { const state = cloneDeep(useNodesStore.getState()) return new Tree( state, // we're not using useLazyTestables here so as to avoid cloning the state again new Testables(state), ); }*/ export const useCloseSelectActionsPopupWindow = () => { const setSelectActionsPopupWindowState = useSetAtom(SelectActionPopupWindowStateAtom) return () => { setSelectActionsPopupWindowState(getDefaultClosedPopupWindowState()) } } export const useOpenSelectActionsPopupWindow = (action: SelectActionContext['action'] = null, targetBranchId: string, data: SelectActionContext['data'] = {}) => { const setSelectActionsPopupWindowState = useSetAtom(SelectActionPopupWindowStateAtom) return (_customData: SelectActionContext['data'] = {}) => { setSelectActionsPopupWindowState({ isOpen: true, context: { action, targetBranchId, data: merge({}, data || {}, _customData) } }) } } /** * Used outside a component */ export const closeSelectActionsPopupWindow = () => { atomsStore.set(SelectActionPopupWindowStateAtom, getDefaultClosedPopupWindowState()) } export const useOpenExamplesPopupWindow = () => { const setExamplesPopupWindowState = useSetAtom(ExamplesPopupWindowStateAtom) return (id: ExamplesContext['id']) => { setExamplesPopupWindowState({ isOpen: true, context: {id} }) } } export const useTreeDashboardAvailableDimensions = () => { const leftSidebarWidth = useAtomValue(leftSidebarWidthAtom) const rightSidebarWidth = useAtomValue(rightSidebarWidthAtom) const edgeOffset = useAtomValue(sidebarEdgeOffsetAtom) const leftOffset = leftSidebarWidth + (edgeOffset * 2) const rightOffset = rightSidebarWidth + (edgeOffset * 2) return { leftOffset, rightOffset, edgeOffset, } } export const useViewPortDimensionsStyle = () => { const [topBarOffset, setTopBarOffset] = useState(0) const [leftMenuOffset, setLeftMenuOffset] = useState(0) useEffect(() => { const adminMenu = document.querySelector('#adminmenu'); if (!adminMenu) return; const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { if (entry.target === adminMenu) { setLeftMenuOffset(entry.contentRect.width); } } }); resizeObserver.observe(adminMenu); return () => { resizeObserver.disconnect(); }; }, []) useLayoutEffect(() => { const topBarHeight = document.querySelector('#wpadminbar')?.getBoundingClientRect()?.height || 0 const leftBarWidth = document.querySelector('#adminmenu')?.getBoundingClientRect()?.width || 0 setTopBarOffset(topBarHeight) setLeftMenuOffset(leftBarWidth) }, []) return { top: topBarOffset, left: leftMenuOffset, height: `calc(100vh - ${topBarOffset}px)`, width: `calc(100vw - ${leftMenuOffset}px)`, } } /** * Hook to detect if the current viewport is mobile-sized (below md breakpoint: 768px) */ export const useIsMobile = (): boolean => { const [isMobile, setIsMobile] = useState(false) useLayoutEffect(() => { const mediaQuery = window.matchMedia('(max-width: 767px)') const handleChange = (e: MediaQueryListEvent | MediaQueryList) => { setIsMobile(e.matches) } // Set initial value handleChange(mediaQuery) // Listen for changes mediaQuery.addEventListener('change', handleChange) return () => { mediaQuery.removeEventListener('change', handleChange) } }, []) return isMobile } export const useOpenPopupsCount = () => { const testablePopUps = useAtomValue(TestablesPopupWindowsAtom) const testableGroupModePopupWindowState = useAtomValue(TestableGroupModePopupWindowStateAtom) const [{isOpen: selectIsOpen}] = useAtom(SelectActionPopupWindowStateAtom) const [addNewTreeIsOpen, setIsOpen] = useAtom(AddNewTreePopupWindowIsOpen) const [getYProductsWindowContext, ] = useAtom(BuyXGetYSpecificProductWindowStateAtom) const [getYNotificationWindowContext, ] = useAtom(BuyXGetYNotInCartNotificationWindowStateAtom) const selectMenuPopups = useAtomValue(SelectMenuPopupWindowsAtom) return testablePopUps.length + (testableGroupModePopupWindowState.isOpen ? 1 : 0) + (selectIsOpen ? 1: 0) + (addNewTreeIsOpen? 1 : 0) + (getYProductsWindowContext? 1 : 0) + (getYNotificationWindowContext? 1 : 0) + selectMenuPopups.length } /** * Non-reactive version of useOpenPopupsCount. Reads current atom values * without subscribing — safe to call inside event handlers without * causing re-renders of the calling component. */ export const getOpenPopupsCount = () => { const testablePopUps = atomsStore.get(TestablesPopupWindowsAtom) const testableGroupModePopupWindowState = atomsStore.get(TestableGroupModePopupWindowStateAtom) const {isOpen: selectIsOpen} = atomsStore.get(SelectActionPopupWindowStateAtom) const addNewTreeIsOpen = atomsStore.get(AddNewTreePopupWindowIsOpen) const getYProductsWindowContext = atomsStore.get(BuyXGetYSpecificProductWindowStateAtom) const getYNotificationWindowContext = atomsStore.get(BuyXGetYNotInCartNotificationWindowStateAtom) const selectMenuPopups = atomsStore.get(SelectMenuPopupWindowsAtom) return testablePopUps.length + (testableGroupModePopupWindowState.isOpen ? 1 : 0) + (selectIsOpen ? 1: 0) + (addNewTreeIsOpen? 1 : 0) + (getYProductsWindowContext? 1 : 0) + (getYNotificationWindowContext? 1 : 0) + selectMenuPopups.length } export const useOpenEditTestablePopup = ({ testableType, supportsMultipleComponents, scope = 'tree-node', supportedComponents, unsupportedComponents, conditionColorPalette, showTabs = false, }: { testableType: ComponentDataType, supportsMultipleComponents: boolean, scope?: PopupWindowStateContext['scope'], supportedComponents?: string[], unsupportedComponents?: string[], conditionColorPalette?: ConditionColorPalette, showTabs?: boolean, }) => { const openTestablePopupWindow = useOpenTestablesPopupWindow(); return (targetTestableId: string, suggestedScope: typeof suggestedScopes[number]['id']) => { // @ts-ignore const context = { id: 'context-edit', scope, data: { componentType: testableType, supportedComponents, conditionColorPalette, targetType: 'testableComposite', supportsMultipleComponents, unsupportedComponents, mode: 'edit', targetId: targetTestableId, showTabs, extraData: { suggestedScope, } } } as PopupWindowStateContext openTestablePopupWindow({ context, onClose: (data) => { if (data.status !== 'success' || !data.components) { return } // @ts-ignore const saver = new TestablesSaver(context) saver.update(data.components) } }); } } export const useOpenEditOfferPopup = (offerId: string) => { const openTestablePopupWindow = useOpenTestablesPopupWindow(); const updateOffer = useNodesStore(store => store.updateOffer) return () => { const context = { id: 'offer-card-edit', scope: 'tree-node', data: { componentType: 'offer', targetType: 'testableComposite', supportsMultipleComponents: false, mode: 'edit', targetId: offerId, } } as PopupWindowStateContext openTestablePopupWindow({ context, onClose: (data) => { const offerToUpdate = data.components?.[0] as unknown as OfferData; if (data.status === 'success' && offerToUpdate) { updateOffer(offerToUpdate) } } }) } }