import { __ } from "../globals"; import { createContext, type ReactNode } from "react"; import { ComponentCategory, type ComponentRuntimeData } from "./components"; import type { QuantityOptions } from "./cards/Quantity.card"; import type { ConfigScreenVariants, ScreenKey } from "../TestablesPopupWindow"; import { TextPreSuFixed } from "./TextPreSuFixed"; import { type ComponentMeta, type ComponentType } from "./ComponentsMeta"; import { isExclude } from "./cards/InclusionValueValidator"; import { InlineWarning } from "./cards/InlineWarning"; import { findFilter } from "./testables"; import type { PopupWindowsState } from "./atoms"; import { Grove } from "./Grove"; import { useNodesStore } from "./store"; type PopupScreen = Partial<{ title: TitleType; description: DescType; }>; type SuggestedComponentOrCategory = { type: 'category', id: ComponentCategory } | { type: 'component', id: string } export type SuggestedScope = { id: string, label: string, examples: string[], description?: string, screens?: Partial<{ productsSelection: PopupScreen< string | ((args: { componentType: ComponentType popupContext: PopupWindowsState['context'] }) => ReactNode) >, config: PopupScreen< (args: { filterTitle: ReactNode, filter: ComponentRuntimeData, componentMeta: ComponentMeta componentType: ComponentType screen: ConfigScreenVariants, popupContext: PopupWindowsState['context'] }) => ReactNode > & { warnings?: (args: { filter: ComponentRuntimeData, selectedFilters: ComponentRuntimeData[], componentMeta: ComponentMeta componentType: ComponentType }) => ReactNode[] | undefined } quantity: PopupScreen ReactNode) > & { mustHaveQuantity?: boolean } }>, errors?: Partial ReactNode[] | undefined>>, actions?: Partial<{ addOffer: Partial<{ buttonLabel: string, buttonDescription: (filteredItemsLabel: ReactNode) => ReactNode, suggestedArrowLabel: string }>, addGroup: Partial<{ suggestedArrowLabel: string }> }>, defaultOptions?: (componentRuntimeData: ComponentRuntimeData) => typeof componentRuntimeData['options'], suggests?: SuggestedComponentOrCategory } const whatProductsToDiscountTitle = __('What Kind of Products to Discount?'); const whatProductsToDiscountDescription = __('These are the products that will be discounted.'); const addDiscountArrowLabel = __('Add Discount Offer') const bundleErrors = (suggestedScope: typeof suggestedScopes[number]['id']) => (filters: ComponentRuntimeData[], screen: ScreenKey | undefined): ReactNode[] | undefined => { const selectedQty = findFilter(filters, 'NumberOfItems'); // if we don't have a qty, fail but only if this is the edit or qty screen // because we may not have a qty on the initial config screen since we have a dedicated screen for that if (!selectedQty && screen && ['config.edit' as ScreenKey, 'qty' as ScreenKey].includes(screen)) { // we need a qty boss. return [ {__('Bundles need a fixed quantify. Add a Quantity filter with a fixed amount.')} ]; } // if we have qty if (selectedQty) { // but not in mode fixed if (selectedQty?.options.quantity.type !== 'equals') { return [ {__('Bundles only support fixed quantities.')} ]; } if (suggestedScope === 'bundle_same_filters' && selectedQty.options.quantity.amount < 2) { // bundles need 2 or more! // ONLY for same filters bundles (2 of product a for $99) return [ {__('Same product type bundles need a quantity of 2 or more.')} ] } } }; const getBundleGroupLetterForPopup = (popupContext: PopupWindowsState['context']): string => { const { testables } = Grove.fromState(useNodesStore.getState(), true) let groupLetter: string = '?' const targetId = popupContext.data.targetId; let indexInGroup: number = 0 if (popupContext.data.targetType === 'root') { // this is a new tree so A indexInGroup = 0 } else { // we're adding to existing testables so let's figure out how many sets there are // if the target is a context, then this is is a new group being formed so the next letter is B if (testables.isContext(targetId)) { // B indexInGroup = 1 // 1 means there are at least 2 items: 0, 1 } else if (testables.isTestableCompositeGroup(targetId)) { // this is adding to an existing group (existing AND group etc) so let's count the existing filter sets indexInGroup = Math.max(testables.getChildren(targetId).length, 0) } else { // target is a testable, so let's find its parent group and its position in it indexInGroup = testables.findIndexInTestablesGroup(targetId) } } return String.fromCharCode('A'.charCodeAt(0) + indexInGroup) } export const suggestedScopes: SuggestedScope[] = [ { id: 'bogo', label: 'Buy One, Get One', description: __('Bogo offers on the same product or across different products, categories, brands, tags, attributes and more.'), examples: [ __('Buy 1 of Product A, get another for free'), __('Buy 1 in Category, get another for free'), __('Buy 1 Product A, get Product B'), __('Buy 1 Category A, get Category B'), ], screens: { productsSelection: { title: __('What Kind of Products Must the Customer Buy?'), description: __('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.') }, config: { title: ({ filterTitle, filter, componentMeta, componentType }) => { if (componentType !== 'filters') { return // use the defaults } const isExclude = componentMeta?.isExclude?.(filter) const prefixed = {__('Buy')}:} />; if (isExclude) { return {__('except')}} /> } return prefixed } }, quantity: { title: __('How many items must the customer buy?'), description: __('These are the number of items the customer must buy for the bogo offer to apply.'), mustHaveQuantity: true } }, actions: { addOffer: { suggestedArrowLabel: __('Add BOGO Offer'), buttonDescription(filteredItemsLabel) { return <> {__('Add a BOGO offer that uses the *').replace('*', '')} {filteredItemsLabel} {__('as the "buy" items')} }, } }, suggests: { type: 'category', id: ComponentCategory.BOGO } }, { id: 'item_discounts', label: 'Item Discounts', examples: [ __('10% off Product A'), __('$5 off Categories A, B, C'), __('12% off Categories A or B. Min. qty 2+'), ], screens: { productsSelection: { title: whatProductsToDiscountTitle, description: whatProductsToDiscountDescription }, config: { title: ({ filterTitle, filter, componentMeta, componentType }) => { if (componentType !== 'filters') { return // use the defaults } const isExclude = componentMeta?.isExclude?.(filter) if (isExclude) { return } return } }, }, actions: { addOffer: { suggestedArrowLabel: addDiscountArrowLabel, buttonDescription(filteredItemsLabel) { return <> {__('Add a Discount offer that applies discounts to the *').replace('*', '')} {filteredItemsLabel} }, } }, suggests: { type: 'category', // a full category because this can apply to Discount and Price Per Item id: ComponentCategory.CartItemDiscounts } }, { id: 'cart_discounts', label: 'Cart Discounts', examples: [ __('10% storewide discount'), __('$20 off when buying Product A'), ], screens: { productsSelection: { title: __('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?'*/) }, config: { title: ({ filterTitle, filter, componentMeta, componentType }) => { if (componentType !== 'filters') { return // use the defaults } return }, warnings: ({ componentType, selectedFilters }) => { if (componentType !== 'filters') { return // no warnings on other types, for now at least } // ok, if the user selected any filters in exclude mode this will not work as they expect since this are cart discounts and the filters are not conditions in exclude mode const filtersInExcludeMode = selectedFilters.filter(componentMeta => isExclude(componentMeta)) if (filtersInExcludeMode.length) { return {__('This cart discount will still apply if the customer has other products in the cart, because product filters only work as conditions on include mode, not on exclude mode.')} } } }, }, actions: { addOffer: { suggestedArrowLabel: addDiscountArrowLabel, buttonDescription(filteredItemsLabel) { return <> {__('Add a Discount offer that uses the *').replace('*', '')} {filteredItemsLabel} {__('as a condition for the cart discount')} }, } }, suggests: { type: 'component', id: 'Discount' } }, { id: 'min_bulk_discount', label: 'Min. Quantity Bulk Discount', description: __('Apply a discount when buying X or more, like 10% when buying 5 or more.'), examples: [], screens: { productsSelection: { title: whatProductsToDiscountTitle, description: __('These are the products that will be discounted when the customer meets the min. required quantity') }, quantity: { title: __('Minimum Purchase Quantity'), mustHaveQuantity: true } }, actions: { addOffer: { suggestedArrowLabel: __('Add Discount offer'), buttonLabel: __('Add Offer'), buttonDescription(filteredItemsLabel) { return <> {__('Add a Discount to the *').replace('*', '')} {filteredItemsLabel} {__('When the min. required amount is in the cart')} }, } }, defaultOptions(componentRuntimeData) { if (componentRuntimeData.type === 'NumberOfItems') { const options = { ...componentRuntimeData.options } as QuantityOptions options.quantity.type = 'minimum' return options } return componentRuntimeData.options }, suggests: { type: 'category', // a full category because this can apply to Discount and Price Per Item id: ComponentCategory.CartItemDiscounts } }, { id: 'bundle_same_filters', label: 'Bundle on the Same Product Types', description: __('This type of bundle is for the same type of products or from a bag, like: Buy 2 of Product A for $99; or Buy 3 from Categories A or B for $149.'), examples: [], screens: { productsSelection: { title: __('Which products will be part of the bundle?'), description: __('These are the products that the final bundle price will apply to.') }, config: { title: ({ filterTitle, filter, componentMeta, componentType }) => { if (componentType !== 'filters') { return // use the defaults } const isExclude = componentMeta?.isExclude?.(filter) if (isExclude) { return {__('to exclude from bundle')}} /> } return {__('in bundle')}} /> } }, quantity: { title: __('Bundle Quantity'), mustHaveQuantity: true } }, errors: { filters: bundleErrors('bundle_same_filters') }, actions: { addOffer: { suggestedArrowLabel: __('Add Bundle offer'), buttonLabel: __('Add Offer'), buttonDescription(filteredItemsLabel) { return <> {__('Add a Bundle offer to be applied to the *').replace('*', '')} {filteredItemsLabel} }, } }, defaultOptions(componentRuntimeData) { if (componentRuntimeData.type === 'NumberOfItems') { const options = { ...componentRuntimeData.options } as QuantityOptions options.quantity.type = 'equals' options.quantity.amount = 2 return options } return componentRuntimeData.options }, suggests: { type: 'component', // a full category because this can apply to Discount and Price Per Item id: 'BundlePrice' } }, { id: 'bundle_different_filters', label: 'Bundle from Product Combos', description: __('This type of bundle is for different product combinations, like: 1 of Product A + 1 of Product B for $99.'), examples: [], screens: { productsSelection: { title: ({ popupContext }) => { let groupLetter: string = getBundleGroupLetterForPopup(popupContext) let suffix: string = '' const { testables } = Grove.fromState(useNodesStore.getState(), true) if (popupContext.data.targetType === 'root') { // this is a brand new tree, so let's how the group 1 of 2 suffix = __('(Group 1 of 2)') } else if (testables.isContext(popupContext.data.targetId)) { // this is a new group being formed, so the products are for group #2 suffix = __('(Group 2 of 2)') } // for other groups let's not count since that makes it more complex than it needs to be return __('Which bundle products for Group *? ++').replaceAll('*', groupLetter).replaceAll('++', suffix) }, description: __('Select the eligible products for the first part of the combo.') }, config: { title: ({ filterTitle, filter, componentMeta, componentType, popupContext }) => { if (componentType !== 'filters') { return // use the defaults } const isExclude = componentMeta?.isExclude?.(filter) const groupLetter = getBundleGroupLetterForPopup(popupContext) if (isExclude) { return {__('to exclude from Group *').replace('*', groupLetter)}} /> } return {__('in Group *').replace('*', groupLetter)}} /> } }, quantity: { title: ({ popupContext }) => { const groupLetter = getBundleGroupLetterForPopup(popupContext) return __('Quantity for Group *').replace('*', groupLetter) }, mustHaveQuantity: true } }, errors: { filters: bundleErrors('bundle_different_filters') }, actions: { addGroup: { suggestedArrowLabel: __('Add Group B to this bundle') }, addOffer: { suggestedArrowLabel: __('Add Bundle offer'), buttonLabel: __('Add Offer'), buttonDescription(filteredItemsLabel) { return <> {__('Add a Bundle offer to be applied to the *').replace('*', '')} {filteredItemsLabel} }, } }, defaultOptions(componentRuntimeData) { if (componentRuntimeData.type === 'NumberOfItems') { const options = { ...componentRuntimeData.options } as QuantityOptions options.quantity.type = 'equals' options.quantity.amount = 1 return options } return componentRuntimeData.options }, suggests: { type: 'component', // a full category because this can apply to Discount and Price Per Item id: 'BundlePrice' } }, { id: '', label: 'Not Sure / Other', examples: [ __('Other offers not listed above'), ] } /*{ id: 'or_inclusive', label: 'Advanced Selection of Products', examples: [ __('Any 1 of Product A, B, C AND/OR Category X, Y, Z'), ] }, { id: 'and', label: 'Mix and Match Pairing (Combos / Bundles)', examples: [ __('1 of Product A + 1 of Product B'), __('2 in Category A + 1 of Product B'), ] },*/ ] export const getSuggestedScopeById = (id?: string) => { return suggestedScopes.find(scope => scope.id === id); } export const SuggestedScopeContext = createContext('')