src/components/templates/ItemDetails/utils.js

/* eslint-disable  no-unused-vars */
import {filter} from "lodash";
import _cloneDeep from "lodash/cloneDeep";
import _isEmpty from "lodash/isEmpty";
import {config} from "utils";

/**
 * Remove a modifier recursively from the mods state
 * Structure as followed in notion.so/Item-Details-9bfe384406a64e71a2f23ac59afc50a1
 *
 * @param {object} args - list of arguments passed as an object
 * @param {object} args.modifications - the current mod state
 * @param {string} args.optionId - the option we are looking for
 * @param {string} args.modId - the modifier id to remove
 * @returns {object} Object of all modifiers with the modifier removed
 */
const removeNestedModifier = ({
  modifications = {},
  targetItem,
  nestedIndex,
  targetOption,
}) => {
  if (_isEmpty(modifications)) {
    return {};
  }

  // Go through the items in the modifier object
  return Object.entries(modifications).reduce((mods, [id, items]) => {
    if (!items.length) {
      return {
        ...mods,
        [id]: [],
      };
    }

    const filteredItems = items?.filter(
      ({item, modifiers, option}) =>
        item === targetItem && modifiers?.[targetOption],
    );
    const nestedOption =
      filteredItems?.[nestedIndex]?.modifiers?.[targetOption] || [];
    const reductionItems =
      filteredItems.length && nestedIndex !== undefined ? nestedOption : items;

    const removeIndex = reductionItems?.findIndex((i) => i.item === targetItem);
    const newItems = reductionItems?.filter(
      (i, index) => index !== removeIndex,
    );

    // Check if this option is the one to remove
    if (id === targetOption) {
      // The option we're looking to remove has been found (possibly in a nested tier)
      // Find the index and filter from the mods list
      // Replace the old mods list for this option with the new one
      return {
        ...mods,
        [id]: newItems,
      };
    }

    if (filteredItems?.length && nestedIndex !== undefined) {
      filteredItems[nestedIndex].modifiers[targetOption] = newItems;
      return {
        ...mods,
        [id]: filteredItems,
      };
    }

    // Check the items in this option for nesting
    // Search for the option id we're looking to remove
    // If it can't be found and there are more modifiers,
    // then recurisvely call this function searching for it
    const newNestedItems = items.reduce((accu, itemObj) => {
      if (itemObj?.option === targetOption && _isEmpty(itemObj?.modifiers)) {
        return accu;
      }

      // If this item is not the option and there are modifiers,
      // recursively call this function with the modifiers of this item
      const nestedModifiers = removeNestedModifier({
        modifications: itemObj?.modifiers,
        nestedIndex,
        targetItem,
        targetOption,
      });

      return [
        ...accu,
        {
          ...itemObj,
          modifiers: nestedModifiers,
        },
      ];
    }, []);

    return {
      ...mods,
      [id]: newNestedItems,
    };
  }, modifications);
};

const nestInsertItemToOption = ({
  modifications = {},
  targetOption = "",
  nestedIndex = 0,
  payload = {},
}) => {
  // Empty modifications state, skip
  if (_isEmpty(modifications)) {
    return {};
  }

  // Add item to current modifications state
  if (modifications?.[targetOption]) {
    return {
      ...modifications,
      [targetOption]: [...modifications?.[targetOption], payload],
    };
  }

  // Loop current modifications state to find the option
  return Object.entries(modifications)?.reduce((acc, [id, items]) => {
    // Skip empty items
    if (_isEmpty(items)) {
      return acc;
    }

    // Move deeper into each item and it's modifiers
    const updatedItems = items?.reduce(
      (accItems, {item, modifiers, option}) => {
        // Skip items without modifiers (not nested)
        if (_isEmpty(modifiers)) {
          return accItems;
        }

        // Add item to nested item modifiers
        const updatedModifiers = nestInsertItemToOption({
          modifications: modifiers,
          nestedIndex,
          payload,
          targetOption,
        });

        return [...accItems, {item, modifiers: updatedModifiers, option}];
      },
      items,
    );

    return {...acc, [id]: updatedItems};
  }, modifications);
};

const modsReducer = (state, {type, payload}) => {
  switch (type) {
    case "ADD_MOD": {
      const {
        isNested = false,
        item = "",
        nestedIndex = 0,
        option = "",
        quantity = 0,
      } = payload;

      const modifiers = isNested ? payload?.modifiers : {};

      const updatedState = nestInsertItemToOption({
        payload: {
          item,
          modifiers,
          option,
          ...(isNested && {quantity}),
        },
        modifications: state,
        nestedIndex,
        targetOption: option,
      });

      return updatedState;
    }
    case "REMOVE_MOD": {
      const {item = "", nestedIndex = 0, option = ""} = payload;

      const updatedState = removeNestedModifier({
        modifications: state,
        nestedIndex,
        targetItem: item,
        targetOption: option,
      });

      return updatedState;
    }
    case "CLEAR_MODS": {
      return {};
    }
    case "SET_MODS": {
      return payload;
    }
    default: {
      return state;
    }
  }
};

