Enables sorting by price (ascending and descending) via the dropdown on a GOG wishlist page. Switching between "sort by price" and a native sorting option (title, date added, user reviews) automatically refreshes the page twice.
// ==UserScript==
// @name GOG Wishlist - Sort by Price (Dropdown)
// @namespace https://github.com/idkicarus
// @homepageURL https://github.com/idkicarus/GOG-wishlist-sort
// @supportURL https://github.com/idkicarus/GOG-wishlist-sort/issues
// @description Enables sorting by price (ascending and descending) via the dropdown on a GOG wishlist page. Switching between "sort by price" and a native sorting option (title, date added, user reviews) automatically refreshes the page twice.
// @version 1.04
// @license MIT
// @match https://www.gog.com/account/wishlist*
// @match https://www.gog.com/*/account/wishlist*
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
// Global flags to track the sorting state.
// lastSortWasPrice: Tracks if the last sort action was by price.
// ascendingOrder: Determines if the current sort order should be ascending.
let lastSortWasPrice = false;
let ascendingOrder = true;
// Retrieve the current refresh stage from sessionStorage.
// This is used to coordinate a two-step refresh process that helps to avoid visual glitches.
let refreshStage = sessionStorage.getItem("gog_sort_fix_stage");
/**
* Hides the wishlist section.
*
* Sets the opacity to 0 and disables pointer events so that the user does not see the unsorted list during refresh.
*/
function hideWishlist() {
let wishlistSection = document.querySelector(".account__product-lists");
if (wishlistSection) {
wishlistSection.style.opacity = "0";
wishlistSection.style.pointerEvents = "none";
}
}
/**
* Shows the wishlist section.
*
* Restores the opacity and re-enables pointer events to reveal the sorted content.
*/
function showWishlist() {
let wishlistSection = document.querySelector(".account__product-lists");
if (wishlistSection) {
wishlistSection.style.opacity = "1";
wishlistSection.style.pointerEvents = "auto";
}
}
// If we are in the first stage of refresh (refreshStage equals "1"),
// wait until the DOM is fully loaded and then hide the wishlist.
// This prevents the user from seeing the intermediate unsorted state.
if (refreshStage === "1") {
document.addEventListener("DOMContentLoaded", () => {
hideWishlist();
});
}
/**
* Sorts the wishlist products by price.
*
* This function performs the following steps:
* 1. Logs the start of the sort process.
* 2. Retrieves the wishlist container (the second element with class 'list-inner').
* 3. Collects all product rows from the container.
* 4. Iterates over each product row to extract the product title and price.
* - Separates items with a valid price from those marked as "TBA" (to be announced).
* 5. Determines the sort order:
* - If the last sort was by price, toggle the order (ascending/descending);
* - Otherwise, default to ascending.
* 6. Sorts the priced items based on the selected order.
* 7. Clears the current product rows from the container.
* 8. Appends the sorted priced items followed by TBA items back to the container.
* 9. Sets the flag indicating that the last sort action was by price.
*/
function sortByPrice() {
console.log("[Sort By Price] Sorting Started.");
// Retrieve the wishlist container element (using the second occurrence of '.list-inner').
let listInner = document.querySelectorAll('.list-inner')[1];
if (!listInner) {
console.error("[Sort By Price] ERROR: Wishlist list-inner element not found.");
return;
}
// Convert the NodeList of product rows to an array.
let productRows = Array.from(listInner.querySelectorAll('.product-row-wrapper'));
console.log(`[Sort By Price] Found ${productRows.length} product rows.`);
let pricedItems = []; // Array to hold products with valid prices.
let tbaItems = []; // Array to hold products that are TBA or marked as SOON.
// Process each product row.
productRows.forEach(row => {
// Extract the title from the product row.
const titleElement = row.querySelector('.product-row__title');
const title = titleElement ? titleElement.innerText.trim() : "Unknown Title";
// Extract the standard price and discounted price elements.
const priceElement = row.querySelector('._price.product-state__price');
const discountElement = row.querySelector('.price-text--discount span.ng-binding');
// Check if the game is flagged as "SOON" by inspecting a dedicated element.
const soonFlag = row.querySelector('.product-title__flag--soon');
// Determine the price: if a discount price exists, use it; otherwise, use the standard price.
let priceText = discountElement ? discountElement.innerText : priceElement ? priceElement.innerText : null;
// Convert the price text to a numeric value by stripping out non-numeric characters.
let priceNumeric = priceText ? parseFloat(priceText.replace(/[^0-9.]/g, '').replace(/,/g, '')) : null;
// Check if the product is marked as TBA by determining the visibility of the TBA badge.
const tbaBadge = row.querySelector('.product-state__is-tba');
const isTbaVisible = tbaBadge && tbaBadge.offsetParent !== null;
// Use the visibility check or missing price to classify as TBA.
const isTBA = isTbaVisible || priceText === null;
// If the item is TBA, or its price is set to 99.99 with a "SOON" flag, or the price is not a number,
// add it to the TBA list; otherwise, add it to the priced items list.
if (isTBA || (priceNumeric === 99.99 && soonFlag) || isNaN(priceNumeric)) {
console.log(`[Sort By Price] Marked as TBA/SOON: ${title}`);
tbaItems.push(row);
} else {
console.log(`[Sort By Price] ${title} - Extracted Price: ${priceNumeric}`);
pricedItems.push({ row, price: priceNumeric, title });
}
});
// Determine sort order:
// If the last sort was by price, toggle the order; if not, default to ascending.
ascendingOrder = lastSortWasPrice ? !ascendingOrder : true;
// Sort the priced items based on price.
pricedItems.sort((a, b) => (ascendingOrder ? a.price - b.price : b.price - a.price));
// Force a reflow by briefly hiding and showing the container.
listInner.style.display = "none";
listInner.offsetHeight;
listInner.style.display = "block";
// Clear current content of the wishlist container.
while (listInner.firstChild) {
listInner.removeChild(listInner.firstChild);
}
// Append sorted priced items first.
pricedItems.forEach(item => listInner.appendChild(item.row));
// Append TBA items after the priced items.
tbaItems.forEach(item => listInner.appendChild(item));
// Set flag indicating that the last sort action was by price.
lastSortWasPrice = true;
console.log("[Sort By Price] Sorting Completed.");
}
/**
* Handles switching back to the native sort method.
*
* If the sort was changed after sorting by price, this function triggers a two-stage page refresh
* to revert the changes smoothly.
*
* @param {string} option - The native sort option selected by the user.
*/
function handleNativeSortClick(option) {
console.log(`[Sort By Price] Switching to native sort: ${option}`);
// If we're in the second stage of refresh, remove the flag and show the wishlist.
if (refreshStage === "1") {
console.log("[Sort By Price] Second refresh triggered to apply sorting.");
sessionStorage.removeItem("gog_sort_fix_stage");
showWishlist();
return;
}
// Otherwise, set the refresh stage and hide the wishlist before reloading.
sessionStorage.setItem("gog_sort_fix_stage", "1");
console.log("[Sort By Price] First refresh (hiding only wishlist section).");
hideWishlist();
setTimeout(() => {
location.reload();
}, 50);
}
/**
* Adds the "Price" sort option to the existing dropdown menu.
*
* This function:
* 1. Searches for the dropdown container.
* 2. If not found, retries after a short delay.
* 3. Creates a new span element representing the "Price" option.
* 4. Attaches an event listener to handle sorting when clicked.
* 5. Appends the new option to the dropdown.
* 6. Adds event listeners to the native sort options to handle switching back if needed.
*/
function addSortByPriceOption() {
const dropdown = document.querySelector(".header__dropdown ._dropdown__items");
if (!dropdown) {
console.log("[Sort By Price] WARNING: Dropdown not found. Retrying...");
// If the dropdown is not found, try again after 500ms (wait for DOM elements to be available)
setTimeout(addSortByPriceOption, 500);
return;
}
// If the "Price" sort option has already been added, exit early
if (document.querySelector("#sort-by-price")) return;
// Create a new span element to serve as the "Price" sort option
let sortPriceOption = document.createElement("span");
sortPriceOption.id = "sort-by-price";
sortPriceOption.className = "_dropdown__item";
sortPriceOption.innerText = "Price";
// When the "Price" option is clicked, sort the wishlist and update the header.
sortPriceOption.addEventListener("click", () => {
sortByPrice();
updateSortHeader("Price");
});
// Add the new option to the dropdown.
dropdown.appendChild(sortPriceOption);
console.log("[Sort By Price] 'Price' option added to sort dropdown.");
// Add click event listeners to all other native sort options in the dropdown.
// When any of these are clicked after a "Price" sort, trigger the native sort refresh process.
document.querySelectorAll(".header__dropdown ._dropdown__item").forEach(item => {
if (item.id !== "sort-by-price") {
item.addEventListener("click", () => {
// Only trigger the native sort refresh if the last sort was by price
if (lastSortWasPrice) {
handleNativeSortClick(item.innerText);
}
});
}
});
}
/**
* Updates the sort header in the dropdown to reflect the current sorting option.
*
* This function:
* 1. Finds the header element.
* 2. Hides any native sort option indicators.
* 3. Creates or updates a custom header element with the provided sort option text.
*
* @param {string} option - The sort option to display (e.g., "Price").
*/
function updateSortHeader(option) {
console.log(`[Sort By Price] Updating sort header to: ${option}`);
const sortHeader = document.querySelector(".header__dropdown ._dropdown__pointer-wrapper");
if (!sortHeader) {
console.log("[Sort By Price] ERROR: Sort header not found.");
return;
}
// Hide any elements that show native sort options.
document.querySelectorAll(".header__dropdown span[ng-show]").forEach(el => {
el.style.display = "none";
});
// Check if the custom sort header exists; if not, create it.
let customSortHeader = document.querySelector("#sort-by-price-header");
if (!customSortHeader) {
customSortHeader = document.createElement("span");
customSortHeader.id = "sort-by-price-header";
customSortHeader.className = "";
sortHeader.insertBefore(customSortHeader, sortHeader.firstChild);
}
// Update the custom header text and make it visible.
customSortHeader.innerText = option;
customSortHeader.style.display = "inline-block";
}
// Create a MutationObserver to watch for the dropdown menu element.
// When the dropdown is found, add the "Price" sort option and disconnect the observer.
const observer = new MutationObserver((mutations, obs) => {
if (document.querySelector(".header__dropdown ._dropdown__items")) {
addSortByPriceOption();
obs.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
// If we are in the refresh stage "1", trigger a second refresh after a short delay.
if (refreshStage === "1") {
console.log("[Sort By Price] Performing second refresh to finalize sorting.");
sessionStorage.removeItem("gog_sort_fix_stage");
setTimeout(() => {
location.reload();
}, 50);
}
})();