import React, {FC, ReactNode, useLayoutEffect, useMemo, useRef, useState} from "react"; import {PopupWindow, ScreenProps} from "./PopupWindow"; import {useLazyOffers, useLazyTestables, useScreenNavigation} from "./tree/hooks"; import {__} from "./globals"; import {Fields, FieldUpdateAction} from "./Fields"; import { componentCategories, ComponentCategory, ComponentRuntimeDataWithParentType, getDefaultRuntimeComponentData } from "./tree/components"; import {cloneDeep, get, mapValues, set, values} from "lodash"; import {ToolTip} from "./ToolTip"; import {ContentWithSidebars} from "./ContentWithSidebars"; import {getCardRendererFromType, Note} from "./tree/cards"; import {AnimatePresence} from "motion/react"; import Fuse from "fuse.js"; import { CategorizedComponents, ComponentMeta, ComponentsMeta, ComponentType, getComponentMeta, getComponentMetaData, getComponentMetaDataField, getComponentsOrderedByCategory, hasImplementation } from "./tree/ComponentsMeta"; import {QuantityMeta} from "./tree/cards/Quantity"; import {PopupWindowsState, PopupWindowStateContext, TestablesPopupWindowsAtom, TreeContextData} from "./tree/atoms"; import {ComponentDataType, TestableData, TestablePartial} from "./tree/store"; import {getNamePrefixedForCardRenderer} from "./tree/CardRenderers"; import {AnyProductsMeta} from "./tree/cards/AnyProducts"; import {FieldType} from "./tree/fields"; import {SelectOption} from "./tree/fields/MultiSelectSearch"; import {Tabs, TabsProps} from "./tree/fields/tabs"; import {IconicTab} from "./tree/IconicTab"; import {useAtomValue} from "jotai"; import {addIdToComponent} from "./tree/NodeHelpers"; import {Button} from "./tree/cards/Button"; import {Context} from "./tree/Context"; import {decodeHtmlEntities, getLabelForComponentType, withDecodedEntities} from "./helpers"; import {NonImplementedMessage} from "./tree/actions/NonImplementedMessage"; import {ProBadge} from "./ProBadge"; import {FilterTabs} from "./tree/FilterTabs"; import {SelectableList} from "./SelectableList"; import {ProductsMeta, ProductsOptions} from "./tree/cards/Products"; import {getSuggestedScopeById, SuggestedScopeContext, suggestedScopes} from "./tree/suggestedScopes"; import classNames from "classnames"; import {SuggestedWrapper} from "./tree/SuggestedWrapper"; import {BrandsMeta} from "./tree/cards/Brands"; import {TagsMeta} from "./tree/cards/Tags"; import {AttributesMeta} from "./tree/cards/Attributes"; import {CategoriesMeta} from "./tree/cards/Categories"; import {TextPreSuFixed} from "./tree/TextPreSuFixed"; import {EmulatedButton} from "./tree/EmulatedButton"; import {ColorWithCustomTint, groupHover, hover, resolveTint} from "./tree/Color"; import {ConditionColorPalette, defaultConditionColorPalette, getColorForComponentType} from "./tree/Colors"; export type TestablesPopupWindowProps = { context: PopupWindowsState['context'], } & Pick const filtersMeta = ComponentsMeta.filters export enum SelectedFiltersStateType { onlyBase, baseAndQuantity, baseAndExtra, baseQuantityAndExtra } const getSelectedFiltersStateType = (filters: ComponentRuntimeDataWithParentType[]): SelectedFiltersStateType => { if (filters.length === 1) { return SelectedFiltersStateType.onlyBase; } else { if (filters.length === 2) { if (filters[1].type === 'Quantity') { return SelectedFiltersStateType.baseAndQuantity; } else { return SelectedFiltersStateType.baseAndExtra; } } else { // we have more than 2 if (filters[1].type === 'Quantity') { return SelectedFiltersStateType.baseQuantityAndExtra; } return SelectedFiltersStateType.baseAndExtra; } } } export const fieldsContentWidth = 114 * 4; type ScrollableListWithFadeProps = { children: ReactNode; maxHeight: number | string; className?: string; style?: React.CSSProperties; } const ScrollableListWithFade: FC = ({children, maxHeight, className, style}) => { const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) const bottomFadeRef = useRef(null) const bottomFadeVisibleRef = useRef(false) const animationFrameIdRef = useRef(null) const updateFadeVisibility = React.useCallback(() => { const container = scrollContainerRef.current const bottomFade = bottomFadeRef.current if (!container || !bottomFade) { return } const hasVerticalOverflow = container.scrollHeight > container.clientHeight + 1 const hasMoreItemsBelow = container.scrollTop + container.clientHeight < container.scrollHeight - 1 const shouldShowBottomFade = hasVerticalOverflow && hasMoreItemsBelow if (bottomFadeVisibleRef.current === shouldShowBottomFade) { return } bottomFadeVisibleRef.current = shouldShowBottomFade bottomFade.style.opacity = shouldShowBottomFade ? '1' : '0' }, []) const scheduleFadeVisibilityUpdate = React.useCallback(() => { if (animationFrameIdRef.current !== null) { return } animationFrameIdRef.current = requestAnimationFrame(() => { animationFrameIdRef.current = null updateFadeVisibility() }) }, [updateFadeVisibility]) useLayoutEffect(() => { scheduleFadeVisibilityUpdate() }, [children, maxHeight, scheduleFadeVisibilityUpdate]) useLayoutEffect(() => { const container = scrollContainerRef.current if (!container) { return } scheduleFadeVisibilityUpdate() const resizeObserver = new ResizeObserver(scheduleFadeVisibilityUpdate) resizeObserver.observe(container) if (scrollContentRef.current) { resizeObserver.observe(scrollContentRef.current) } container.addEventListener('scroll', scheduleFadeVisibilityUpdate, {passive: true}) window.addEventListener('resize', scheduleFadeVisibilityUpdate) return () => { resizeObserver.disconnect() container.removeEventListener('scroll', scheduleFadeVisibilityUpdate) window.removeEventListener('resize', scheduleFadeVisibilityUpdate) if (animationFrameIdRef.current !== null) { cancelAnimationFrame(animationFrameIdRef.current) animationFrameIdRef.current = null } } }, [scheduleFadeVisibilityUpdate]) return
{children}
} export type QuantityDecision = 'any' | 'limit' | null; const filterTabColor = getColorForComponentType('filter') const conditionTabColor = getColorForComponentType('condition') const offerTabColor = getColorForComponentType('offer') const getConditionPopupThemeClasses = (palette: ConditionColorPalette) => ({ tabSelectedBackground: `bg-${palette}-80`, tabSelectedText: `text-${palette}-normal`, popupTitle: 'text-gray-550',//`text-${palette}-shade-200`, popupLink: `text-${palette}-normal hover:text-${palette}-normal`, popupIconBackground: `bg-${palette}-normal`, popupIconText: 'text-white', listTitleHover: `--group-hover:text-${palette}-normal`, listCardHoverBackground: `hover:bg-${palette}-20 hover:bg-opacity-[.20]`, listCardHoverBorder: `hover:border-${palette}-70 hover:border-opacity-[.80]`, listCardIconBackground: `bg-gray-500 group-hover:bg-${palette}-normal`, listCardIconText: 'text-white group-hover:text-white', listCardExampleBullet: `bg-${palette}-40 group-hover:bg-${palette}-normal`, listRowHoverBorder: `hover:border-${palette}-70`, listRowIcon: `group-hover:text-${palette}-normal`, }) const defaultConditionPopupThemeClasses = getConditionPopupThemeClasses(defaultConditionColorPalette) const filterPopupThemeClasses = { listTitleHover: filterTabColor.text(groupHover(100)), listCardHoverBackground: classNames(filterTabColor.background(hover(20)), 'hover:bg-opacity-[.20]'), listCardHoverBorder: classNames(filterTabColor.border(hover(70)), 'hover:border-opacity-[.80]'), listCardIconBackground: classNames('bg-gray-500', filterTabColor.background(groupHover(100))), listCardIconText: 'text-white group-hover:text-white', listCardExampleBullet: classNames(filterTabColor.background(40), filterTabColor.background(groupHover(100))), listRowHoverBorder: filterTabColor.border(hover(70)), listRowIcon: filterTabColor.text(groupHover(100)), } const offerPopupThemeClasses = { tabIcon: 'text-[#6498ed]', tabSelectedBackground: 'bg-[#cddef9]', tabSelectedText: 'text-[#6498ed]', popupTitle: 'text-[#6498ed]', popupLink: 'text-[#6498ed] hover:text-[#6498ed]', popupIconBackground: 'bg-[#6498ed]', popupIconText: 'text-white', listTitleHover: 'group-hover:text-[#6498ed]', listCardHoverBackground: 'hover:bg-[#eef4fd] hover:bg-opacity-[.20]', listCardHoverBorder: 'hover:border-[#6498ed] hover:border-opacity-[.80]', listCardIconBackground: 'bg-gray-500 group-hover:bg-[#6498ed]', listCardIconText: 'text-white group-hover:text-white', listCardExampleBullet: 'bg-[#a4c3f4] group-hover:bg-[#367ae8]', listRowHoverBorder: 'hover:border-[#6498ed]', listRowIcon: 'group-hover:text-[#6498ed]', } const offerTabThemeColor: ColorWithCustomTint = { color: offerTabColor, tint: (color, type, context) => { switch (context) { case 'iconic-tab.selected.background': return offerPopupThemeClasses.tabSelectedBackground case 'iconic-tab.selected.text': return offerPopupThemeClasses.tabSelectedText default: return undefined } } } const conditionTabThemeColor: ColorWithCustomTint = { color: conditionTabColor, tint: (color, type, context) => { switch (context) { case 'iconic-tab.selected.background': return defaultConditionPopupThemeClasses.tabSelectedBackground case 'iconic-tab.selected.text': return defaultConditionPopupThemeClasses.tabSelectedText default: return undefined } } } const OfferIcon = ({className}: {className?: string}) => ( ); const PreconditionsIcon = ({className}: {className?: string}) => ( ); const PredatesIcon = ({className}: {className?: string}) => ( ); const tabs: TabsProps['tabs'] & { title: string | ((suggestedScope: string) => string | ReactNode) , description?: ReactNode }[] = [ { id: 'filter' as TestableData['testableType'], title: (suggestedScope: string) => { if (!suggestedScope) { return __('What Kind of Products?') } else if (suggestedScope === 'bogo') { return __('What Kind of Products Must the Customer Buy?') } else if (suggestedScope === 'item_discounts') { return __('What Kind of Products to Discount?') } else if (suggestedScope === 'cart_discounts') { return __('What Kind of Products Must the Customer Buy to Qualify for the Cart Discount?'/*'Does the customer need to have specific products in their cart to be eligible for the discount?'*/) } }, description: (suggestedScope: string) => { if (!suggestedScope) { return
{true &&
{__('These products will be filtered from the cart and passed to your selected offers.')}
} {false && }
} else if (suggestedScope === 'bogo') { return __('The products that the customer must buy to be eligible for the offer. These products will not be discounted but will be used to trigger offer. These represent the "buy" part of the "Buy One Get One" offer.') } else if (suggestedScope === 'item_discounts') { return __('These are the products that will be discounted.') } }, label: isSelected => } label={__('Filters')} isSelected={isSelected} color={filterTabColor} />, }, { id: 'condition' as TestableData['testableType'], title: __('Add Preconditions'), description: __('Add one or more conditions that need to pass before the coupon and offers can be applied.'), label: isSelected => } label={__('Conditions')} isSelected={isSelected} color={conditionTabThemeColor} />, }, { id: 'offer' as ComponentDataType, title: __('Add an offer'), label: isSelected => } label={__('Offers')} isSelected={isSelected} color={offerTabThemeColor} />, }, ] export const isConfigurable = (filterMeta: ComponentMeta | undefined, context: PopupWindowStateContext['scope'] | string = ''): boolean => { if (!filterMeta) { return false } let configurable = filterMeta.configurable; if (typeof configurable === 'function') { configurable = configurable(context) } return configurable !== false; } export const TestablesPopupWindow: FC = ({context, onClose}) => { const TestablesPopupWindows = useAtomValue(TestablesPopupWindowsAtom) const windowId = context.id const [isOpen, setIsOpen] = useState(true) const hasClosedRef = useRef(false) const defaultScreenId = 1//2 const popupWindowZIndex = 1000000 // @ts-ignore const [currentScreenId, setCurrentScreenId, {next, prev}] = useScreenNavigation(defaultScreenId) // null means no decision has been made const [filters, setFilters] = useState<(ComponentRuntimeDataWithParentType)[]>([/*{ id: 'khgkgk', parentType: 'offers', type: 'BuyXGetY', options: { mode: 'products', whenNotInCart: { notification: { button: { url: { type: 'smart', } } } } } /!*{ name: '', // the html name of the field type: 'text', comparisonTypes: { text: { expectedValue: '', comparisonType: 'is' }, number: { quantity: { type: 'equals', amount: 0, range: { minimum: 0, maxmimum: 0 }, } }, exists: true } } *!/ }*/]) const [suggestedScope, setSuggestedScope] = useState(context.data?.extraData?.suggestedScope) type QuantityChoice = 'no-quantity' | 'with-quantity' const [qtyChoice, setQtyChoice] = useState('no-quantity') // this is for READ ONLY operations, mutable operations should be done with the updateTestables() from the store! const testables = useLazyTestables() const offers = useLazyOffers() const [testableType, setTestableType] = useState(context.data.componentType as ComponentDataType) const popupColor = getColorForComponentType(testableType) const popupConditionPalette = context.data?.conditionColorPalette || defaultConditionColorPalette const conditionPopupThemeClasses = useMemo( () => getConditionPopupThemeClasses(popupConditionPalette), [popupConditionPalette] ) const popupThemeColor = useMemo(() => { if (testableType === 'filter') { return { color: popupColor, tint: (color, type, context) => { switch (context) { case 'testables-popup.list.title-hover': return filterPopupThemeClasses.listTitleHover case 'testables-popup.list.card.hover-background': return filterPopupThemeClasses.listCardHoverBackground case 'testables-popup.list.card.hover-border': return filterPopupThemeClasses.listCardHoverBorder case 'testables-popup.list.card.icon.background': return filterPopupThemeClasses.listCardIconBackground case 'testables-popup.list.card.icon.text': return filterPopupThemeClasses.listCardIconText case 'testables-popup.list.card.example-bullet': return filterPopupThemeClasses.listCardExampleBullet case 'testables-popup.list.row.hover-border': return filterPopupThemeClasses.listRowHoverBorder case 'testables-popup.list.row.icon': return filterPopupThemeClasses.listRowIcon default: return undefined } } } } if (testableType === 'condition') { return { color: popupColor, tint: (color, type, context) => { switch (context) { case 'popupwindow.title': return conditionPopupThemeClasses.popupTitle case 'popupwindow.link': return conditionPopupThemeClasses.popupLink case 'popupwindow.icon.background': return conditionPopupThemeClasses.popupIconBackground case 'popupwindow.icon.text': return conditionPopupThemeClasses.popupIconText case 'testables-popup.list.title-hover': return conditionPopupThemeClasses.listTitleHover case 'testables-popup.list.card.hover-background': return conditionPopupThemeClasses.listCardHoverBackground case 'testables-popup.list.card.hover-border': return conditionPopupThemeClasses.listCardHoverBorder case 'testables-popup.list.card.icon.background': return conditionPopupThemeClasses.listCardIconBackground case 'testables-popup.list.card.icon.text': return conditionPopupThemeClasses.listCardIconText case 'testables-popup.list.card.example-bullet': return conditionPopupThemeClasses.listCardExampleBullet case 'testables-popup.list.row.hover-border': return conditionPopupThemeClasses.listRowHoverBorder case 'testables-popup.list.row.icon': return conditionPopupThemeClasses.listRowIcon default: return undefined } } } } if (testableType !== 'offer') { return popupColor } return { color: popupColor, tint: (color, type, context) => { switch (context) { case 'popupwindow.title': return offerPopupThemeClasses.popupTitle case 'popupwindow.link': return offerPopupThemeClasses.popupLink case 'popupwindow.icon.background': return offerPopupThemeClasses.popupIconBackground case 'popupwindow.icon.text': return offerPopupThemeClasses.popupIconText case 'testables-popup.list.title-hover': return offerPopupThemeClasses.listTitleHover case 'testables-popup.list.card.hover-background': return offerPopupThemeClasses.listCardHoverBackground case 'testables-popup.list.card.hover-border': return offerPopupThemeClasses.listCardHoverBorder case 'testables-popup.list.card.icon.background': return offerPopupThemeClasses.listCardIconBackground case 'testables-popup.list.card.icon.text': return offerPopupThemeClasses.listCardIconText case 'testables-popup.list.card.example-bullet': return offerPopupThemeClasses.listCardExampleBullet case 'testables-popup.list.row.hover-border': return offerPopupThemeClasses.listRowHoverBorder case 'testables-popup.list.row.icon': return offerPopupThemeClasses.listRowIcon default: return undefined } } } }, [popupColor, testableType, conditionPopupThemeClasses]) /* const addTestables = useNodesStore(store => store.addTestables)*/ if (filters.length > 1 && filters.find(({type}) => type === AnyProductsMeta.id)) { const onlyTwoFilters = filters.length === 2 const hasQuantity = filters.find(({type}) => type === QuantityMeta.id) if (!(onlyTwoFilters && hasQuantity)) { setFilters(filters.filter(({type}) => type !== AnyProductsMeta.id)) } } const [decisionOnQuantity, setDecisionOnQuantity] = useState(null) const [currentFilterId, setCurrentFilterId] = React.useState(null); const [startedOver, setStartedOver] = useState(false) const defaultScreenContentWidth = 400 const resetting = useRef(false) /** * -----------*SCREEN IDS*------------- */ let scopeSelectionScreenId = -1 let mainListSelectionScreenId = 1 let initialIndividualComponentConfigScreenId = 2 let quantityScreenId: number = 3 let refineSelectionScreenId = 4 let editScreenId = 5 if (context.data.suggestedScopeExperienceSelector) { scopeSelectionScreenId = 1 mainListSelectionScreenId++ initialIndividualComponentConfigScreenId++ quantityScreenId++ refineSelectionScreenId++ editScreenId++ } const refineCanNavigateToThisScreen = () => { if (!supportsMultipleComponents) { return applyFilters.bind(this); } if (!supportsRefiningSelection()) { return applyFilters.bind(this); } if (!isEditMode()) { // check if the selected filter can be configured, // if not, send an action to close this window const hasAnyProductsSelected = !!filters.find(({type}) => type === AnyProductsMeta.id) if (testableType === 'filter' && hasAnyProductsSelected) { // return an action to perfom, in this case, just close the popup successfully return applyFilters.bind(this) } } return true // edit mode can always be configured } /*const {next} = UseScreenNavigationActions("testables")*/ const getSelectedFilter = (customFilters?: ComponentRuntimeDataWithParentType[]) => { return filters.find(({type}) => type === currentFilterId) /* if (currentScreenId === 2) { // get me the base filter return filters[0] } */ /* return (customFilters || filters).find(({type}) => type === currentFilterId) */ } const getSelectedFilterMeta = (): ComponentMeta | undefined => { const selectedFilter = getSelectedFilter() if (!selectedFilter) { return; } return getComponentMeta((testableType + 's' as unknown as ComponentType), selectedFilter?.type || '') } const removeComponents = (remove: ComponentRuntimeDataWithParentType[]) => { const typesToRemove = remove.map(({type}) => type) typesToRemove.forEach(type => removeComponent(type)) } const removeComponent = (type: string) => { const selectedFiltersAfterRemoval: { current: ComponentRuntimeDataWithParentType[] | [], removed: boolean } = {current: [], removed: false} setFilters(filters => { selectedFiltersAfterRemoval.current = filters.filter(filter => type !== filter.type) selectedFiltersAfterRemoval.removed = true return selectedFiltersAfterRemoval.current }) if (!selectedFiltersAfterRemoval.removed) { return; } setTimeout(() => { if (selectedFiltersAfterRemoval.current.length === 0) { // if they've removed all, just remove this group all together if edit, reset/start over otherwise if (isEditMode()) { // here let's remove the whole group by sending a close request with 0 components close({ componentType: testableType, status: 'success', components: [] }) } else { reset(true) } } else { setCurrentFilterId(selectedFiltersAfterRemoval.current[0].type); } }, 10) /*setTimeout(() => { if (selectedFiltersAfterRemoval.current.length === 0) { reset(true) } else { setCurrentFilterId(selectedFiltersAfterRemoval.current[0].type); } }, 10)*/ } if ((!currentFilterId && !!decisionOnQuantity) || (filters.length > 1 && !currentFilterId) || (currentFilterId === QuantityMeta.id && !filters.find(({type}) => type === QuantityMeta.id))) { if (filters.length > 1) { setCurrentFilterId(filters[0].type) } } if (filters.length > 1 && !decisionOnQuantity) { // if you've added 2 filters, you have made a decision on quantity setDecisionOnQuantity('any') } const isEditMode = () => { return context.data.targetType?.startsWith('testable') && context.data.mode === 'edit'; } // set the current screen to 2 upon mounting if were on edit mode useLayoutEffect(() => { if (isEditMode() && !resetting.current) { // here we'll get the filters from the store // and set them to the filters state if ((context.data.componentType as TreeContextData['componentType']) === 'offer') { const offer = cloneDeep(offers.get(context.data.targetId as string)) setFilters([offer]) setCurrentFilterId(offer.type) setCurrentScreenId(getNextScreenToJumpToIfSupported(editScreenId)) } else { if (context.scope === 'tier-subchild') { // we need to set the filters as the testable partial but with the added type so that we can use it as expected, in other words, merge the base testable but use the options of the partial including its id ofc const baseTestable = cloneDeep(testables.getParent(context.data.targetId as string) as TestableData) const testablePartial = cloneDeep(testables.getNode(context.data.targetId as string) as TestablePartial) setFilters([ { ...baseTestable, ...testablePartial, parentType: `${baseTestable.testableType}s`, } ]) setCurrentFilterId(baseTestable.type) setCurrentScreenId(getNextScreenToJumpToIfSupported(editScreenId)) } else if (context.scope === 'tier-base') { let testableBase = cloneDeep(testables.getNode(context.data.targetId as string)); setFilters([ testableBase ]) setCurrentFilterId(testableBase.type) setCurrentScreenId(getNextScreenToJumpToIfSupported(editScreenId)) } else { const parentContext = testables.getParent(context.data.targetId as string)! if (parentContext) { setFilters(cloneDeep( (testables.getChildren(parentContext.id) as TestableData[]).map(testables.getWithParentType.bind(testables)) )) //also set the current filter id to the targetId bc were in edit mode setCurrentFilterId(testables.getNode(context.data.targetId as string)!.type) setCurrentScreenId(getNextScreenToJumpToIfSupported(editScreenId)) } } } } }, [context.data.targetType]) const reset = (startedOver: boolean = true) => { resetting.current = true setFilters([]) setCurrentFilterId(null); (currentScreenId !== mainListSelectionScreenId) && setCurrentScreenId(mainListSelectionScreenId) setQtyChoice('no-quantity') setDecisionOnQuantity(null) // this should be set to false on close, true when resetting on edit mode and window is not closing setStartedOver(startedOver) // this behaves weirdly here so we reset it in the main screen: setSuggestedScope(undefined) resetting.current = false } if (isOpen && filters.length === 0 && currentScreenId !== scopeSelectionScreenId && currentScreenId !== mainListSelectionScreenId) { reset(true) } const applyFilters = (extraFiltersToAdd: ComponentRuntimeDataWithParentType[] = []) => { close({ componentType: testableType, status: 'success', components: filters, // only send the scope when its a root node, ignore for everything else ...(suggestedScope && context.data.targetType === 'root'? { extraData: {suggestedScope} } : { }) }); // check if target is root, add it to the root }; const FieldsToRender = ({fieldToRender, notes, setNotes}: { fieldToRender: ComponentRuntimeDataWithParentType | undefined, notes: Note[], setNotes: (value: (((prevState: Note[]) => Note[]) | Note[])) => void }) => { //getSelectedFilter() if (!fieldToRender) { return; } return { const newFilters = [...filters]; // for categories.tsx: // here the fields would be expectedValues // we need to only save the id from the value which is a SelectOption // the thing is, how? so that this is reusable and maintainable const selectedFilter = fieldToRender; const renderType = typeof field.renderType === 'function' ? field.renderType(selectedFilter?.options) : field.renderType let maybeDot = '' if (extraNestedFieldPathRelative) { if (!extraNestedFieldPathRelative.startsWith('[')) { maybeDot = '.'; } } const fieldPath = `options.${field.id}${extraNestedFieldPathRelative ? maybeDot + extraNestedFieldPathRelative : ''}`; /** * ok so here get the component meta and trigger the hooks for this field if needed */ const componentMeta = getComponentMeta(`${testableType}s`, componentRuntimeData.type) if (renderType === FieldType.MultiSelect) { const selectedIds = get(selectedFilter, fieldPath) || []//selectedFilter.options[field.id] || []; const hooks = get(componentMeta?.fieldHooks || {}, field.id) || {} as ComponentMeta['fieldHooks']; if (action?.action === FieldUpdateAction.Add) { // if the action is add, we need to add the value to the options // so we can use the value as it is let valueToAdd = (value as SelectOption).id; if (typeof hooks?.add === 'function') { valueToAdd = hooks.add((value as SelectOption).id, (value as SelectOption)) } value = [...selectedIds, valueToAdd]; } else if (action?.action === FieldUpdateAction.Remove) { // if the action is remove, we need to remove the value from the options value = selectedIds.filter((id: string) => id !== (value as SelectOption).id) } } else if (renderType === FieldType.Custom) { if (action?.action === FieldUpdateAction.Add) { // if the action is add, we need to add the value to the options // so we can use the value as it is value = [...(get(selectedFilter, fieldPath) || []), value]; } } else if (renderType === FieldType.Select) { value = (value as SelectOption).id } if (action?.action === FieldUpdateAction.Remove && renderType === FieldType.Custom) { removeArrayElementAtPath(selectedFilter, fieldPath); } else { let valueToSet = field?.settingValue?.(value) || value; set(selectedFilter, fieldPath, typeof valueToSet === 'object' ? cloneDeep(valueToSet) : value); } if (action?.merge) { Object.entries(action.merge).forEach(([fieldId, mergeValue]) => { set(selectedFilter, `options.${fieldId}`, typeof mergeValue === 'object' ? cloneDeep(mergeValue) : mergeValue); }); } setFilters(newFilters); }}/>; } const isEditing = () => { return isEditMode() && !startedOver; } const unavailableComponentMessage = __('This component is currently unavailable. If it was configured with Coupons+ Pro, re-enable Pro to edit it.') function getFieldTitle(metaTypeAndId: [ComponentType, string]) { const componentMeta = getComponentMeta(...metaTypeAndId) if (!componentMeta) { return __('Unavailable component') } let titleTemplate: string = '*' //__('Select *') if (componentMeta?.fieldsTitlePrefix === false || !hasImplementation(componentMeta)) { titleTemplate = __('*') } let replacedTitle = titleTemplate.replace('*', withDecodedEntities(getComponentMetaData(componentMeta)).name); return <>{componentMeta?.titlePrefix} {replacedTitle}; } function getFieldDescription(metaTypeAndId: [ComponentType, string]) { const componentMeta = getComponentMeta(...metaTypeAndId) if (!componentMeta) { return; } return withDecodedEntities(getComponentMetaData(componentMeta))?.description; } const availableTabs = tabs.filter( ({id}) => id === testableType || context.data?.supportedComponentTypes?.includes(id as ComponentDataType) ) const showTabs = context.data?.showTabs !== false; const selectedTab = availableTabs.find(tab => tab.id === testableType); const supportsMultipleComponents = context.data?.supportsMultipleComponents !== false; const supportedComponents = [ ...context.data?.supportedComponents || [], ]; /** * Will return the next screend id if its currently supported, if not, it will jump to the next supported screen */ const supports: (type: string) => boolean = (type): boolean => { if (context.data?.unsupportedComponents?.includes(type)) { return false; } return context.data?.supportedComponents ? context.data.supportedComponents.includes(type) : true; } const supportsQuantity = supports(QuantityMeta.id); const getNextScreenToJumpToIfSupported = (screedId: number) => { if (screedId === quantityScreenId) { // we want to jump to the quantity screen, let's check if its supported though return supportsQuantity ? screedId : 5 } return screedId; } const addComponentByMeta = (componentMeta: ComponentMeta, selectIt = true) => { // make sure its type dont exist already because we can only have one of each type if (filters.find(({type}) => type === componentMeta.id)) { return; } const filterToAdd = addIdToComponent({ parentType: testableType + 's' as ComponentRuntimeDataWithParentType['parentType'], ...getDefaultRuntimeComponentData(componentMeta) }, false, testableType === 'offer'); setFilters([...filters, filterToAdd]) selectIt && setCurrentFilterId(filterToAdd.type) } const removeComponentByMeta = (componentMeta: ComponentMeta) => { setFilters(filters => filters.filter(({type}) => type !== componentMeta.id)) if (currentFilterId === componentMeta.id) { setCurrentFilterId(null) } } const canShowBottomNavigation = () => { if (currentFilterId) { let currentFilter = filters.find(({type}) => type === currentFilterId); const parentType = currentFilter?.parentType if (parentType) { const canShowButtonsCallable = getComponentMeta(...[parentType, currentFilterId])?.canShowBottomNavigation if (canShowButtonsCallable) { return canShowButtonsCallable(currentFilter) } } } return true } const shouldUseSelectableCards = ['offer', 'condition', /*'filter'*/].includes(testableType) const selectComponentFromList = (componentMeta: ComponentMeta, canBeSelected: boolean) => { if (!canBeSelected) { return } addComponentByMeta(componentMeta) next() } const getSelectableComponentTitle = (componentMeta: ComponentMeta) => { const componentMetaData = withDecodedEntities(getComponentMetaData(componentMeta)) const cardRenderer = getCardRendererFromType(componentMeta.id) const preRenderedTitle = getNamePrefixedForCardRenderer(cardRenderer, context.scope).map((prefix, index) => { return ( index === 0 ? prefix && {prefix} : {prefix} {componentMetaData.name} ) }) if (typeof componentMeta.listTitle === 'function') { return componentMeta.listTitle(preRenderedTitle, componentMetaData.name) } return preRenderedTitle } const decodeSelectableText = (value: unknown) => { return typeof value === 'string' ? decodeHtmlEntities(value) : undefined } const normalizeForSearch = (value: string): string => { return value .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') } const getSelectableComponentDescription = (componentMeta: ComponentMeta) => { const descriptionSource = getComponentMetaDataField(componentMeta, 'description') const description = typeof descriptionSource === 'function' ? descriptionSource({suggestedScope: suggestedScope || ''}) : descriptionSource return decodeSelectableText(description) ?? description } const getSelectableComponentExample = (componentMeta: ComponentMeta) => { const exampleSource = getCardRendererFromType(componentMeta.id)?.example const example = typeof exampleSource === 'function' ? exampleSource(context.scope) : exampleSource return decodeSelectableText(example) ?? example } const getSelectableComponentUseCases = (componentMeta: ComponentMeta) => { return (getComponentMetaData(componentMeta).useCases || []) .slice(0, 2) .map((useCase) => ({ ...useCase, title: decodeSelectableText(useCase.title) || '', content: (useCase.content || []) .map(content => decodeSelectableText(content)) .filter((content): content is string => !!content), })) .filter(({title, content}) => title || content.length) } const SelectableFiltersList = ({componentsOrderedByCategory, canBeSelected = true, suggestedCategoryId}: {componentsOrderedByCategory: CategorizedComponents, canBeSelected?: boolean, suggestedCategoryId?: string}) => { const [pressedComponentId, setPressedComponentId] = useState(null) return
{values(mapValues(componentsOrderedByCategory, (componentMetas, category) => { if (!componentMetas?.length) { return null; } const isSuggestedCategory = suggestedCategoryId === category; const categoryContent =
{componentCategories[category]?.label ?? componentCategories[category]}
{componentMetas.map((filterMeta) => { const componentMetaData = withDecodedEntities(getComponentMetaData(filterMeta)) const title = getSelectableComponentTitle(filterMeta) const description = getSelectableComponentDescription(filterMeta) const example = getSelectableComponentExample(filterMeta) const useCases = getSelectableComponentUseCases(filterMeta) const exampleItems = example ? [example] : useCases.flatMap((useCase) => useCase.content).slice(0, 2) return
{shouldUseSelectableCards ? setPressedComponentId(filterMeta.id)} onMouseUp={() => setPressedComponentId(null)} onMouseLeave={() => setPressedComponentId(null)} onClick={() => selectComponentFromList(filterMeta, canBeSelected)} >
{filterMeta.icon?.({className: "w-4 h-4", context: 'card'})}
{title}
{!hasImplementation(filterMeta) && }
{description &&
{description}
}
{!!useCases.length &&
{useCases.map((useCase) => ( {useCase.title} ))}
} {!!exampleItems.length &&
{__('Examples')}
    {exampleItems.map((exampleItem, index) =>
  • {exampleItem}
  • )}
}
: <> {example &&
{example}
} }
})}
; return {categoryContent}; }))}
} const componentType = `${testableType}s` as ComponentType let FullSelectableFiltersList = ({extraItemsToIgnore = [], nonImplementedCanBeSelected = true}: {extraItemsToIgnore?: string[], nonImplementedCanBeSelected?: boolean}) => { const [searchQuery, setSearchQuery] = useState('') const searchInputRef = useRef(null) const isSearchEnabled = testableType === 'filter' || (testableType === 'condition' && context.scope === 'preconditions') const activeSearchQuery = isSearchEnabled ? searchQuery : '' const trimmedSearchQuery = activeSearchQuery.trim() const hasSearchQuery = trimmedSearchQuery.length > 0 const normalizedSearchQuery = normalizeForSearch(trimmedSearchQuery) let componentsOrderedByCategory: CategorizedComponents = getComponentsOrderedByCategory( componentType, supportedComponents, [...extraItemsToIgnore, ...context.data?.unsupportedComponents || [], ...filters.map(({type}) => type)], // @ts-ignore context.scope, true ); React.useEffect(() => { if (!isSearchEnabled) { setSearchQuery('') } }, [isSearchEnabled]) React.useEffect(() => { if (!isSearchEnabled) { return } const focusAndSelectInput = () => { const input = searchInputRef.current if (!input) { return } input.focus({preventScroll: true}) if (input.value.length > 0) { input.select() } } const animationFrameId = requestAnimationFrame(focusAndSelectInput) const delayedFocusTimeoutId = window.setTimeout(focusAndSelectInput, 340) return () => { cancelAnimationFrame(animationFrameId) clearTimeout(delayedFocusTimeoutId) } }, [currentScreenId, isSearchEnabled]) const suggested = context.data?.suggested; let suggestedCategoryId: string | undefined; if (suggested) { if (suggested.type === 'category') { // Sort the suggested category to be first const categoryId = suggested.id; if (componentsOrderedByCategory[categoryId]) { suggestedCategoryId = categoryId; const suggestedCategoryComponents = componentsOrderedByCategory[categoryId]; delete componentsOrderedByCategory[categoryId]; componentsOrderedByCategory = { [categoryId]: suggestedCategoryComponents, ...componentsOrderedByCategory }; } } else if (suggested.type === 'component') { // Find and extract the component from its category const componentId = suggested.id; let foundComponent: ComponentMeta | undefined; for (const [category, components] of Object.entries(componentsOrderedByCategory)) { const componentIndex = components?.findIndex(c => c.id === componentId); if (componentIndex !== undefined && componentIndex >= 0 && components) { foundComponent = components[componentIndex]; // Remove the component from its original category components.splice(componentIndex, 1); // Remove category if empty if (components.length === 0) { delete componentsOrderedByCategory[category]; } break; } } if (foundComponent) { // Create a 'suggested' category with the component and put it at the top suggestedCategoryId = ComponentCategory.Suggested; componentsOrderedByCategory = { [ComponentCategory.Suggested]: [foundComponent], ...componentsOrderedByCategory }; } } } type SearchEntry = { category: string, componentMeta: ComponentMeta, title: string, id: string, description: string, extras: string[], relatedSearchTerms: string[], } let filteredComponentsOrderedByCategory: CategorizedComponents if (hasSearchQuery) { const searchEntries: SearchEntry[] = Object.entries(componentsOrderedByCategory).flatMap(([category, componentMetas]) => { return (componentMetas || []).map((componentMeta) => { const componentMetaData = withDecodedEntities(getComponentMetaData(componentMeta)) const description = getSelectableComponentDescription(componentMeta) || '' const example = getSelectableComponentExample(componentMeta) || '' const useCases = getSelectableComponentUseCases(componentMeta) const relatedSearchTerms = (componentMeta.relatedSearchTerms || []) .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) return { category, componentMeta, title: componentMetaData.name || '', id: componentMeta.id || '', description, extras: [ example, ...useCases.map(({title}) => title), ...useCases.flatMap(({content}) => content), ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0), relatedSearchTerms, } }) }) const getDirectMatchScore = (entry: SearchEntry): number => { const normalizedTitle = normalizeForSearch(entry.title) const normalizedId = normalizeForSearch(entry.id) const normalizedDescription = normalizeForSearch(entry.description) const normalizedRelatedSearchTerms = entry.relatedSearchTerms.map(normalizeForSearch) const normalizedExtras = entry.extras.map(normalizeForSearch) const getValueScore = (normalizedValue: string, baseWeight: number): number => { if (!normalizedValue.includes(normalizedSearchQuery)) { return 0 } let score = baseWeight if (normalizedValue.startsWith(normalizedSearchQuery)) { score += 60 } if (normalizedValue === normalizedSearchQuery) { score += 90 } return score } const directScores = [ getValueScore(normalizedTitle, 500), getValueScore(normalizedId, 300), getValueScore(normalizedDescription, 140), ...normalizedRelatedSearchTerms.map((value) => getValueScore(value, 220)), ...normalizedExtras.map((value) => getValueScore(value, 120)), ] return Math.max(0, ...directScores) } const directMatches = searchEntries .map((entry) => ({ entry, directScore: getDirectMatchScore(entry), })) .filter(({directScore}) => directScore > 0) .sort((a, b) => { const aTitle = normalizeForSearch(a.entry.title) const bTitle = normalizeForSearch(b.entry.title) const aTitleStartsWithQuery = aTitle.startsWith(normalizedSearchQuery) const bTitleStartsWithQuery = bTitle.startsWith(normalizedSearchQuery) if (aTitleStartsWithQuery !== bTitleStartsWithQuery) { return aTitleStartsWithQuery ? -1 : 1 } const aTitleIncludesQuery = aTitle.includes(normalizedSearchQuery) const bTitleIncludesQuery = bTitle.includes(normalizedSearchQuery) if (aTitleIncludesQuery !== bTitleIncludesQuery) { return aTitleIncludesQuery ? -1 : 1 } return b.directScore - a.directScore }) const rankedEntries = directMatches.length > 0 ? directMatches.map(({entry}) => entry) : (() => { const fuse = new Fuse(searchEntries, { keys: [ {name: 'title', weight: 0.70}, {name: 'relatedSearchTerms', weight: 0.10}, {name: 'description', weight: 0.10}, {name: 'id', weight: 0.08}, {name: 'extras', weight: 0.02}, ], includeScore: true, shouldSort: true, threshold: 0.18, minMatchCharLength: 2, ignoreLocation: true, isCaseSensitive: false, ignoreDiacritics: true, }) return fuse.search(trimmedSearchQuery) .sort((a, b) => { const aTitle = normalizeForSearch(a.item.title) const bTitle = normalizeForSearch(b.item.title) const aTitleStartsWithQuery = aTitle.startsWith(normalizedSearchQuery) const bTitleStartsWithQuery = bTitle.startsWith(normalizedSearchQuery) if (aTitleStartsWithQuery !== bTitleStartsWithQuery) { return aTitleStartsWithQuery ? -1 : 1 } const aTitleIncludesQuery = aTitle.includes(normalizedSearchQuery) const bTitleIncludesQuery = bTitle.includes(normalizedSearchQuery) if (aTitleIncludesQuery !== bTitleIncludesQuery) { return aTitleIncludesQuery ? -1 : 1 } return (a.score ?? 1) - (b.score ?? 1) }) .map(({item}) => item) })() const rankedEntriesByCategory = new Map() rankedEntries.forEach((item) => { const currentEntries = rankedEntriesByCategory.get(item.category) || [] rankedEntriesByCategory.set(item.category, [...currentEntries, item]) }) filteredComponentsOrderedByCategory = {} Object.keys(componentsOrderedByCategory).forEach((category) => { const categoryEntries = rankedEntriesByCategory.get(category) || [] if (categoryEntries.length > 0) { filteredComponentsOrderedByCategory[category] = categoryEntries.map(({componentMeta}) => componentMeta) } }) } else { filteredComponentsOrderedByCategory = componentsOrderedByCategory } if (suggestedCategoryId && !filteredComponentsOrderedByCategory[suggestedCategoryId]?.length) { suggestedCategoryId = undefined } const hasSearchResults = Object.values(filteredComponentsOrderedByCategory).some((componentMetas) => componentMetas.length > 0) return
{isSearchEnabled &&
setSearchQuery(event.target.value)} placeholder={componentType === 'filters'? __('Search product filters...') : __('Search conditions...')} className="w-full py-[6px] px-9 border border-gray-100 !bg-gray-150 !bg-opacity-40 text-smaller-1 rounded-[22px] caret-gray-400 transition bg-transparent focus:outline-none focus:border-blue-50/10 focus:!bg-opacity-60 ring-gray-200 ring-opacity-30 ring-offset-1 placeholder:text-gray-300" /> {hasSearchQuery && ( )}
} {hasSearchResults ? :
{__('No components match your search.')}
} {!hasSearchQuery && }
; }; const NonImplementedComponentsList = ({extraItemsToIgnore = [], canBeSelected = true}: { extraItemsToIgnore?: string[], canBeSelected: boolean}) => { const [showList, setShowList] = useState(false) const nonImplementedComponents: CategorizedComponents = getComponentsOrderedByCategory( componentType, supportedComponents, [...extraItemsToIgnore, ...context.data?.unsupportedComponents || [], ...filters.map(({type}) => type)], // @ts-ignore context.scope, false ) const hasNonImplementedComponents = Object.values(nonImplementedComponents).some(components => components.length > 0) if (!hasNonImplementedComponents) { return null; } return
{!showList && } {showList && }
} const renderSuggestedScope = (renderer: string | ((suggestedScope: string) => string) | undefined) => { return typeof renderer === 'function' ? renderer(suggestedScope || '') : renderer }; const supportsRefiningSelection = () => { if (!supportsMultipleComponents) { return false; } // is type filters if (testableType === 'filter') { const hasProductsFilterInAllowedMode = filters.find(({type, options}) => { return type === ProductsMeta.id && (options as ProductsOptions).inclusionType === 'allowed' }) const hasQuantity = filters.find(({type}) => type === QuantityMeta.id) if (hasProductsFilterInAllowedMode) { /** * By this point the filters are products and in 'allowed' mode, so the only refining supported here is qty. */ if (!isEditMode()) { return false // only allow adding quantity on the edit screen since the user has already made a choice regarding quantity on the previous screen } return !hasQuantity } } return true; } const getDescFromComponentMeta = (componentMetaArg: [ComponentType, string]): string | undefined => { const componentMeta = getComponentMeta(...componentMetaArg) if (!componentMeta) { return; } const desc = getComponentMetaDataField(componentMeta, 'description'); const suggestedScopeDesc = typeof desc == 'function' ? desc({suggestedScope}) : desc; if (suggestedScopeDesc) { return suggestedScopeDesc; } // else lets fallback to the default description return withDecodedEntities(getComponentMetaData(componentMeta))?.description; } const getTitleFromComponentMeta = (componentMetaArg: [ComponentType, string]): ReactNode => { const fieldTitle = getFieldTitle(componentMetaArg); const componentMeta = getComponentMeta(...componentMetaArg) if (!suggestedScope || !componentMeta?.supportsCustomTitleForSuggestedScopes?.(suggestedScope)) { return fieldTitle } else if (suggestedScope === 'bogo') { return {__('Buy')}:} /> } else if (suggestedScope === 'item_discounts') { return } else if (suggestedScope === 'cart_discounts') { return } } const iconFromSelectedFilter = () => { if ([editScreenId, initialIndividualComponentConfigScreenId].includes(currentScreenId)) { return getSelectedFilterMeta()?.icon?.({ className: classNames( "min-w-5 min-h-5 max-w-5 max-h-5", resolveTint( popupThemeColor, 'popupwindow.icon.text', 'text-purple-tint-10', 'text' ) ), context: 'card' },) } } const mainListScreenIcon = (() => { const iconClassName = classNames( "min-w-5 min-h-5 max-w-5 max-h-5", resolveTint( popupThemeColor, 'popupwindow.icon.text', 'text-purple-tint-10', 'text' ) ) if (testableType === 'offer') { return () => } if (testableType === 'condition' && context.scope === 'preconditions') { return () => } if (testableType === 'condition' && context.scope === 'predates') { return () => } return undefined })(); const isPredatesConditionMainList = testableType === 'condition' && context.scope === 'predates' const mainListScreenTitle = context.data?.mainWindow?.title ?? ( isPredatesConditionMainList ? __('Schedule') : renderSuggestedScope(selectedTab?.title) ) const mainListScreenDescription = context.data?.mainWindow?.description ?? ( isPredatesConditionMainList ? __('Select the date & time this coupon will be available.') : renderSuggestedScope(selectedTab?.description) ) const screens: ScreenProps[] = [ ...[context.data?.suggestedScopeExperienceSelector? { id: scopeSelectionScreenId, title: __('What kind of promo are these items for?'), // ''What offer will these items target?' description: __('Select the type of offer you plan to add to get a more personalized product filtering experience.'), contentWidth: () => fieldsContentWidth, content: ({height, width}) => { return categorizedItems={{ uncategorized: suggestedScopes }} categoryData={{ // not currently used.. bogo: { label: __('BOGO') } }} itemRenderer={{ customRender: ({id, description, label, examples}, selectItem) => { return

{label}

{description}

    2 })}> {examples.map(example =>
  • {example}

  • )}
}, content: ({label}) => { return label }, example: ({examples}) => (
2 })}> {examples.map(example =>

{example}

)}
) }} onSelect={({id}) => { // here set the suggested scope id and move on to the next screen setSuggestedScope(id) // move to next screen next() }} />
; }, showBottomNavigation: false, } : undefined].filter(screen => !!screen), { id: mainListSelectionScreenId, title: mainListScreenTitle, description: mainListScreenDescription, icon: mainListScreenIcon, onBackToThisScreen: () => { reset() }, contentWidth: () => fieldsContentWidth, content: ({height, width}) => { const [lastHoveredFilterType, setLastHoveredFilterType] = useState(null) const selectableFiltersListHeight = typeof height === 'number' ? Math.max(0, height - (showTabs ? 54 : 0)) : '100%' return
{showTabs && { setTestableType(testableType as ComponentDataType) }} width={'auto'} style={'transparent'}/> }
}, showBottomNavigation: false, }, { id: initialIndividualComponentConfigScreenId, canNavigateToThisScreen: () => { const filterMeta = getSelectedFilterMeta()! if (!filterMeta) { return false; } // check if the selected filter can be configured, return isConfigurable(filterMeta, context.scope) }, title() { const firstFilter = filters[0] if (!firstFilter?.parentType) { return '' } const metaArgs = [firstFilter.parentType, firstFilter.type] as [ComponentType, string] return getTitleFromComponentMeta(metaArgs) }, description() { const firstFilter = filters[0] if (!firstFilter?.parentType) { return '' } const componentMetaArg = [firstFilter.parentType, firstFilter.type] return getDescFromComponentMeta(componentMetaArg) }, icon: iconFromSelectedFilter, contentWidth: () => fieldsContentWidth, content: ({width, height}) => { const [notes, setNotes] = useState([]) return {notes.map(({id, title, content}) => )} } > {hasImplementation(getSelectedFilterMeta())? FieldsToRender({fieldToRender: getSelectedFilter(), notes, setNotes}) : } }, showBottomNavigation: () => canShowBottomNavigation() && hasImplementation(getSelectedFilterMeta()), primaryBottom: () => { return { label: (supportsMultipleComponents ? __('Next') : __('Done')), onClick: () => { if (!supportsMultipleComponents) { applyFilters(); return } else if (testableType === 'filter' && getSelectedFilter()?.type !== QuantityMeta.id) { /* edit: let's not. // let's add the base quantity setFilters(filters => { // @ts-ignore const quantityDefaultRuntimeData = testables.getWithParentType(getDefaultRuntimeComponentData(QuantityMeta), testableType); (quantityDefaultRuntimeData.options as QuantityOptions).type = QuantityType.Any; const newFilters = [...filters, quantityDefaultRuntimeData]; // and the set is as the selected so that in screen 3 the user can select the quantity setCurrentFilterId(QuantityMeta.id) return newFilters; }) */ } next() } } } }, { id: quantityScreenId, canNavigateToThisScreen: () => { if (context.data?.supportsOptionalQuantity === false) { return false; } if (testableType !== 'filter') { // conditions and offers dont have a quantity to configure return false; } // we're in testables, so let's check if quantity is supported return supportsQuantity; }, title() { if (suggestedScope === 'bogo') { return __('How many items must the customer buy?'); } return ; }, description() { return QuantityMeta?.description?.({suggestedScope: suggestedScope || ''}) }, contentWidth: () => fieldsContentWidth, content: ({width}) => { const addQuantityFilter = () => addComponentByMeta(QuantityMeta, false); let selectedQuantityFilter = filters.find(({type}) => type === QuantityMeta.id); if (suggestedScope === 'bogo' && !selectedQuantityFilter) { // auto select the quantity filter because its required for bogo's addQuantityFilter() } const canShowOptionalTabs = suggestedScope !== 'bogo' return
{canShowOptionalTabs && [ { id: 'no-quantity' as QuantityChoice, label: __('Any Quantity'), subLabel: __('At least 1 item'), }, { id: 'with-quantity' as QuantityChoice, label: __('Specific Quantity'), subLabel: __('Fixed, minimum, maximum, or range'), } ], [qtyChoice] )} selectedTabId={qtyChoice as QuantityChoice} onTabSelect={(id) => { const choice = id as QuantityChoice if (choice === 'with-quantity') { addQuantityFilter() } else { // remove the qty filter from the state removeComponentByMeta(QuantityMeta) } setQtyChoice(choice) }} />} {selectedQuantityFilter &&
{selectedQuantityFilter && false &&

Buy Quantity

} {selectedQuantityFilter && FieldsToRender({fieldToRender: selectedQuantityFilter, notes: [], setNotes: () => { }})}
}
/* return

the third screen, here ww will:

show the otpion to whetre to add qunatity to this gourp

theres no quantoty filter added here yet

if the user decided to add it, then we'll manually add it and will show the fields for the quanoty

if after adding, the user decied to not add qty, we'll remove it

in either case, we will NOT automat8ically move the user to the next screen, all qty operations neeed be handled in this screen

after the user has mnade a decision (and after setting the qty), the user will click next and we will go to the next 4th screen

*/ }, primaryBottom: () => { let label = __('Next'); // a true bool needs be checked. 'truthy' doesnt cut it here. if (refineCanNavigateToThisScreen() !== true) { label = __('Apply') } return { label, } } }, { id: refineSelectionScreenId, canNavigateToThisScreen: refineCanNavigateToThisScreen, title: undefined, description: undefined, contentWidth: () => fieldsContentWidth, content: ({height}) => { return

{__('Your selected *').replace('*', { filter: __('filters'), condition: __('conditions'), default: __('components') }[testableType])}

f

({ ...filter, [testableType === 'offer' ? 'parentType' : 'testableType']: testableType, })), }} color={popupColor} isRoot={false} buttonsAreEnabled={false} removable={false} scaleOnHover={false} animateLayout={false} />

