import {ComponentMeta} from "../ComponentsMeta"; import {iconWithClassName} from "../IconWithClassName"; import {CardRenderer} from "../CardRenderers"; import {__, CouponsPlus} from "../../globals"; import {usingOfferOptions, withPrefix} from "./CardHelpers"; import {FieldType} from "../fields"; import {TabsProps} from "../fields/tabs"; import {CustomProductOptionValue, CustomProductsFieldLabel} from "./Products"; import {merge} from "lodash"; import {SelectOption} from "../fields/MultiSelectSearch"; import {fieldsCacheContainer} from "../FieldsCache"; import {AdvancedGetYItemsMapField} from "./AdvancedGetYItemsMapField"; import {ProductDiscountOptions} from "./BuyXGetX"; import {DiscountQuantity, DiscountValue} from "./DiscountValue"; import {CardItemSecondaryValue} from "../CardItemSecondaryValue"; import {Testables} from "../testables"; import {useNodesStore} from "../store"; import {colors} from "../Colors"; import {Card} from "../Card"; import {CardItem} from "../CardItem"; import {BuyXGetYProductsFields} from "./BuyXGetYProductsFields"; import {ComponentCategory} from "../components"; import {WhenNotInCartCustomFields} from "./WhenNotInCartCustomFields"; import {NonImplementedMessage} from "../actions/NonImplementedMessage"; import {TestableComposite} from "../TestableComposite"; import React, {useMemo} from "react"; import {hover} from "../Color"; import {BaseOfferOptions} from "./OfferTypes"; import {RepeatingLabel} from "./Recusrive"; import {resolveData} from "../export/custom/BuyXGetYExporterImporter"; import {useOffer} from "../OfferHooks"; import classNames from "classnames"; import {RemoveButton} from "../RemoveButton"; import {ListItem} from "./ListItem"; import {useOpenBuyXGetYProductPopupFlow} from "./BuyXGetYProductPopupFlow"; import {useOpenEditOfferPopup} from "../hooks"; import {FloatingMarker} from "../FloatingMarker"; import {EditHintTextStrip} from "../EditHintTextStrip"; import {QuantityMeta} from "./Quantity"; import {getAdvancedYCardColor, getYUnsupportedComponents} from "./GetYAdvancedComponents"; export type ProductData = (ProductDiscountOptions & { id: string, isVariable: boolean, })[]; export type AdvancedData = { source: string[], // ids of contexts at runtime, should be fully nested data while saving and while importing... discountOptions: ProductDiscountOptions }; export type NotInCartNotification = { message: string, button: { text: string, url: { type: string, // smart, page, etc. value: string, } } }; export type BuyXGetYOptions = BaseOfferOptions & { mode: 'products' | 'advanced', data: ProductData | AdvancedData, // for local use only, not saved in the database, used to keep track of the values in the UI when switching between modes and prevent data corruption and then bugs. __local?: { data_products?: ProductData, data_advanced?: AdvancedData, } whenNotInCart: { notification: NotInCartNotification } } const getLocalDataFromCurrentOptions = (options: BuyXGetYOptions): NonNullable => ({ data_products: Array.isArray(options.data) ? options.data as ProductData : (options.__local?.data_products || resolveData('products', options) as ProductData), data_advanced: !Array.isArray(options.data) ? options.data as AdvancedData : (options.__local?.data_advanced || resolveData('advanced', options) as AdvancedData), }) const getSyncedLocalData = ( options: BuyXGetYOptions, mode: BuyXGetYOptions['mode'], data: ProductData | AdvancedData, ): NonNullable => ({ data_products: mode === 'products' ? data as ProductData : (options.__local?.data_products || resolveData('products', options) as ProductData), data_advanced: mode === 'advanced' ? data as AdvancedData : (options.__local?.data_advanced || resolveData('advanced', options) as AdvancedData), }) export const type = 'BuyXGetY' export const BuyXGetYMeta: ComponentMeta = { id: type, type: 'offers', fieldsTitlePrefix: false, category: ComponentCategory.BOGO, listTitle: withPrefix(__('BOGO:')), titlePrefix: {__('BOGO:')}, icon: iconWithClassName(({className, context}) => { if (context === 'card') { // solid return } // outline return }), fieldHooks: { data: { add: (id: string, value: SelectOption): ProductData[number] => { return { id, isVariable: value.value.option.isVariable, quantity: 1, discount: { type: 'free', amount: 0 } } } } }, canShowBottomNavigation: ({options}) => { const {mode, data} = options as BuyXGetYOptions // can only show when we have actually selected products. return mode === 'products'? (data as ProductData)?.length : (data as AdvancedData)?.source?.length }, fieldsMap: ({options}) => { let hasSelectedProducts = true && options.mode === 'products' ? (options.data as ProductData)?.length : (options.data as AdvancedData)?.source?.length; return [ [ { id: 'mode', renderType: FieldType.Tab, fieldProps: {dimUnselected: true} as Partial, fieldOptions: (options, onUpdate, {componentRuntimeData, field}) => ({ onUpdate: (value: BuyXGetYOptions['mode']) => { const newData = resolveData(options.mode, options) onUpdate( componentRuntimeData, { ...field, id: 'data', }, newData, { action: 'update', merge: { __local: { ...options.__local, [(options as BuyXGetYOptions).mode === 'products' ? 'data_products' : 'data_advanced']: newData } } }, ) } }) }, ], options.mode === 'products' && [ { id: 'data', renderType: FieldType.Custom, fieldProps: (options: BuyXGetYOptions, onUpdate, {componentRuntimeData, field}) => { return { component: { const localData = getSyncedLocalData(options, 'products', data) onUpdate( componentRuntimeData, field, data, // @ts-ignore { action: 'update', merge: { __local: localData } } // cannot import the enum because of a circular dependency ) }}/> } } } ], options.mode === 'advanced' && (CouponsPlus.implementations.offers.GetY.advanced? [ { id: 'data', renderType: FieldType.Custom, fieldProps: (options, onUpdate, {componentRuntimeData, field}) => ({ component: { const currentAdvancedData = resolveData('advanced', options) as AdvancedData const nextAdvancedData: AdvancedData = { ...currentAdvancedData, source: [...(currentAdvancedData.source || []), id] } onUpdate( componentRuntimeData, field, // this field is already for options.data id, { action: 'add', merge: { __local: getSyncedLocalData(options, 'advanced', nextAdvancedData) } }, 'source' // so we only need .source here ) }} discountOptions={merge( { qtyType: 'simple', qty: { type: 'any', amount: 1 }, quantity: 1, discount: { amount: 0, type: 'free' }, missingItemsInCart: { mode: 'manual-add' } }, options.data?.discountOptions ?? {}, )} onQtyTypeChange={qtyType => { const currentAdvancedData = resolveData('advanced', options) as AdvancedData const nextAdvancedData: AdvancedData = { ...currentAdvancedData, discountOptions: { ...currentAdvancedData.discountOptions, qtyType } } onUpdate( componentRuntimeData, field, // this represents the whole options.data prop qtyType, { action: 'update', merge: { __local: getSyncedLocalData(options, 'advanced', nextAdvancedData) } }, `discountOptions.qtyType` ) }} onQuantityChange={newValue => { if (typeof newValue === 'number') { const currentAdvancedData = resolveData('advanced', options) as AdvancedData const nextAdvancedData: AdvancedData = { ...currentAdvancedData, discountOptions: { ...currentAdvancedData.discountOptions, quantity: newValue } } onUpdate( componentRuntimeData, field, // this represents the whole options.data prop newValue, { action: 'update', merge: { __local: getSyncedLocalData(options, 'advanced', nextAdvancedData) } }, `discountOptions.quantity` ) } }} onDiscountTypeChange={type => { const currentAdvancedData = resolveData('advanced', options) as AdvancedData const nextAdvancedData: AdvancedData = { ...currentAdvancedData, discountOptions: { ...currentAdvancedData.discountOptions, discount: { ...currentAdvancedData.discountOptions.discount, type } } } onUpdate( componentRuntimeData, field, // this represents the whole options.data prop type, { action: 'update', merge: { __local: getSyncedLocalData(options, 'advanced', nextAdvancedData) } }, `discountOptions.discount.type` ) }} onDiscountAmountChange={value => { const currentAdvancedData = resolveData('advanced', options) as AdvancedData const nextAdvancedData: AdvancedData = { ...currentAdvancedData, discountOptions: { ...currentAdvancedData.discountOptions, discount: { ...currentAdvancedData.discountOptions.discount, amount: value } } } onUpdate( componentRuntimeData, field, // this represents the whole options.data prop value, { action: 'update', merge: { __local: getSyncedLocalData(options, 'advanced', nextAdvancedData) } }, `discountOptions.discount.amount` ) }} /> } ) } ] : [ { id: 'data', renderType: FieldType.Custom, fieldProps: () => ({ component: }) } ]), hasSelectedProducts && [ { id: 'whenNotInCart.notification', title: __("Notification when items can't be added to the cart automatically"), //description: __(''), renderType: FieldType.Custom, fieldProps: (options: BuyXGetYOptions, onUpdate, {componentRuntimeData, field}) => { return { component: { onUpdate( componentRuntimeData, field, notification, // @ts-ignore {action: 'update'} // cannot import the enum because of a circular dependency ) }} /> } } } ], ] }, } export const BuyXGetYRenderer: CardRenderer = { type: type, prefix: "*", example: __('Buy 1 hoodie, get 1 t-shirt free, Buy 1 hoodie, get 1 t-shirt or sweatshirt at 50% off'), icon: BuyXGetYMeta.icon!, AboveComputedValue: () => false, suggestedScopeMarker: (suggestedScope) => { // only shows in default cards, custom cards must handle the suggested scope marker themselves. if (suggestedScope === 'bogo') { return { prefix: __('BOGO'), label: __('Get') } } }, CustomCardComponent: ({id}) => { const offer = useOffer(id) const removeOffer = useNodesStore(store => store.removeOffer) const openEditOffer = useOpenEditOfferPopup(id) if (!offer) { return false } if (offer.options.mode !== 'products') { return false } return ({parameters: {color}, extraData: {afterContentInside, showRightContextButton, rightContextButton, branchId, suggestedScope}}) => { const data = offer.options.data as ProductData let isInsideAnotherBranch = false//branch.isInsideAnotherBranch() const isRecursive = offer.options.__BASE__.isRecursive const updateOffer = useNodesStore(store => store.updateOffer) const updateProductsData = (nextData: ProductData) => { const latestOffer = useNodesStore.getState().getOffer(offer.id) || offer const latestOptions = latestOffer.options as BuyXGetYOptions updateOffer({ ...latestOffer, options: { ...latestOptions, data: nextData, __local: { ...latestOptions.__local, data_products: nextData, } } }) } const removeProductByIndex = (indexToRemove: number) => { const latestOffer = useNodesStore.getState().getOffer(offer.id) || offer const latestData = (latestOffer.options.data as ProductData) || [] updateProductsData( latestData.filter((_, index) => index !== indexToRemove) ) } const openProductPopup = useOpenBuyXGetYProductPopupFlow({ offerId: offer.id, openOfferEditorFirst: true, }) let productsList = ((data as ProductData) ?? [])?.map((productData, index, itemsArray) => { const {id, quantity, discount, missingItemsInCart, qty} = productData const productDiscountOptions = { quantity, discount } const productOption = fieldsCacheContainer.get('products').get>(id)?.value?.option const productName = productOption?.type === 'variation' ? (productOption?.parent_name || productOption?.name) : (productOption?.name || id) const isFirst = index === 0; if (!isFirst) { afterContentInside = null showRightContextButton = false } const cardItem = ({ background: { color, tint: (color) => `${color.background(110)} ${color.background(hover(100))}` }, icon: { background: { color, tint: (color) => color.background(10) }, color: { color, tint: (color) => color.text(90) } } }), [color])} customPadding={'pt-1 pb-[6px] px-[6px]'} position={'first last'} onClick={() => { openProductPopup({ index, data: productData }) }} cardRenderer={{ icon: false,//BuyXGetYMeta.icon!, ComputedValue: () =>
{__('Get')}
{false && {productData.missingItemsInCart.mode === 'auto-add' ? __('Auto') : __('Manual')} }
, BelowComputedValue: () =>
>(id)} showType={'variation'} size={'small'} />
}} /> return <>
color.border(40) }, highlight: { color, tint: (color) => color.border(20) } }} scaleOnHover={true} animateLayout={false} beforeCardInsideContent={afterContentInside} enableSelfHovering={true} afterContent={ isHovered => <> { // prevent bubbling and thus bugs e.stopPropagation() removeProductByIndex(index) }} entityID={offer.id} show={isHovered} /> } outsideContent={() => { return
    : } className={missingItemsInCart.mode === 'auto-add' ? "text-blue-normal" : ''} > {missingItemsInCart.mode === 'auto-add' ? __('Automatically added') : __('Manually added')}
}} > {isInsideAnotherBranch ?
{afterContentInside} {cardItem}
: <> {afterContentInside} {cardItem} }
{showRightContextButton && rightContextButton}
}); return
{suggestedScope === 'bogo' && }
{ e.stopPropagation() // prevent bubbling and therefore a bug where the removed offer will be clicked to be opened right after being removed removeOffer(offer.id, branchId) }} entityID={offer.id} show={true} />
{productsList?.length ? productsList :
{isInsideAnotherBranch ?
{afterContentInside}
: <> {afterContentInside} }
}
{isRecursive && }
} }, ComputedValue: usingOfferOptions((opts, {color}) => { const {mode, data} = opts if (mode === 'products') { return /*
{__('Get')}
*/ } const advancedData: AdvancedData = data as AdvancedData /** * This copies a lot from buy x get x, should be refactored to remove duplication */ return
{false && -}
{__('Get')}
{advancedData.discountOptions.quantity}
{false && -} {false && color.text(20) }}>{__('Get')}} {
{/* {interpolateElements(__(' {1} for {2} '), { 1: renderOfferMainValue(quantity), 2: renderDiscountValue(), })} */}
}
; }), OutsideComputedValue: usingOfferOptions(({mode, data, __BASE__: {isRecursive} }, {color},) => { const contextsWithChildrenIDs = useNodesStore(store => { if (mode !== 'advanced') { return [] } const testables = new Testables(store) return ((data as AdvancedData)?.source || []).filter(contextID => { return testables.getNode(contextID) }) }) if (mode === 'products') { /* let productsList = ((data as ProductData) ?? [])?.map((productData, index, itemsArray) => { const {id, quantity, discount} = productData const productDiscountOptions = { quantity, discount } const productOption = fieldsCacheContainer.get('products').get>(id)?.value?.option const productName = productOption?.type === 'variation' ? (productOption?.parent_name || productOption?.name) : (productOption?.name || id) return
color.border(40) }, highlight: { color, tint: (color) => color.border(20) } }} scaleOnHover={false} animateLayout={false}> ({ background: { color, tint: (color) => `${color.background(110)} ${color.background(hover(100))}` }, icon: { background: { color, tint: (color) => color.background(10) }, color: { color, tint: (color) => color.text(90) } } }), [color])} customPadding={'pt-1 pb-[6px] px-[6px]'} position={'first last'} cardRenderer={{ icon: false,//BuyXGetYMeta.icon!, ComputedValue: () =>
{__('Get')}
{quantity}
{false && {productData.missingItemsInCart.mode === 'auto-add' ? __('Auto') : __('Manual')} }
, BelowComputedValue: () =>
>(id)} showType={'variation'} />
}} />
{index + 1 !== itemsArray.length && }
}); */ return
{productsList?.length ?
{productsList}
:
{__('Click card to select products')}
}
{isRecursive &&
}
} else if (mode === 'advanced') { return

{__('On the cheapest item from any of these groups')}

{contextsWithChildrenIDs.map((context) => { return <>

{__('OR')}

})}
} }), }