[Wallapop] Toggles to hide reserved / non-reserved items on search results (right of Sort-by)

Two native-looking chips to the RIGHT of “Ordenar por:”. Selected = green + bold + stronger outline; width locked with fractional measurements to avoid layout shift. Simple display:none filtering.

// ==UserScript==
// @name         [Wallapop] Toggles to hide reserved / non-reserved items on search results (right of Sort-by)
// @namespace    https://greasyfork.org/en/scripts/546437-wallapop-hide-reserved-non-reserved-items-on-search-results-right-of-sort-by
// @match        https://es.wallapop.com/*
// @version      2025-08-20
// @author       nooobye
// @license      MIT
// @description  Two native-looking chips to the RIGHT of “Ordenar por:”. Selected = green + bold + stronger outline; width locked with fractional measurements to avoid layout shift. Simple display:none filtering.
// @icon         https://www.google.com/s2/favicons?sz=32&domain_url=wallapop.com
// @icon64       https://www.google.com/s2/favicons?sz=64&domain_url=wallapop.com
// @run-at       document-end
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  "use strict";

  const KEY_HIDE_RESERVED = "gm_walla_hide_reserved";
  const KEY_HIDE_NONRES = "gm_walla_hide_nonreserved";
  let hideReserved = !!GM_getValue(KEY_HIDE_RESERVED, false);
  let hideNonRes = !!GM_getValue(KEY_HIDE_NONRES, false);

  GM_addStyle(
    ".gm-hide-reserved{display:none!important}.gm-hide-nonreserved{display:none!important}.gm-selected{background-color:#e8f6f4!important;color:#015354!important;border-color:#015354!important}.gm-strong-outline{box-shadow:0 0 0 1.25px currentColor inset}"
  );

  function chipsRow() {
    return document.querySelector('[class*="SearchPage__bubbles"] .d-flex.flex-wrap');
  }
  function sortBySlotIn(row) {
    if (!row) return null;
    const sortBtn = row.querySelector('[class*="SortByButton__"]');
    if (!sortBtn) return null;
    return sortBtn.closest(".me-2") || sortBtn.parentElement || null;
  }

  function findSelectedFromButton(btn) {
    const hit = Array.from(btn.classList).find(c => c.includes("applied-bubble_Bubble--selected__"));
    return hit || "";
  }
  function findSelectedFromCSS() {
    try {
      const sheets = Array.from(document.styleSheets);
      for (let i = 0; i < sheets.length; i += 1) {
        let rules;
        try { rules = sheets[i].cssRules; } catch (e) { continue; }
        if (!rules) continue;
        for (let j = 0; j < rules.length; j += 1) {
          const sel = rules[j].selectorText || "";
          if (!sel || !sel.includes("applied-bubble_Bubble--selected__")) continue;
          const m = sel.match(/\.applied-bubble_Bubble--selected__[\w-]+/);
          if (m && m[0]) return m[0].slice(1);
        }
      }
    } catch (e) {}
    return "";
  }

  function readNativeChipClasses() {
    const row = chipsRow();
    if (!row) return null;
    const exampleBtn = row.querySelector('button[class*="applied-bubble_Bubble__"]');

    let selected = findSelectedFromCSS();
    if (!selected && exampleBtn) selected = findSelectedFromButton(exampleBtn);
    if (!selected) selected = "";

    const button = exampleBtn ? Array.from(exampleBtn.classList)
      : ["applied-bubble_Bubble__Xbe51", "px-2", "d-flex", "justify-content-center", "align-items-center"];

    const contentWrapEl = exampleBtn && exampleBtn.querySelector('[class*="applied-bubble_Bubble__content_wrapper__"]');
    const contentEl = exampleBtn && exampleBtn.querySelector('[class*="applied-bubble_Bubble__content__"]');

    const contentWrap = contentWrapEl ? Array.from(contentWrapEl.classList)
      : ["applied-bubble_Bubble__content_wrapper__2LGtw", "px-2", "d-flex", "justify-content-center", "align-items-center"];
    const content = contentEl ? Array.from(contentEl.classList)
      : ["applied-bubble_Bubble__content__9tP0J"];

    return { button, selected, contentWrap, content };
  }

  // Measure off-DOM with fractional precision and return width in px (float)
  function measureChipWidth(label, classes, isSelected, boldWeight) {
    const measWrap = document.createElement("div");
    measWrap.style.position = "absolute";
    measWrap.style.visibility = "hidden";
    measWrap.style.left = "-9999px";
    measWrap.style.top = "-9999px";

    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = classes.button.join(" ");
    if (isSelected) {
      if (classes.selected) btn.classList.add(classes.selected);
      else btn.classList.add("gm-selected");
      btn.classList.add("gm-strong-outline");
    }

    const contentWrap = document.createElement("div");
    contentWrap.className = classes.contentWrap.join(" ");

    const text = document.createElement("div");
    text.className = classes.content.join(" ");
    text.textContent = label;
    if (isSelected) text.style.fontWeight = boldWeight || "700";

    contentWrap.appendChild(text);
    btn.appendChild(contentWrap);
    measWrap.appendChild(btn);
    document.body.appendChild(measWrap);

    const rect = btn.getBoundingClientRect(); // fractional precision
    const w = rect.width;
    measWrap.remove();
    return w;
  }

  function lockButtonWidth(btn, textNode, label, classes) {
    const baseline = getComputedStyle(textNode).fontWeight || "400";
    const wNormal = measureChipWidth(label, classes, false, baseline);
    const wSelected = measureChipWidth(label, classes, true, "700");
    const lock = Math.ceil(Math.max(wNormal, wSelected)) + 1; // 1px cushion against subpixel rounding
    btn.style.width = lock + "px";
    btn.style.minWidth = lock + "px";
    btn.style.boxSizing = "border-box";
    return { baselineWeight: baseline };
  }

  function buildChip({ id, label, get, set }) {
    const row = chipsRow();
    if (!row) return null;
    const existing = document.getElementById(id);
    if (existing) return existing;

    const classes = readNativeChipClasses();
    if (!classes) return null;

    const wrap = document.createElement("div");
    wrap.className = "me-2 mb-2";
    wrap.id = id;

    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = classes.button.join(" ");
    btn.setAttribute("role", "button");
    btn.setAttribute("aria-pressed", "false");

    const contentWrap = document.createElement("div");
    contentWrap.className = classes.contentWrap.join(" ");

    const text = document.createElement("div");
    text.className = classes.content.join(" ");
    text.textContent = label;

    contentWrap.appendChild(text);
    btn.appendChild(contentWrap);
    wrap.appendChild(btn);

    const slot = sortBySlotIn(row);
    if (slot && slot.parentNode === row) row.insertBefore(wrap, slot.nextSibling);
    else if (slot && slot.parentNode) slot.parentNode.insertBefore(wrap, slot.nextSibling);
    else return null;

    const { baselineWeight } = lockButtonWidth(btn, text, label, classes);

    function syncVisual() {
      const active = !!get();
      btn.setAttribute("aria-pressed", String(active));
      text.style.fontWeight = active ? "700" : baselineWeight;
      if (classes.selected) {
        btn.classList.toggle(classes.selected, active);
        btn.classList.toggle("gm-selected", false);
      } else {
        btn.classList.toggle("gm-selected", active);
      }
      btn.classList.toggle("gm-strong-outline", active);
    }

    function toggle() {
      const next = !get();
      set(next);
      applyFilter();
      syncVisual();
    }

    btn.addEventListener("click", toggle);
    btn.addEventListener("keydown", e => {
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        toggle();
      }
    });

    // Re-lock widths once web fonts are ready (prevents late font-swap jitter)
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(() => lockButtonWidth(btn, text, label, classes)).catch(function (e) {});
    }

    syncVisual();
    return wrap;
  }

  function grid() {
    return document.querySelector('div[aria-label="Items list"]');
  }
  function cards() {
    const g = grid();
    return g ? Array.from(g.querySelectorAll('a[href^="/item/"]')) : [];
  }
  function isReserved(card) {
    return !!card.querySelector('wallapop-badge[badge-type="reserved"]');
  }
  function applyFilter() {
    const list = cards();
    for (let i = 0; i < list.length; i += 1) {
      const el = list[i];
      el.classList.remove("gm-hide-reserved", "gm-hide-nonreserved");
      const reserved = isReserved(el);
      if (hideReserved && reserved) el.classList.add("gm-hide-reserved");
      if (hideNonRes && !reserved) el.classList.add("gm-hide-nonreserved");
    }
  }

  function injectChips() {
    const row = chipsRow();
    const slot = sortBySlotIn(row);
    if (!row || !slot) return false;

    buildChip({
      id: "gm_chip_hide_reserved",
      label: "Ocultar reservados",
      get: () => hideReserved,
      set: v => { hideReserved = !!v; GM_setValue(KEY_HIDE_RESERVED, hideReserved); }
    });
    buildChip({
      id: "gm_chip_hide_nonreserved",
      label: "Ocultar no reservados",
      get: () => hideNonRes,
      set: v => { hideNonRes = !!v; GM_setValue(KEY_HIDE_NONRES, hideNonRes); }
    });
    return true;
  }

  const root = document.querySelector("#__next") || document.body;
  const mo = new MutationObserver(list => {
    for (let i = 0; i < list.length; i += 1) {
      const m = list[i];
      if (m.addedNodes && m.addedNodes.length) {
        injectChips();
        applyFilter();
        break;
      }
    }
  });
  mo.observe(root, { childList: true, subtree: true });

  let tries = 0;
  (function tick() {
    if (injectChips()) {
      applyFilter();
    } else if (tries < 20) {
      tries += 1;
      setTimeout(tick, 200);
    }
  }());
})();