Fanatical bundles carousel enhancer

In all Fanatical Build Your Own bundles, adds "Add to bundle" button to the carousel - like in game bundles. It is handy to have arrows, to browse products, and button to add to cart close by. Also marks a tile of the product, currently shown in the carousel. In bundles with tiers, adds tier info to the carousel. All bundles: move carousel on top and use mouse wheel horizontal scroll to switch next/prev product.

// ==UserScript==
// @name         Fanatical bundles carousel enhancer
// @version      2025-04-06
// @namespace    Jakub Marcinkowski
// @description  In all Fanatical Build Your Own bundles, adds "Add to bundle" button to the carousel - like in game bundles. It is handy to have arrows, to browse products, and button to add to cart close by. Also marks a tile of the product, currently shown in the carousel. In bundles with tiers, adds tier info to the carousel. All bundles: move carousel on top and use mouse wheel horizontal scroll to switch next/prev product.
// @author       Jakub Marcinkowski <kuba.marcinkowski on g mail>
// @copyright    2023+, Jakub Marcinkowski <kuba.marcinkowski on g mail>
// @license      Zlib
// @homepageURL  https://gist.github.com/JakubMarcinkowski
// @homepageURL  https://github.com/JakubMarcinkowski
// @match        https://*.fanatical.com/*
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii01MCAtNTAgMTAwIDEwMCIgY2xpcC1wYXRoPSJjaXJjbGUoMTAwJSkiPjxjaXJjbGUgcj0iNTAiIGZpbGw9IiNmOTAiLz48cGF0aCBkPSJNLTUwLC0yNUgyOUwyMywtOUgtNTBNMjAsLTNILTE5VjM2TDAsMTJIMTMiIGZpbGw9IiNmZmYiLz48L3N2Zz4
// @run-at       document-body
// ==/UserScript==

