// 👋 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();
})();