{__('Optional')}: {{ filter: __('Refine your selection'), condition: __('Restrict the scope'), default: __('Add more components') }[testableType]}

{{ filter: __('Add more filters to create a more specific group of products.'), condition: __('Add another condition that must also be met'), default: __('Select another component') }[testableType]}

{/*
*/}
}, primaryBottom: () => { return ({ label: __('Apply'), /* label: testableType === 'filter' ? __('Add Filters') : __('Add Conditions'), */ onClick: applyFilters, }) } }, { id: editScreenId, disableScreenTransition: true, canNavigateToThisScreen: () => { if (isEditMode()) { // check if the selected filter can be configured, // if not, let's go to the 'refine' selection screen so that the user may add other filters, which should be what they want when editing const selectedFilter = getSelectedFilter() const selectedFilterMeta = selectedFilter ? getComponentMeta((testableType + 's' as unknown as ComponentType), selectedFilter?.type || '') : undefined if (!selectedFilterMeta) { return true; } const canBeConfigured = isConfigurable(selectedFilterMeta, context.scope) !== false if (!canBeConfigured) { // return an action to perfom, in this case, just close the popup successfully return () => setCurrentScreenId(refineSelectionScreenId) } } return true // edit mode can always be configured }, title: () => { if (currentFilterId) { const parentType = filters.find(({type}) => type === currentFilterId)?.parentType if (parentType) { return getTitleFromComponentMeta([parentType, currentFilterId]) } return ''; } return ''; }, description: () => { if (currentFilterId) { const parentType = filters.find(({type}) => type === currentFilterId)?.parentType if (parentType) { return getDescFromComponentMeta([parentType, currentFilterId]) //return getFieldDescription([parentType, currentFilterId]) } } }, icon: iconFromSelectedFilter, content: ({width, height}) => ({ ...filter, [testableType === 'offer' ? 'parentType' : 'testableType']: testableType, })), }} color={popupColor} activeItemIds={[getSelectedFilter()?.id || undefined].filter(id => !!id) as string[]} onItemClick={filterData => setCurrentFilterId((filterData as TestableData).type)} onRemoveClick={(filter) => { const filterToRemove = filter as TestableData removeComponent(filterToRemove.type) }} isRoot={false} buttonsAreEnabled={false} removable={filters.length > 1} scaleOnHover={true} popupScope={'popup-sidebar'} />
} rightSidebar={undefined} >
{(() => { const [notes, setNotes] = useState([]) const selectedFilter = getSelectedFilter() const selectedFilterMeta = getSelectedFilterMeta() if (!hasImplementation(selectedFilterMeta)) { return } return FieldsToRender({fieldToRender: selectedFilter, notes, setNotes}) })()}
, contentWidth: () => fieldsContentWidth, backTo: () => { const startOverScreen = { id: 1, label: __('Start over'), }; if (isEditMode()) { return startOverScreen; } return filters.length > 1 ? startOverScreen : null }, showBottomNavigation: canShowBottomNavigation, primaryBottom: () => { if (isEditMode()) { return { label: __('Apply Changes'), onClick: () => { close({status: 'success', components: filters, componentType: testableType}); return }, }; } return ({ label: testableType === 'filter' ? __('Apply Filters') : __('Apply Conditions'), onClick: applyFilters, }) }, secondaryBottom: supportsMultipleComponents ? (() => { if (!supportsRefiningSelection()) { return undefined; } return ({ label: __('Add another *').replace('*', { ['filter']: __('filter'), ['condition']: __('condition'), ['offer']: __('offer'), default: __('component'), }[testableType]), onClick: () => { setCurrentScreenId(refineSelectionScreenId) }, }) } ) : undefined }, ].filter(screen => screen !== undefined) as ScreenProps[]; const finalizeClose = (data: Parameters[0]) => { // @ts-ignore context.data.onClose?.(data); onClose(cloneDeep(data), { deferWindowRemoval: true, }) } const close = (data: Parameters[0], shouldAnimate: boolean = true) => { if (hasClosedRef.current) { return } hasClosedRef.current = true if (shouldAnimate) { setIsOpen(false) finalizeClose(data) return } // @ts-ignore context.data.onClose?.(data); onClose(cloneDeep(data)) // we have got to wait a lil because the useLayoutEffect will set data again setTimeout(() => { reset(false) }, 600) } let canGoBack: boolean = true; if (context.scope === 'tier-subchild') { canGoBack = false } else if (isEditMode() && testableType === 'offer') { // offers in edit mode can never go back canGoBack = false } return close({ status: 'fail', componentType: testableType, components: [], }, false)} screenId={currentScreenId} setCurrentScreenId={setCurrentScreenId} defaultScreenId={defaultScreenId} screens={screens} showBackNavigation={canGoBack} customTop={context?.data?.top} customWidth={context?.data?.width} headerCenteredContent={suggestedScope?
{getSuggestedScopeById(suggestedScope)?.label}
: undefined} />
} /** * Will remove the parent too if empty */ function removeArrayElementAtPath(obj, path) { // Handle nested array paths like "options.expectedValues[0][0]" const matches = path.match(/(.+?)(\[\d+\])(\[\d+\])?$/); if (!matches) return obj; // Not a valid array path const [, basePath, firstBracket, secondBracket] = matches; const firstIndex = parseInt(firstBracket.slice(1, -1)); // Get the parent array const parentArray = get(obj, basePath); if (!Array.isArray(parentArray)) return obj; if (secondBracket) { // Handle nested array case: "options.expectedValues[0][0]" const secondIndex = parseInt(secondBracket.slice(1, -1)); const nestedArray = parentArray[firstIndex]; if (Array.isArray(nestedArray)) { // Remove the element from the nested array nestedArray.splice(secondIndex, 1); // If the nested array is now empty, remove it from the parent array if (nestedArray.length === 0) { parentArray.splice(firstIndex, 1); } } } else { // Handle single-level array case: "options.expectedValues[0]" parentArray.splice(firstIndex, 1); } // Update the object with the modified array set(obj, basePath, parentArray); return obj; }