Greasy Fork

Xbox Store Price & Deals Filter

Add price range filters and deal filters to Xbox store. Filter by Game Pass discounts, specific discount percentages, and price ranges.

目前为 2025-01-25 提交的版本。查看 最新版本

// 👋 Hola, usa 🐒Tampermonkey 👇
// https://www.tampermonkey.net/

// ==UserScript==
// @name            Xbox Store Price & Deals Filter
// @name:es         Filtros de Precio y Ofertas para Xbox Store
// @name:it         Filtri Prezzi e Offerte per Xbox Store
// @name:fr         Filtres de Prix et Offres pour Xbox Store
// @name:de         Preis- und Angebotsfilter für Xbox Store
// @namespace       https://jlcareglio.github.io/
// @version         0.8.0
// @description     Add price range filters and deal filters to Xbox store. Filter by Game Pass discounts, specific discount percentages, and price ranges.
// @description:es  Agrega filtros de rango de precios y ofertas a la tienda Xbox. Filtra por descuentos de Game Pass, porcentajes específicos de descuento y rangos de precios.
// @description:it  Aggiunge filtri per fascia di prezzo e offerte allo store Xbox. Filtra per sconti Game Pass, percentuali di sconto specifiche e fasce di prezzo.
// @description:fr  Ajoute des filtres de gamme de prix et d'offres au magasin Xbox. Filtre par réductions Game Pass, pourcentages de réduction spécifiques et gammes de prix.
// @description:de  Fügt Preisbereich- und Angebotsfilter zum Xbox-Store hinzu. Filtert nach Game Pass-Rabatten, spezifischen Rabattprozenten und Preisbereichen.
// @icon            https://www.google.com/s2/favicons?sz=64&domain=xbox.com
// @grant           none
// @author          Jesús Lautaro Careglio Albornoz
// @source          https://gist.githubusercontent.com/JLCareglio/9cbddea558658f695983a64b9cece6a6/raw/
// @match           https://www.xbox.com/*/games/all-games*
// @match           https://www.xbox.com/*/games/browse*
// @supportURL      https://gist.githubusercontent.com/JLCareglio/9cbddea558658f695983a64b9cece6a6/
// ==/UserScript==