const mapModifications = (mods) => {
  if (_isEmpty(mods) || !mods) {
    return [];
  }
  return Object.entries(mods).reduce((accu, [parentOption, items]) => {
    const newItems =
      items?.length &&
      items.reduce((accu, {item, quantity = 1, option, modifiers = {}}) => {
        const itemObj = {
          item,
          modifiers: mapModifications(modifiers),
          option: option || parentOption,
        };
        const newItems = new Array(quantity).fill(0).map(() => itemObj);
        return [...accu, ...newItems];
      }, []);
    return [
      ...accu,
      {
        modifiers: newItems || [],
        option: parentOption,
      },
    ];
  }, []);
};

// return all mods as an array
const mergeModifications = (modifiers = {}) => {
  if (_isEmpty(modifiers)) {
    return [];
  }
  return Object.entries(modifiers).reduce((acc, [option, items]) => {
    if (items?.length) {
      const nestItems = items.reduce((acc, curItem) => {
        return [...acc, curItem.item, ...mergeModifications(curItem.modifiers)];
      }, []);
      return [...acc, ...nestItems];
    }
    return acc;
  }, []);
};

const sortOptions = (optionA = {}, optionB = {}) => {
  if (optionA === null) optionA = {};
  if (optionB === null) optionB = {};
  let weightA = 0;
  let weightB = 0;

  if (optionA.min) {
    weightA -= 1;
  } else {
    weightA += 1;
  }

  if (optionB.min) {
    weightB -= 1;
  } else {
    weightB += 1;
  }

  if (optionA.min && optionB.min) {
    if (optionB.min < optionA.min) {
      weightB += 1;
    } else if (optionB.min > optionA.min) {
      weightA += 1;
    }
  }

  if (optionA.max) {
    weightA -= 1;
  } else {
    weightA += 1;
  }
  if (optionB.max) {
    weightB -= 1;
  } else {
    weightB += 1;
  }

  if (optionA.max && optionB.max) {
    if (optionB.max < optionA.max) {
      weightB += 1;
    } else if (optionB.max > optionA.max) {
      weightA += 1;
    }
  }
  return {
    weightA,
    weightB,
  };
};

const mapItemTabs = (options, optionsHash, HARDCODES) => {
  let tabs = options.map((i) => ({entities: [i], name: i.name}));

  if (HARDCODES.optionsTabs && HARDCODES.optionsTabs.length) {
    tabs = HARDCODES.optionsTabs
      .map((tab) => {
        const entities = options
          .reduce((accu, option) => {
            const formatedName = option.name.toLowerCase().trim();
            // Find all options that match the hardcodes defined by the client
            const isAnOption = tab.optionNames.filter((optionName) =>
              formatedName.includes(optionName.toLowerCase()),
            ).length;
            if (tab.type === 0) {
              !isAnOption && accu.push(option.id);
            } else {
              isAnOption && accu.push(option.id);
            }
            return accu;
          }, [])
          .reduce((accu, optionId) => {
            // Filter out all optionItems that match diet if diet is selected
            const newOptions = _cloneDeep(optionsHash[optionId]);

            return [...accu, newOptions];
          }, []);

        return {
          entities: entities.filter((i) => i.items.length),
          optionIds: entities.map((i) => i?.id).filter(Boolean),
          name: tab.tabName,
        };
      })
      .filter((i) => i.entities.length);
  }

  tabs = tabs.map((i) => ({
    ...i,
    entities: config.option_weighted_sort
      ? i.entities.sort((optionA, optionB) => {
          const {weightA = 0, weightB = 0} = sortOptions(optionA, optionB);
          return weightA - weightB;
        })
      : i.entities,
  }));

  return tabs;
};

/**
 * nestedModifierState - recursively search modifiers state for the modifiers of an option. Read {@link https://www.notion.so/Item-Details-9bfe384406a64e71a2f23ac59afc50a1} first.
 *
 * @param {string} optionId - the option to search
 * @param {string} modId - the item id to search
 * @param {object} modsState - the modifier state
 * @todo Test using multiple nested tiers...
 * @todo Move this to the mods context
 */
const nestedModifierState = ({optionId, modId, modsState}) => {
  if (optionId && modId && !_isEmpty(modsState)) {
    // When we are validating a nested item...
    return Object.entries(modsState).reduce((accu, [id, options]) => {
      // No options were found
      if (!options?.length) return accu;

      const modifiers = options.reduce((accu, itemObj) => {
        if (id === optionId && itemObj?.item === modId) {
          // The nested item was found, now include all of it's modifiers
          return {
            ...accu,
            ...(itemObj?.modifiers || {}),
          };
        }

        // Continue recursion when there are modifiers in search of a option & item match
        if (Object.entries(itemObj?.modifiers ?? {}).length) {
          const nestedModifiers = nestedModifierState({
            modId,
            modsState: itemObj?.modifiers,
            optionId,
          });

          // Include all of it's modifiers if the nested item was found
          return {
            ...accu,
            ...(nestedModifiers || {}),
          };
        }

        return accu;
      }, {});

      // Only include the modifiers of the nested items
      return {
        ...accu,
        ...modifiers,
      };
    }, {});
  }

  // Validating ordinary modifiers with nothing nested
  return modsState;
};

export {
  mapItemTabs,
  mapModifications,
  mergeModifications,
  modsReducer,
  nestedModifierState,
  sortOptions,
};