GOG Wishlist - Sort by Price (Dropdown)

Enables wishlist sorting by price (ascending and descending); switching between "sort by price" and a native sorting option (i.e., title, date added, user reviews), requires 2 page refreshes.

目前為 2025-02-15 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name GOG Wishlist - Sort by Price (Dropdown)
  3. // @description Enables wishlist sorting by price (ascending and descending); switching between "sort by price" and a native sorting option (i.e., title, date added, user reviews), requires 2 page refreshes.
  4. // @version 1.0
  5. // @license MIT
  6. // @match https://www.gog.com/account/wishlist*
  7. // @match https://www.gog.com/*/account/wishlist*
  8. // @run-at document-start
  9. // @grant none
  10. // @namespace https://greasyfork.org/users/1435312
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. // Global flags to track whether the last sort was by price and the current sort order (ascending vs descending)
  15. let lastSortWasPrice = false;
  16. let ascendingOrder = true;
  17.  
  18. // Retrieve the refresh stage from sessionStorage.
  19. // This is used to control a two-step refresh process which minimizes visual glitches when switching sort methods.
  20. let refreshStage = sessionStorage.getItem("gog_sort_fix_stage");
  21.  
  22. /**
  23. * Hides the wishlist section by setting its opacity to 0 and disabling pointer events.
  24. * This method is used to prevent users from seeing the intermediate state during a refresh.
  25. */
  26. function hideWishlist() {
  27. // Select the wishlist container element
  28. let wishlistSection = document.querySelector(".account__product-lists");
  29. if (wishlistSection) {
  30. wishlistSection.style.opacity = "0"; // Make wishlist invisible but still present in the DOM
  31. wishlistSection.style.pointerEvents = "none"; // Disable interactions with the wishlist
  32. }
  33. }
  34.  
  35. /**
  36. * Shows the wishlist section by restoring its opacity and pointer events.
  37. * This is called after the refresh process to reveal the sorted content.
  38. */
  39. function showWishlist() {
  40. let wishlistSection = document.querySelector(".account__product-lists");
  41. if (wishlistSection) {
  42. wishlistSection.style.opacity = "1"; // Restore visibility
  43. wishlistSection.style.pointerEvents = "auto"; // Re-enable interactions
  44. }
  45. }
  46.  
  47. // If we are on the first refresh stage (i.e. refreshStage equals "1"),
  48. // wait for the DOM to be loaded and then hide the wishlist.
  49. // This ensures that during the refresh process, the user does not see an unsorted list.
  50. if (refreshStage === "1") {
  51. document.addEventListener("DOMContentLoaded", () => {
  52. hideWishlist();
  53. });
  54. }
  55.  
  56. /**
  57. * Sorts the wishlist products by price.
  58. * This function:
  59. * - Locates the wishlist container element.
  60. * - Extracts product rows and separates them into priced items and TBA (to be announced) items.
  61. * - Determines the sort order (ascending or descending) based on whether the last sort was by price.
  62. * - Rebuilds the DOM with sorted priced items followed by TBA items.
  63. */
  64. function sortByPrice() {
  65. console.log("[Sort By Price] Sorting Started.");
  66.  
  67. // Query all elements with class 'list-inner' and use the second one (index 1) which contains the wishlist
  68. let listInner = document.querySelectorAll('.list-inner')[1];
  69. if (!listInner) {
  70. console.error("[Sort By Price] ERROR: Wishlist list-inner element not found.");
  71. return;
  72. }
  73.  
  74. // Get all wishlist product rows as an array
  75. let productRows = Array.from(listInner.querySelectorAll('.product-row-wrapper'));
  76. console.log(`[Sort By Price] Found ${productRows.length} product rows.`);
  77.  
  78. // Separate items into those with a price (pricedItems) and those that are "TBA" or not priced (tbaItems)
  79. let pricedItems = [];
  80. let tbaItems = [];
  81.  
  82. // Process each product row
  83. productRows.forEach(row => {
  84. // Attempt to find the standard price element and the discounted price element if available
  85. const priceElement = row.querySelector('._price.product-state__price');
  86. const discountElement = row.querySelector('.price-text--discount span.ng-binding');
  87. // Check if there is a flag indicating the product is "coming soon"
  88. const soonFlag = row.querySelector('.product-title__flag--soon');
  89.  
  90. // Determine the price text: use discount text if available, otherwise standard price text
  91. let priceText = discountElement
  92. ? discountElement.innerText
  93. : priceElement
  94. ? priceElement.innerText
  95. : null;
  96.  
  97. // Convert the extracted text into a numeric value by stripping non-numeric characters
  98. let priceNumeric = priceText
  99. ? parseFloat(priceText.replace(/[^0-9.]/g, '').replace(/,/g, ''))
  100. : null;
  101.  
  102. // Create a text content check to mark items that are "TBA"
  103. // Also consider soonFlag and absence of priceText as indicators for TBA status
  104. const textContent = priceElement ? priceElement.textContent.toUpperCase() : "";
  105. const isTBA = textContent.includes("TBA") || soonFlag || priceText === null;
  106.  
  107. // Special case: if the price is exactly 99.99 and the "soon" flag is present, treat it as TBA
  108. // because the 99.99 is a placeholder for the real price
  109. if (isTBA || (priceNumeric && priceNumeric === 99.99 && soonFlag)) {
  110. tbaItems.push(row);
  111. } else {
  112. // For items with valid prices, push an object with the row and its numeric price
  113. pricedItems.push({ row, price: priceNumeric });
  114. }
  115. });
  116.  
  117. // Toggle sorting order:
  118. // If the last sorting operation was by price, switch the order (toggle ascending/descending).
  119. // Otherwise, default to ascending order.
  120. ascendingOrder = lastSortWasPrice ? !ascendingOrder : true;
  121. pricedItems.sort((a, b) => (ascendingOrder ? a.price - b.price : b.price - a.price));
  122.  
  123. // Trigger a reflow by briefly hiding and showing the list container.
  124. listInner.style.display = "none";
  125. listInner.offsetHeight; // Force reflow
  126. listInner.style.display = "block";
  127.  
  128. // Clear all current child elements from the list container.
  129. while (listInner.firstChild) {
  130. listInner.removeChild(listInner.firstChild);
  131. }
  132.  
  133. // Append sorted priced items first.
  134. pricedItems.forEach(item => listInner.appendChild(item.row));
  135. // Append TBA items at the end in their original order.
  136. tbaItems.forEach(item => listInner.appendChild(item));
  137.  
  138. // Set flag indicating that sorting was done by price
  139. lastSortWasPrice = true;
  140. console.log("[Sort By Price] Sorting Completed.");
  141. }
  142.  
  143. /**
  144. * Handles clicks on native sort options.
  145. * When a native sort option is selected after a custom "Price" sort, a two-stage refresh is triggered:
  146. * - First refresh hides the wishlist.
  147. * - Second refresh restores visibility and applies the native sort.
  148. *
  149. * @param {string} option - The label of the native sort option clicked.
  150. */
  151. function handleNativeSortClick(option) {
  152. console.log(`[Sort By Price] Switching to native sort: ${option}`);
  153.  
  154. // If we're in the middle of the refresh process (first refresh already occurred)
  155. // then trigger the second refresh.
  156. if (refreshStage === "1") {
  157. console.log("[Sort By Price] Second refresh triggered to apply sorting.");
  158. sessionStorage.removeItem("gog_sort_fix_stage"); // Clear the refresh stage flag
  159. showWishlist(); // Reveal the wishlist
  160. return;
  161. }
  162.  
  163. // If this is the first time switching away from "Price" sort, set the refresh stage flag.
  164. sessionStorage.setItem("gog_sort_fix_stage", "1");
  165. console.log("[Sort By Price] First refresh (hiding only wishlist section).");
  166.  
  167. hideWishlist(); // Hide the wishlist section before refresh
  168. setTimeout(() => {
  169. // Reload the page after a short delay to let the UI update
  170. location.reload();
  171. }, 50); // 50ms delay is used to ensure the UI hides before the reload occurs
  172. }
  173.  
  174. /**
  175. * Adds a "Price" sorting option to the sort dropdown.
  176. * This function waits until the dropdown is available in the DOM and then adds:
  177. * - A new option to sort by price.
  178. * - Event listeners on native sort options to handle refresh if a previous "Price" sort was active.
  179. */
  180. function addSortByPriceOption() {
  181. // Find the dropdown container for sorting options
  182. const dropdown = document.querySelector(".header__dropdown ._dropdown__items");
  183. if (!dropdown) {
  184. console.log("[Sort By Price] WARNING: Dropdown not found. Retrying...");
  185. // If the dropdown is not found, try again after 500ms (wait for DOM elements to be available)
  186. setTimeout(addSortByPriceOption, 500);
  187. return;
  188. }
  189.  
  190. // If the "Price" sort option has already been added, exit early
  191. if (document.querySelector("#sort-by-price")) return;
  192.  
  193. // Create a new span element to serve as the "Price" sort option
  194. let sortPriceOption = document.createElement("span");
  195. sortPriceOption.id = "sort-by-price";
  196. sortPriceOption.className = "_dropdown__item";
  197. sortPriceOption.innerText = "Price";
  198. // When clicked, sort the wishlist by price and update the sort header text
  199. sortPriceOption.addEventListener("click", () => {
  200. sortByPrice();
  201. updateSortHeader("Price");
  202. });
  203.  
  204. // Append the new sort option to the dropdown list
  205. dropdown.appendChild(sortPriceOption);
  206. console.log("[Sort By Price] 'Price' option added to sort dropdown.");
  207.  
  208. // Add click event listeners to all other native sort options in the dropdown.
  209. // When any of these are clicked after a "Price" sort, trigger the native sort refresh process.
  210. document.querySelectorAll(".header__dropdown ._dropdown__item").forEach(item => {
  211. if (item.id !== "sort-by-price") {
  212. item.addEventListener("click", () => {
  213. // Only trigger the native sort refresh if the last sort was by price
  214. if (lastSortWasPrice) {
  215. handleNativeSortClick(item.innerText);
  216. }
  217. });
  218. }
  219. });
  220. }
  221.  
  222. /**
  223. * Updates the sort header displayed in the UI to reflect the currently active sort option.
  224. *
  225. * @param {string} option - The label of the sort option to display.
  226. */
  227. function updateSortHeader(option) {
  228. console.log(`[Sort By Price] Updating sort header to: ${option}`);
  229. // Find the container for the sort header pointer
  230. const sortHeader = document.querySelector(".header__dropdown ._dropdown__pointer-wrapper");
  231. if (!sortHeader) {
  232. console.log("[Sort By Price] ERROR: Sort header not found.");
  233. return;
  234. }
  235.  
  236. // Hide any existing sort labels that are controlled by Angular's ng-show directive
  237. document.querySelectorAll(".header__dropdown span[ng-show]").forEach(el => {
  238. el.style.display = "none";
  239. });
  240.  
  241. // Look for a custom header element we may have already created for the "Price" sort
  242. let customSortHeader = document.querySelector("#sort-by-price-header");
  243. if (!customSortHeader) {
  244. // If not found, create one and insert it at the beginning of the sort header container
  245. customSortHeader = document.createElement("span");
  246. customSortHeader.id = "sort-by-price-header";
  247. customSortHeader.className = "";
  248. sortHeader.insertBefore(customSortHeader, sortHeader.firstChild);
  249. }
  250.  
  251. // Update the header text and ensure it is visible
  252. customSortHeader.innerText = option;
  253. customSortHeader.style.display = "inline-block";
  254. }
  255.  
  256. /**
  257. * A MutationObserver is set up to monitor the document for when the sort dropdown is added to the DOM.
  258. * Once detected, the "Price" sort option is added and the observer disconnects to prevent further calls.
  259. */
  260. const observer = new MutationObserver((mutations, obs) => {
  261. // Check if the dropdown container for sort options is present
  262. if (document.querySelector(".header__dropdown ._dropdown__items")) {
  263. addSortByPriceOption(); // Add the "Price" option to the dropdown
  264. obs.disconnect(); // Stop observing since our work is done
  265. }
  266. });
  267.  
  268. // Begin observing the body for changes in child elements and subtree modifications
  269. observer.observe(document.body, { childList: true, subtree: true });
  270.  
  271. /**
  272. * If the script detects that it is in the first refresh stage (refreshStage equals "1"),
  273. * perform a second refresh after a short delay. This ensures that any changes made during
  274. * the refresh process are fully applied.
  275. */
  276. if (refreshStage === "1") {
  277. console.log("[Sort By Price] Performing second refresh to finalize sorting.");
  278. sessionStorage.removeItem("gog_sort_fix_stage"); // Clear the refresh flag
  279.  
  280. setTimeout(() => {
  281. // Reload the page after 50ms to allow any pending UI updates to complete
  282. location.reload();
  283. }, 50); // 50ms delay; honestly, could be longer to ensure no race conditions before reload
  284. }
  285. })();