(function() {
  'use strict';

  let carousel, tileCard, tileTarget, carouselTarget, button,
      buttonDummy, tilesTitles, carouselTitle, observerAddRem,
      markOnly;
  const observerInitial = new MutationObserver(() => {
    const contentElem = document.getElementsByClassName('content')[0];
    if (!contentElem) return;
    if (contentElem.parentElement.parentElement.id !== 'root') return;
    observerInitial.disconnect();
    observePageChange(contentElem);
  });
  observerInitial.observe(document.body, {childList: true, subtree: true});

  function observePageChange(elem) {
    const observerPage = new MutationObserver((mutationsList) => {
      const addedBundle = mutationsList
          .find((mutation) => [...mutation.addedNodes].find(checkIfBundle));
      if (addedBundle) {
        carousel = document.querySelector('section.bundle-carousel');
        if (carousel) {
          carouselOnTop();
          carousel.addEventListener('wheel', wheelSwitch, {passive: false});
          carouselTitle = carousel.getElementsByClassName('product-name')[0].firstChild; // text node
          carouselTarget = carousel.getElementsByClassName('right-column')[0];
          if (document.querySelector('main.PickAndMixProductPage')) {
            markOnly = !!document.querySelector('.bundle-carousel .pnm-add-btn'); // Add button exists in game bundles by default
            tilesTitles = [...document.querySelectorAll('h2.card-product-name')];
            startButtonCopy();
          } else if (document.getElementsByClassName('tier-title').length !== 0) { // Bundle with tiers
            tilesTitles = [...document.querySelectorAll('h3.card-product-name')];
            startTierCopy();
          }
        }
        return;
      }
      const removedBundle = mutationsList
          .find((mutation) => [...mutation.removedNodes].find(checkIfBundle));
      if (removedBundle) {
        if (observerAddRem) observerAddRem.disconnect();
        if (tileCard) removeListeners(tileCard);
      }
    });
    observerPage.observe(elem, {childList: true});
  }

  function checkIfBundle(node) {
    return node.tagName && node.tagName === 'MAIN'
      && (node.classList.contains('PickAndMixProductPage') || node.classList.contains('bundle-page'));
  }

  function carouselOnTop() {
    unwrapCarousel(carousel);
    unwrapCarousel(carousel.parentElement);
    const bgContrast = document.querySelector('[class$="backgroundContrast"]');
    if (bgContrast) carousel.parentElement.before(bgContrast);
  }

  function unwrapCarousel(relElem) {
    while (relElem.previousElementSibling) {
      carousel.after(relElem.previousElementSibling);
    }
  }

  function startButtonCopy() {
    moveTheButton();
    const observerTitle = new MutationObserver(moveTheButton);
    observerTitle.observe(carouselTitle, {characterData: true});
    if (markOnly) return;
    observerAddRem = new MutationObserver((mutationsList) => {
      if (mutationsList[0].target
          && mutationsList[0].target.tagName === 'A'
          || !buttonDummy
         ) return;
      mutationsList.forEach((mutation) => {
        if (mutation.target.tagName !== 'BUTTON') return;
        const buttonDummy2 = button.cloneNode(true);
        buttonDummy.replaceWith(buttonDummy2);
        buttonDummy = buttonDummy2;
      });
    });
    observerAddRem.observe(
      document.querySelector('div.PickAndMixProductPage__content.container > section'),
      {subtree: true, attributeFilter: ["class"]}
    );
  }

  function moveTheButton() {
    if (tileCard) { // Not on the first run
      moveToTile();
      if (!markOnly) removeListeners(tileCard);
    }
    tileCard = tilesTitles
        .find((tile) => tile.textContent === carouselTitle.nodeValue)
        .closest('article');
    tileTarget = tileCard.querySelector('.PickAndMixCard__addToBundle > div');
    button = tileTarget.getElementsByTagName('button')[0];
    moveToCarousel();
    if (!markOnly) addListeners(tileCard);
  }

  function moveToTile(event) {
    tileCard.parentElement.classList.remove('fbce-in-carousel');
    if (markOnly) return;
    tileTarget.append(button);
    if (buttonDummy) buttonDummy.remove();
    buttonDummy = button.cloneNode(true);
    carouselTarget.prepend(buttonDummy);
  }

  function moveToCarousel(event) {
    tileCard.parentElement.classList.add('fbce-in-carousel');
    if (markOnly) return;
    carouselTarget.prepend(button);
    if (buttonDummy) buttonDummy.remove();
    if (!event) buttonDummy = button.cloneNode(true);
    tileTarget.append(buttonDummy);
  }

  function removeListeners(node) {
    node.removeEventListener('mouseenter', moveToTile);
    node.removeEventListener('mouseleave', moveToCarousel);
  }

  function addListeners(node) {
    node.addEventListener('mouseenter', moveToTile);
    node.addEventListener('mouseleave', moveToCarousel);
  }

  function startTierCopy() {
    const container = document.createElement('div');
    container.className = 'fbce-tierInfo';
    carouselTarget.prepend(container);
    carouselTarget = container;
    copyTierInfo();
    const observerTitle = new MutationObserver(copyTierInfo);
    observerTitle.observe(carouselTitle, {characterData: true});
  }

  function copyTierInfo() {
    const tileCard = tilesTitles
        .find((tile) => tile.textContent === carouselTitle.nodeValue)
        .closest('article')
        .closest('div');
    const tierElem = tileCard.closest('.tier');
    const tierTiles = tierElem.querySelectorAll(':scope > div > div > div');
    const tierCount = tierTiles.length;
    const tierIndex = [...tierTiles]
        .findIndex((tile) => tile === tileCard);
    let string = [...tierElem.children[0].childNodes]
        .reduce((str,node) => {
          return str += node.nodeType === Node.TEXT_NODE ? node.textContent : ''
        }, '');
    string += tierElem.children[0].firstElementChild.textContent;
    carouselTarget.replaceChildren(
      string,
      document.createElement('br'),
      `${tierIndex + 1}/${tierCount}`
    );
  }

  function wheelSwitch(e) {
    if (!e.cancelable || e.deltaY !== 0) return;
    if (e.deltaX > 0) {
      document.querySelector('button.carousel-button[aria-label="Next"]').click();
    } else if (e.deltaX < 0) {
      document.querySelector('button.carousel-button[aria-label="Previous"]').click();
    }
  }

  const styleSheet = new CSSStyleSheet();
  document.adoptedStyleSheets.push(styleSheet);
  styleSheet.replaceSync(`
    .right-column > button {
      float: right;
      padding: 6px;
      margin-left: 0.3rem;
      margin-bottom: 1rem;
    }
    .right-column > div.fbce-tierInfo {
      text-align: right;
      margin-bottom: .5rem;
    }
    h4.mb-3 + .overview-container {clear: both;}
    section.bundle-carousel {padding-top: 1px;}
    #carousel-content {padding: 1rem;}
    .PickAndMixProductPage__content {padding-top: 0 !important;}
    .fbce-in-carousel {scale: 1.1;}
    .fbce-in-carousel > article {background-color: dimgrey;}
    .fbce-in-carousel .PickAndMixCard__bottomRowIcons * {color: bisque !important;}
    article.left-column > div.product-details {
      /* Fix. BYO Fantasy Game Assets Bundle had Pixelart Fonts Asset packs. */
      /* Description contained "supported characters", which swelled container. */
      word-break: break-word;
    }
    :root {
      /* Fix. Sometimes fanatical have unnecesary horizontal scrollbar. */
      margin-left: -1.1rem;
    }
  `);

  /* Tested:
    https://www.fanatical.com/en/pick-and-mix/essential-game-music-build-your-own-bundle - audio
    https://www.fanatical.com/en/pick-and-mix/ultimate-machine-learning-and-ai-build-your-own-bundle - ebook
    https://www.fanatical.com/en/pick-and-mix/build-your-own-tabletop-wargame-bundle - games, already has Add
    https://www.fanatical.com/en/pick-and-mix/new-skills-new-you-build-your-own-bundle - elearning
    https://www.fanatical.com/en/pick-and-mix/build-your-own-fantasy-game-assets-bundle - mixed audio + graphics
  */
})();