(async () => {
  // Función para esperar a que un elemento exista
  function waitForElement(selector) {
    return new Promise((resolve) => {
      if (document.querySelector(selector)) {
        return resolve(document.querySelector(selector));
      }

      const observer = new MutationObserver(() => {
        if (document.querySelector(selector)) {
          observer.disconnect();
          resolve(document.querySelector(selector));
        }
      });

      observer.observe(document.documentElement, {
        childList: true,
        subtree: true,
      });
    });
  }

  /* Significado de las distintas clases que pueden estar en algún lugar dentro de una ProductCard de un juego:
    - "Price-module__afterPriceTextContainer___r7fdq": se puede comprar a un precio reducido si se tiene una suscripción activa de gamePass
    - "ProductCard-module__discountTag___OjGFy" indica que tiene un descuento y el porcentaje esta en su innerText
    - "ProductCard-module__price___cs1xr" su innerText contiene el precio final en formato local, por ejemplo, puede tener "ARS$ 19.999,20" por lo que se tiene que convertir al flotante 19999.20 pero si no contiene ningún numero, por ejemplo "Gratis+" o "Free" es porque el producto es gratis y la ausencia de esta clase indica que el producto NO se puede comprar o adquirir
  */
  const GAME_PASS_DISCOUNT_CLASS =
    "Price-module__afterPriceTextContainer___r7fdq";
  const DISCOUNT_TAG_CLASS = "ProductCard-module__discountTag___OjGFy";
  const FINAL_PRICE_CLASS = "ProductCard-module__price___cs1xr";
  const FILTER_LIST_CLASS = "SortAndFilters-module__filterList___T81LH";
  const BUTTON_APPLY_FILTERS_CLASS =
    "ApplyFiltersButton-module__applyButton___faTvE";
  const BUTTON_SHOW_FILTERS_CLASS = "SortAndFilters-module__button___OeFeU";
  const LOAD_MORE_ROW_CLASS = "BrowsePage-module__loadMoreRow___sx0qx";
  // Esperar a que exista el elemento filterList
  const filterList = await waitForElement(`.${FILTER_LIST_CLASS}`);

  // El resto del código continúa igual...
  if (filterList) {
    // Modificar la función createFilter para aceptar inputs adicionales
    function createFilter(id, title, options, additionalContent = "") {
      const li = document.createElement("li");
      li.className = "SortAndFilters-module__li___aV+Oo";

      const svgExpanded = `
        <svg width="1em" height="1em" viewBox="0 0 32 32" aria-hidden="true" class="SelectionDropdown-module__chevronIcon___Z81Q8" style="transform: rotate(180deg);">
        <path d="M1.132 10.277a1.125 1.125 0 011.493-.088l.098.088 13.381 13.38 13.349-13.349a1.125 1.125 0 011.492-.087l.099.087c.408.408.437 1.051.087 1.493l-.087.098-14.145 14.145a1.125 1.125 0 01-1.493.087l-.098-.087L1.132 11.868a1.125 1.125 0 010-1.591z" fill-rule="evenodd"></path>
        </svg>
      `;

      const svgCollapsed = `
        <svg width="1em" height="1em" viewBox="0 0 32 32" aria-hidden="true" class="SelectionDropdown-module__chevronIcon___Z81Q8">
        <path d="M1.132 10.277a1.125 1.125 0 011.493-.088l.098.088 13.381 13.38 13.349-13.349a1.125 1.125 0 011.492-.087l.099.087c.408.408.437 1.051.087 1.493l-.087.098-14.145 14.145a1.125 1.125 0 01-1.493.087l-.098-.087L1.132 11.868a1.125 1.125 0 010-1.591z" fill-rule="evenodd"></path>
        </svg>
      `;

      li.innerHTML = `
        <div class="SelectionDropdown-module__container___XzkIx" id="${id}">
          <button class="SelectionDropdown-module__titleContainer___YyoD0" aria-expanded="false">
            <span class="typography-module__xdsSubTitle2___6d6Da SelectionDropdown-module__titleText___PN6s9">${title}</span>
            <div class="SelectionDropdown-module__filterInfoContainer___7ktfT">
              ${svgExpanded}
            </div>
          </button>
          <div style="max-height: 20rem; overflow-y: auto" class="hidden">
            <ul class="Selections-module__options___I24e7" role="listbox">
              ${options
                .map(
                  (opt, index) => `
                <li>
                  <button class="Selections-module__selectionContainer___m2xzM" 
                          id="${opt.id}" 
                          role="${opt.type || "checkbox"}" 
                          aria-selected="${
                            opt.defaultSelected ? "true" : "false"
                          }" 
                          aria-checked="${
                            opt.defaultSelected ? "true" : "false"
                          }"
                          aria-label="${opt.label.replace(/<[^>]*>/g, "")}, ${
                    index + 1
                  } de ${options.length}">
                    <label class="Selections-module__icon___IBPqb">
                      <input type="${opt.type || "checkbox"}" 
                             id="${opt.id}_${opt.type || "checkbox"}" 
                             name="${opt.group || opt.id}" 
                             value="${opt.id}" 
                             ${opt.defaultSelected ? "checked" : ""}>
                      <span class="typography-module__xdsSubTitle2___6d6Da Selections-module__label___CpN0F Selections-module__textColor___CnMSs">
                        ${opt.label}
                      </span>
                    </label>
                  </button>
                </li>
              `
                )
                .join("")}
            </ul>
            ${additionalContent}
          </div>
        </div>
      `;

      return li;
    }

    function addFilterHandlers(filterElement, filterFn) {
      const button = filterElement.querySelector(
        ".SelectionDropdown-module__titleContainer___YyoD0"
      );
      const optionsDiv = filterElement.querySelector("div[style]");
      const options = filterElement.querySelectorAll(
        ".Selections-module__selectionContainer___m2xzM"
      );

      button.addEventListener("click", () => {
        const isExpanded = button.getAttribute("aria-expanded") === "true";
        button.setAttribute("aria-expanded", !isExpanded);
        optionsDiv.classList.toggle("hidden");
      });

      options.forEach((option) => {
        const input = option.querySelector("input");
        if (!input) return;

        // Función para manejar el cambio de estado
        const toggleFilter = () => {
          const isSelected = option.getAttribute("aria-selected") === "true";
          const newState = !isSelected;

          if (input.type === "radio") {
            // Para radio buttons, desmarcar todos los otros del mismo grupo
            options.forEach((opt) => {
              const otherInput = opt.querySelector('input[type="radio"]');
              if (otherInput && otherInput.name === input.name) {
                opt.setAttribute("aria-selected", "false");
                opt.setAttribute("aria-checked", "false");
                otherInput.checked = false;
              }
            });
          }

          option.setAttribute("aria-selected", newState);
          option.setAttribute("aria-checked", newState);
          input.checked = newState;

          // Aplicar filtro
          filterFn();
          saveFilterState();
        };

        // Manejar click en el botón completo
        option.addEventListener("click", (e) => {
          if (e.target !== input) {
            e.preventDefault(); // Prevenir comportamiento por defecto
            toggleFilter();
          }
        });

        // Manejar cambios en el input
        input.addEventListener("change", () => {
          if (input.type === "radio") {
            options.forEach((opt) => {
              const otherInput = opt.querySelector('input[type="radio"]');
              if (otherInput && otherInput.name === input.name) {
                opt.setAttribute("aria-selected", otherInput.checked);
                opt.setAttribute("aria-checked", otherInput.checked);
              }
            });
          } else {
            option.setAttribute("aria-selected", input.checked);
            option.setAttribute("aria-checked", input.checked);
          }

          // Aplicar filtro
          filterFn();
          saveFilterState();
        });
      });
    }

    function applyAllFilters() {
      const productCards = document.querySelectorAll(
        ".ProductCard-module__cardWrapper___6Ls86"
      );

      const selectedDiscountFilter =
        document.querySelector('input[name="discountGroup"]:checked')?.value ||
        "none";
      const onlyGamePass =
        document.querySelector("#gamePassOnly_checkbox")?.checked || false;

      // Obtener el valor personalizado de descuento
      const customDiscountPercent = parseInt(
        document.querySelector("#customDiscountPercent")?.value || "0"
      );

      productCards.forEach((card) => {
        // Obtener estados de los filtros
        const minPrice =
          parseFloat(document.querySelector("#priceMin").value) || 0;
        const maxPrice =
          parseFloat(document.querySelector("#priceMax").value) || Infinity;
        const showFree = document.querySelector("#free_checkbox").checked;
        const showPaid = document.querySelector("#paid_checkbox").checked;
        const showUnpurchasable = document.querySelector(
          "#unpurchasable_checkbox"
        ).checked;

        // Verificar precio
        const priceElement = card.querySelector(`.${FINAL_PRICE_CLASS}`);
        let shouldShow = true;

        if (!priceElement) {
          shouldShow = showUnpurchasable;
        } else {
          const priceText = priceElement.innerText;
          const hasNumbers = /\d/.test(priceText);
          const isFree = !hasNumbers;

          if (isFree) {
            shouldShow = showFree;
          } else {
            shouldShow = showPaid;
            if (shouldShow) {
              const price = parseFloat(
                priceText.replace(/[^0-9,]/g, "").replace(",", ".")
              );
              shouldShow =
                price >= minPrice && (maxPrice === 0 || price <= maxPrice);
            }
          }
        }

        // Verificar descuentos
        if (shouldShow) {
          const discountTag = card.querySelector(`.${DISCOUNT_TAG_CLASS}`);
          const hasGamePassDiscount = card.querySelector(
            `.${GAME_PASS_DISCOUNT_CLASS}`
          );
          const discountPercentage = discountTag
            ? parseInt(discountTag.innerText.replace(/[^0-9]/g, ""))
            : 0;

          if (onlyGamePass && selectedDiscountFilter !== "none") {
            // Primero verificamos si tiene descuento de Game Pass
            shouldShow = hasGamePassDiscount ? true : false;

            // Si tiene Game Pass, ahora verificamos el porcentaje de descuento
            if (shouldShow) {
              switch (selectedDiscountFilter) {
                case "anyDiscount":
                  shouldShow = discountTag !== null;
                  break;
                case "discount50plus":
                  shouldShow = discountPercentage >= 50;
                  break;
                case "discount75plus":
                  shouldShow = discountPercentage >= 75;
                  break;
                case "discountCustom":
                  shouldShow = discountPercentage >= customDiscountPercent;
                  break;
              }
            }
          } else if (onlyGamePass) {
            shouldShow = hasGamePassDiscount ? true : false;
          } else if (selectedDiscountFilter !== "none") {
            switch (selectedDiscountFilter) {
              case "anyDiscount":
                shouldShow = discountTag !== null;
                break;
              case "discount50plus":
                shouldShow = discountPercentage >= 50;
                break;
              case "discount75plus":
                shouldShow = discountPercentage >= 75;
                break;
              case "discountCustom":
                shouldShow = discountPercentage >= customDiscountPercent;
                break;
            }
          }
        }

        card.parentElement.style.display = shouldShow ? "" : "none";
      });
    }

    // Crear y añadir los filtros
    // Filtro de Precio con inputs personalizados
    const priceRangeInputs = `
      <div>
        <div style="display: flex; flex-direction: column; align-items: center">
          <span>Más de</span>
          <input type="number" id="priceMin" min="0" />
        </div>
        <div style="display: flex; flex-direction: column; align-items: center">
          <span>Menos de</span>
          <input type="number" id="priceMax" min="0" />
        </div>
      </div>
    `;

    const priceFilter = createFilter(
      "PriceRange",
      "Precio",
      [
        { id: "free", label: "Mostrar Gratis", defaultSelected: true },
        { id: "paid", label: "Mostrar de Pago", defaultSelected: true },
        {
          id: "unpurchasable",
          label: "Mostrar Incomprable",
          defaultSelected: true,
        },
      ],
      priceRangeInputs
    );

    // Modificar la creación del filtro de ofertas
    const offersFilter = createFilter("Offers", "Oferta", [
      {
        id: "anyDiscount",
        label: "Con descuento",
        type: "radio",
        group: "discountGroup",
      },
      {
        id: "discount50plus",
        label: "50% o más",
        type: "radio",
        group: "discountGroup",
      },
      {
        id: "discount75plus",
        label: "75% o más",
        type: "radio",
        group: "discountGroup",
      },
      {
        id: "discountCustom",
        label: `<input type="number" id="customDiscountPercent" min="0" max="100" style="width: 50px;">% o más`,
        type: "radio",
        group: "discountGroup",
      },
      { id: "gamePassOnly", label: "Solo con Game Pass", type: "checkbox" },
    ]);

    filterList.appendChild(priceFilter);
    filterList.appendChild(offersFilter);

    // Agregar handlers para los filtros
    const priceFilterFn = () => {
      applyAllFilters();
      return true; // Siempre retorna true ya que la lógica está en applyAllFilters
    };

    const offersFilterFn = () => {
      applyAllFilters();
      return true; // Siempre retorna true ya que la lógica está en applyAllFilters
    };

    addFilterHandlers(priceFilter, priceFilterFn);
    addFilterHandlers(offersFilter, offersFilterFn);

    // Agregar listeners para los inputs de precio
    const priceInputs = document.querySelectorAll("#priceMin, #priceMax");
    priceInputs.forEach((input) => {
      input.addEventListener("change", applyAllFilters);
    });

    // Agregar listener para el input de porcentaje personalizado
    document
      .querySelector("#customDiscountPercent")
      ?.addEventListener("change", (e) => {
        const radio = document.querySelector("#discountCustom_radio");
        if (radio) {
          radio.checked = true;
          radio.dispatchEvent(new Event("change"));
        }
      });
  }

  // Funciones para manejar persistencia
  let currentFilterState = {
    priceMin: "",
    priceMax: "",
    free: true,
    paid: true,
    unpurchasable: true,
    discountGroup: "none",
    customDiscountPercent: "",
    gamePassOnly: false,
  };

  function saveFilterState() {
    currentFilterState = {
      priceMin: document.querySelector("#priceMin")?.value || "",
      priceMax: document.querySelector("#priceMax")?.value || "",
      free: document.querySelector("#free_checkbox")?.checked || false,
      paid: document.querySelector("#paid_checkbox")?.checked || false,
      unpurchasable:
        document.querySelector("#unpurchasable_checkbox")?.checked || false,
      discountGroup:
        document.querySelector('input[name="discountGroup"]:checked')?.value ||
        "none",
      customDiscountPercent:
        document.querySelector("#customDiscountPercent")?.value || "",
      gamePassOnly:
        document.querySelector("#gamePassOnly_checkbox")?.checked || false,
    };
  }

  function loadFilterState() {
    if (!currentFilterState) return;

    if (document.querySelector("#priceMin"))
      document.querySelector("#priceMin").value = currentFilterState.priceMin;
    if (document.querySelector("#priceMax"))
      document.querySelector("#priceMax").value = currentFilterState.priceMax;
    if (document.querySelector("#free_checkbox"))
      document.querySelector("#free_checkbox").checked =
        currentFilterState.free;
    if (document.querySelector("#paid_checkbox"))
      document.querySelector("#paid_checkbox").checked =
        currentFilterState.paid;
    if (document.querySelector("#unpurchasable_checkbox"))
      document.querySelector("#unpurchasable_checkbox").checked =
        currentFilterState.unpurchasable;
    if (
      currentFilterState.discountGroup !== "none" &&
      document.querySelector(`#${currentFilterState.discountGroup}_radio`)
    ) {
      document.querySelector(
        `#${currentFilterState.discountGroup}_radio`
      ).checked = true;
    }
    if (document.querySelector("#customDiscountPercent"))
      document.querySelector("#customDiscountPercent").value =
        currentFilterState.customDiscountPercent;
    if (document.querySelector("#gamePassOnly_checkbox"))
      document.querySelector("#gamePassOnly_checkbox").checked =
        currentFilterState.gamePassOnly;
  }

  // Observer para mantener los filtros en móvil
  function setupFilterListObserver() {
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === "childList") {
          const filterList = document.querySelector(`.${FILTER_LIST_CLASS}`);
          if (filterList && !filterList.hasAttribute("data-initialized")) {
            initializeFilters(filterList);
            filterList.setAttribute("data-initialized", "true");
          }
        }
      });
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }

  function initializeFilters(filterList) {
    // Definir las funciones de filtro antes de usarlas
    const priceFilterFn = () => {
      applyAllFilters();
      saveFilterState();
      return true;
    };

    const offersFilterFn = () => {
      applyAllFilters();
      saveFilterState();
      return true;
    };

    // Crear los filtros
    const priceFilter = createFilter(
      "PriceRange",
      "Precio",
      [
        {
          id: "free",
          label: "Mostrar Gratis",
          defaultSelected: currentFilterState.free,
        },
        {
          id: "paid",
          label: "Mostrar de Pago",
          defaultSelected: currentFilterState.paid,
        },
        {
          id: "unpurchasable",
          label: "Mostrar Incomprable",
          defaultSelected: currentFilterState.unpurchasable,
        },
      ],
      `
      <div>
        <div style="display: flex; flex-direction: column; align-items: center">
          <span>Más de</span>
          <input type="number" id="priceMin" min="0" />
        </div>
        <div style="display: flex; flex-direction: column; align-items: center">
          <span>Menos de</span>
          <input type="number" id="priceMax" min="0" />
        </div>
      </div>
      `
    );

    const offersFilter = createFilter("Offers", "Oferta", [
      {
        id: "anyDiscount",
        label: "Con descuento",
        type: "radio",
        group: "discountGroup",
      },
      {
        id: "discount50plus",
        label: "50% o más",
        type: "radio",
        group: "discountGroup",
      },
      {
        id: "discount75plus",
        label: "75% o más",
        type: "radio",
        group: "discountGroup",
      },
      {
        id: "discountCustom",
        label: `<input type="number" id="customDiscountPercent" min="0" max="100" style="width: 50px;">% o más`,
        type: "radio",
        group: "discountGroup",
      },
      { id: "gamePassOnly", label: "Solo con Game Pass", type: "checkbox" },
    ]);

    filterList.appendChild(priceFilter);
    filterList.appendChild(offersFilter);

    addFilterHandlers(priceFilter, priceFilterFn);
    addFilterHandlers(offersFilter, offersFilterFn);

    // Agregar listeners para los inputs
    const priceInputs = document.querySelectorAll("#priceMin, #priceMax");
    priceInputs.forEach((input) => {
      input.addEventListener("change", () => {
        applyAllFilters();
        saveFilterState();
      });
    });

    document
      .querySelector("#customDiscountPercent")
      ?.addEventListener("change", (e) => {
        const radio = document.querySelector("#discountCustom_radio");
        if (radio) {
          radio.checked = true;
          radio.dispatchEvent(new Event("change"));
          saveFilterState();
        }
      });

    // Cargar estado guardado
    loadFilterState();
    applyAllFilters();
  }

  // Agregar listener para el botón de aplicar filtros
  function setupApplyButton() {
    const applyButton = document.querySelector(
      `.${BUTTON_APPLY_FILTERS_CLASS}`
    );
    if (applyButton) {
      applyButton.addEventListener("click", () => {
        applyAllFilters();
        saveFilterState();
      });
    }
  }

  // Inicialización
  if (document.querySelector(`.${FILTER_LIST_CLASS}`)) {
    initializeFilters(document.querySelector(`.${FILTER_LIST_CLASS}`));
  }
  setupFilterListObserver();
  setupApplyButton();
})();