GOG Wishlist - Sort by Price (Dropdown)

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.

  1. // ==UserScript==
  2. // @name GOG Wishlist - Sort by Price (Dropdown)
  3. // @namespace https://github.com/idkicarus
  4. // @homepageURL https://github.com/idkicarus/GOG-wishlist-sort
  5. // @supportURL https://github.com/idkicarus/GOG-wishlist-sort/issues
  6. // @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.
  7. // @version 1.04
  8. // @license MIT
  9. // @match https://www.gog.com/account/wishlist*
  10. // @match https://www.gog.com/*/account/wishlist*
  11. // @run-at document-start
  12. // @grant none
  13. // ==/UserScript==
  14.  
  15. (function () {
  16. // Global flags to track the sorting state.
  17. // lastSortWasPrice: Tracks if the last sort action was by price.
  18. // ascendingOrder: Determines if the current sort order should be ascending.
  19. let lastSortWasPrice = false;
  20. let ascendingOrder = true;
  21.  
  22. // Retrieve the current refresh stage from sessionStorage.
  23. // This is used to coordinate a two-step refresh process that helps to avoid visual glitches.
  24. let refreshStage = sessionStorage.getItem("gog_sort_fix_stage");
  25.  
  26. /**
  27. * Hides the wishlist section.
  28. *
  29. * Sets the opacity to 0 and disables pointer events so that the user does not see the unsorted list during refresh.
  30. */
  31. function hideWishlist() {
  32. let wishlistSection = document.querySelector(".account__product-lists");
  33. if (wishlistSection) {
  34. wishlistSection.style.opacity = "0";
  35. wishlistSection.style.pointerEvents = "none";
  36. }
  37. }
  38.  
  39. /**
  40. * Shows the wishlist section.
  41. *
  42. * Restores the opacity and re-enables pointer events to reveal the sorted content.
  43. */
  44. function showWishlist() {
  45. let wishlistSection = document.querySelector(".account__product-lists");
  46. if (wishlistSection) {
  47. wishlistSection.style.opacity = "1";
  48. wishlistSection.style.pointerEvents = "auto";
  49. }
  50. }
  51.  
  52. // If we are in the first stage of refresh (refreshStage equals "1"),
  53. // wait until the DOM is fully loaded and then hide the wishlist.
  54. // This prevents the user from seeing the intermediate unsorted state.
  55. if (refreshStage === "1") {
  56. document.addEventListener("DOMContentLoaded", () => {
  57. hideWishlist();
  58. });
  59. }
  60.  
  61. /**
  62. * Sorts the wishlist products by price.
  63. *
  64. * This function performs the following steps:
  65. * 1. Logs the start of the sort process.
  66. * 2. Retrieves the wishlist container (the second element with class 'list-inner').
  67. * 3. Collects all product rows from the container.
  68. * 4. Iterates over each product row to extract the product title and price.
  69. * - Separates items with a valid price from those marked as "TBA" (to be announced).
  70. * 5. Determines the sort order:
  71. * - If the last sort was by price, toggle the order (ascending/descending);
  72. * - Otherwise, default to ascending.
  73. * 6. Sorts the priced items based on the selected order.
  74. * 7. Clears the current product rows from the container.
  75. * 8. Appends the sorted priced items followed by TBA items back to the container.
  76. * 9. Sets the flag indicating that the last sort action was by price.
  77. */
  78. function sortByPrice() {
  79. console.log("[Sort By Price] Sorting Started.");
  80.  
  81. // Retrieve the wishlist container element (using the second occurrence of '.list-inner').
  82. let listInner = document.querySelectorAll('.list-inner')[1];
  83. if (!listInner) {
  84. console.error("[Sort By Price] ERROR: Wishlist list-inner element not found.");
  85. return;
  86. }
  87.  
  88. // Convert the NodeList of product rows to an array.
  89. let productRows = Array.from(listInner.querySelectorAll('.product-row-wrapper'));
  90. console.log(`[Sort By Price] Found ${productRows.length} product rows.`);
  91.  
  92. let pricedItems = []; // Array to hold products with valid prices.
  93. let tbaItems = []; // Array to hold products that are TBA or marked as SOON.
  94.  
  95. // Process each product row.
  96. productRows.forEach(row => {
  97. // Extract the title from the product row.
  98. const titleElement = row.querySelector('.product-row__title');
  99. const title = titleElement ? titleElement.innerText.trim() : "Unknown Title";
  100.  
  101. // Extract the standard price and discounted price elements.
  102. const priceElement = row.querySelector('._price.product-state__price');
  103. const discountElement = row.querySelector('.price-text--discount span.ng-binding');
  104.  
  105. // Check if the game is flagged as "SOON" by inspecting a dedicated element.
  106. const soonFlag = row.querySelector('.product-title__flag--soon');
  107.  
  108. // Determine the price: if a discount price exists, use it; otherwise, use the standard price.
  109. let priceText = discountElement ? discountElement.innerText : priceElement ? priceElement.innerText : null;
  110. // Convert the price text to a numeric value by stripping out non-numeric characters.
  111. let priceNumeric = priceText ? parseFloat(priceText.replace(/[^0-9.]/g, '').replace(/,/g, '')) : null;
  112.  
  113. // Check if the product is marked as TBA by determining the visibility of the TBA badge.
  114. const tbaBadge = row.querySelector('.product-state__is-tba');
  115. const isTbaVisible = tbaBadge && tbaBadge.offsetParent !== null;
  116.  
  117. // Use the visibility check or missing price to classify as TBA.
  118. const isTBA = isTbaVisible || priceText === null;
  119.  
  120. // If the item is TBA, or its price is set to 99.99 with a "SOON" flag, or the price is not a number,
  121. // add it to the TBA list; otherwise, add it to the priced items list.
  122. if (isTBA || (priceNumeric === 99.99 && soonFlag) || isNaN(priceNumeric)) {
  123. console.log(`[Sort By Price] Marked as TBA/SOON: ${title}`);
  124. tbaItems.push(row);
  125. } else {
  126. console.log(`[Sort By Price] ${title} - Extracted Price: ${priceNumeric}`);
  127. pricedItems.push({ row, price: priceNumeric, title });
  128. }
  129. });
  130.  
  131. // Determine sort order:
  132. // If the last sort was by price, toggle the order; if not, default to ascending.
  133. ascendingOrder = lastSortWasPrice ? !ascendingOrder : true;
  134. // Sort the priced items based on price.
  135. pricedItems.sort((a, b) => (ascendingOrder ? a.price - b.price : b.price - a.price));
  136.  
  137. // Force a reflow by briefly hiding and showing the container.
  138. listInner.style.display = "none";
  139. listInner.offsetHeight;
  140. listInner.style.display = "block";
  141.  
  142. // Clear current content of the wishlist container.
  143. while (listInner.firstChild) {
  144. listInner.removeChild(listInner.firstChild);
  145. }
  146.  
  147. // Append sorted priced items first.
  148. pricedItems.forEach(item => listInner.appendChild(item.row));
  149. // Append TBA items after the priced items.
  150. tbaItems.forEach(item => listInner.appendChild(item));
  151.  
  152. // Set flag indicating that the last sort action was by price.
  153. lastSortWasPrice = true;
  154. console.log("[Sort By Price] Sorting Completed.");
  155. }
  156.  
  157. /**
  158. * Handles switching back to the native sort method.
  159. *
  160. * If the sort was changed after sorting by price, this function triggers a two-stage page refresh
  161. * to revert the changes smoothly.
  162. *
  163. * @param {string} option - The native sort option selected by the user.
  164. */
  165. function handleNativeSortClick(option) {
  166. console.log(`[Sort By Price] Switching to native sort: ${option}`);
  167.  
  168. // If we're in the second stage of refresh, remove the flag and show the wishlist.
  169. if (refreshStage === "1") {
  170. console.log("[Sort By Price] Second refresh triggered to apply sorting.");
  171. sessionStorage.removeItem("gog_sort_fix_stage");
  172. showWishlist();
  173. return;
  174. }
  175.  
  176. // Otherwise, set the refresh stage and hide the wishlist before reloading.
  177. sessionStorage.setItem("gog_sort_fix_stage", "1");
  178. console.log("[Sort By Price] First refresh (hiding only wishlist section).");
  179.  
  180. hideWishlist();
  181. setTimeout(() => {
  182. location.reload();
  183. }, 50);
  184. }
  185.  
  186. /**
  187. * Adds the "Price" sort option to the existing dropdown menu.
  188. *
  189. * This function:
  190. * 1. Searches for the dropdown container.
  191. * 2. If not found, retries after a short delay.
  192. * 3. Creates a new span element representing the "Price" option.
  193. * 4. Attaches an event listener to handle sorting when clicked.
  194. * 5. Appends the new option to the dropdown.
  195. * 6. Adds event listeners to the native sort options to handle switching back if needed.
  196. */
  197. function addSortByPriceOption() {
  198. const dropdown = document.querySelector(".header__dropdown ._dropdown__items");
  199. if (!dropdown) {
  200. console.log("[Sort By Price] WARNING: Dropdown not found. Retrying...");
  201. // If the dropdown is not found, try again after 500ms (wait for DOM elements to be available)
  202. setTimeout(addSortByPriceOption, 500);
  203. return;
  204. }
  205.  
  206. // If the "Price" sort option has already been added, exit early
  207. if (document.querySelector("#sort-by-price")) return;
  208.  
  209. // Create a new span element to serve as the "Price" sort option
  210. let sortPriceOption = document.createElement("span");
  211. sortPriceOption.id = "sort-by-price";
  212. sortPriceOption.className = "_dropdown__item";
  213. sortPriceOption.innerText = "Price";
  214.  
  215. // When the "Price" option is clicked, sort the wishlist and update the header.
  216. sortPriceOption.addEventListener("click", () => {
  217. sortByPrice();
  218. updateSortHeader("Price");
  219. });
  220.  
  221. // Add the new option to the dropdown.
  222. dropdown.appendChild(sortPriceOption);
  223. console.log("[Sort By Price] 'Price' option added to sort dropdown.");
  224.  
  225. // Add click event listeners to all other native sort options in the dropdown.
  226. // When any of these are clicked after a "Price" sort, trigger the native sort refresh process.
  227. document.querySelectorAll(".header__dropdown ._dropdown__item").forEach(item => {
  228. if (item.id !== "sort-by-price") {
  229. item.addEventListener("click", () => {
  230. // Only trigger the native sort refresh if the last sort was by price
  231. if (lastSortWasPrice) {
  232. handleNativeSortClick(item.innerText);
  233. }
  234. });
  235. }
  236. });
  237. }
  238.  
  239. /**
  240. * Updates the sort header in the dropdown to reflect the current sorting option.
  241. *
  242. * This function:
  243. * 1. Finds the header element.
  244. * 2. Hides any native sort option indicators.
  245. * 3. Creates or updates a custom header element with the provided sort option text.
  246. *
  247. * @param {string} option - The sort option to display (e.g., "Price").
  248. */
  249. function updateSortHeader(option) {
  250. console.log(`[Sort By Price] Updating sort header to: ${option}`);
  251. const sortHeader = document.querySelector(".header__dropdown ._dropdown__pointer-wrapper");
  252. if (!sortHeader) {
  253. console.log("[Sort By Price] ERROR: Sort header not found.");
  254. return;
  255. }
  256.  
  257. // Hide any elements that show native sort options.
  258. document.querySelectorAll(".header__dropdown span[ng-show]").forEach(el => {
  259. el.style.display = "none";
  260. });
  261.  
  262. // Check if the custom sort header exists; if not, create it.
  263. let customSortHeader = document.querySelector("#sort-by-price-header");
  264. if (!customSortHeader) {
  265. customSortHeader = document.createElement("span");
  266. customSortHeader.id = "sort-by-price-header";
  267. customSortHeader.className = "";
  268. sortHeader.insertBefore(customSortHeader, sortHeader.firstChild);
  269. }
  270.  
  271. // Update the custom header text and make it visible.
  272. customSortHeader.innerText = option;
  273. customSortHeader.style.display = "inline-block";
  274. }
  275.  
  276. // Create a MutationObserver to watch for the dropdown menu element.
  277. // When the dropdown is found, add the "Price" sort option and disconnect the observer.
  278. const observer = new MutationObserver((mutations, obs) => {
  279. if (document.querySelector(".header__dropdown ._dropdown__items")) {
  280. addSortByPriceOption();
  281. obs.disconnect();
  282. }
  283. });
  284. observer.observe(document.body, { childList: true, subtree: true });
  285.  
  286. // If we are in the refresh stage "1", trigger a second refresh after a short delay.
  287. if (refreshStage === "1") {
  288. console.log("[Sort By Price] Performing second refresh to finalize sorting.");
  289. sessionStorage.removeItem("gog_sort_fix_stage");
  290.  
  291. setTimeout(() => {
  292. location.reload();
  293. }, 50);
  294. }
  295. })();