Perfect Circuit Numbered Pagination

Adds a persistent numbered page navigator to Perfect Circuit category pages.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Perfect Circuit Numbered Pagination
// @namespace    local.perfectcircuit.pagination
// @license      MIT
// @version      0.0.3
// @description  Adds a persistent numbered page navigator to Perfect Circuit category pages.
// @match        https://www.perfectcircuit.com/*.html*
// @run-at       document-idle
// @grant        none
// @noframes
// ==/UserScript==

(() => {
  "use strict";

  const HOST_ID = "pc-numbered-pagination-host";
  const COLLAPSED_KEY = "pc-numbered-pagination:collapsed";
  const CACHE = new Map();

  const PAGE_PARAM = "p";
  const LIMIT_PARAM = "product_list_limit";
  const DEFAULT_PER_PAGE = 48;

  function normalizeText(value) {
    return String(value || "")
      .replace(/\u00a0/g, " ")
      .replace(/\s+/g, " ")
      .trim();
  }

  function toNumber(value) {
    const number = Number(String(value || "").replace(/,/g, ""));
    return Number.isFinite(number) ? number : 0;
  }

  function getCurrentPage() {
    const url = new URL(location.href);
    const page = toNumber(url.searchParams.get(PAGE_PARAM));
    return Number.isInteger(page) && page > 0 ? page : 1;
  }

  function getLimitFromUrl() {
    const url = new URL(location.href);
    const limit = toNumber(url.searchParams.get(LIMIT_PARAM));
    return Number.isInteger(limit) && limit > 0 ? limit : null;
  }

  function getBestPerPage(start, end, currentPage) {
    const fromUrl = getLimitFromUrl();
    if (fromUrl) return fromUrl;

    // On page 2+, the first visible item preserves the configured page size,
    // even when the current page is partial.
    if (currentPage > 1 && start > 1) {
      const inferred = Math.round((start - 1) / (currentPage - 1));
      if (Number.isInteger(inferred) && inferred > 0) return inferred;
    }

    const fromRange = end - start + 1;
    return fromRange > 0 ? fromRange : DEFAULT_PER_PAGE;
  }

  function parseCountFromText(text) {
    const clean = normalizeText(text);
    const match = clean.match(
      /(?:\bitems?\s*)?(\d[\d,]*)\s*(?:-|\u2010|\u2011|\u2012|\u2013|\u2014|\u2015|to)\s*(\d[\d,]*)\s+of\s+(\d[\d,]*)(?:\s+items?)?/i
    );

    if (!match) return null;

    const start = toNumber(match[1]);
    const end = toNumber(match[2]);
    const totalItems = toNumber(match[3]);
    const currentPage = getCurrentPage();
    const perPage = getBestPerPage(start, end, currentPage);
    const totalPages = Math.ceil(totalItems / perPage);

    if (
      !Number.isInteger(start) ||
      !Number.isInteger(end) ||
      !Number.isInteger(totalItems) ||
      start <= 0 ||
      end < start ||
      totalItems <= 0 ||
      perPage <= 0 ||
      totalPages <= 0
    ) {
      return null;
    }

    return {
      start,
      end,
      totalItems,
      perPage,
      totalPages,
      source: "count"
    };
  }

  function findCountInElement(element) {
    if (!element) return null;

    const ownerDocument = element.ownerDocument || document;
    const walker = ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
    let node;

    while ((node = walker.nextNode())) {
      const text = normalizeText(node.nodeValue);
      if (!text || text.length > 160) continue;

      const parsed = parseCountFromText(text);
      if (parsed) return parsed;
    }

    return null;
  }

  function findCountInDom() {
    const scopes = document.querySelectorAll(
      ".toolbar, .toolbar-products, .pages, .page-main, main"
    );

    for (const scope of scopes) {
      const parsed = findCountInElement(scope);
      if (parsed) return parsed;
    }

    return null;
  }

  function parsePageNumberFromAnchor(anchor) {
    try {
      const url = new URL(anchor.href, location.href);
      const fromParam = toNumber(url.searchParams.get(PAGE_PARAM));
      if (Number.isInteger(fromParam) && fromParam > 0) return fromParam;
    } catch {
      return null;
    }

    const label = normalizeText(
      [
        anchor.textContent,
        anchor.getAttribute("aria-label"),
        anchor.getAttribute("title")
      ]
        .filter(Boolean)
        .join(" ")
    );

    const labelMatch = label.match(/\bpage\s+(\d+)\b/i) || label.match(/^(\d+)$/);
    const fromLabel = labelMatch ? toNumber(labelMatch[1]) : 0;

    return Number.isInteger(fromLabel) && fromLabel > 0 ? fromLabel : null;
  }

  function getVisiblePageNumbers() {
    const containers = Array.from(
      document.querySelectorAll(".pages, .pagination, .toolbar, .toolbar-products")
    );

    return containers
      .flatMap((container) => Array.from(container.querySelectorAll("a[href]")))
      .map(parsePageNumberFromAnchor)
      .filter((page) => Number.isInteger(page) && page > 0);
  }

  async function fetchCurrentHtmlInfo() {
    const cacheKey = location.href;
    if (CACHE.has(cacheKey)) return CACHE.get(cacheKey);

    const promise = fetch(location.href, { credentials: "same-origin" })
      .then((response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.text();
      })
      .then((html) => {
        const parsedDocument = new DOMParser().parseFromString(html, "text/html");
        return findCountInElement(parsedDocument.body);
      })
      .catch(() => null);

    CACHE.set(cacheKey, promise);
    return promise;
  }

  async function getPaginationInfo() {
    const domInfo = findCountInDom();
    if (domInfo) return domInfo;

    const htmlInfo = await fetchCurrentHtmlInfo();
    if (htmlInfo) return htmlInfo;

    const visiblePages = getVisiblePageNumbers();
    const maxVisiblePage = visiblePages.length ? Math.max(...visiblePages) : 0;

    if (maxVisiblePage > 1) {
      return {
        start: null,
        end: null,
        totalItems: null,
        perPage: getLimitFromUrl() || DEFAULT_PER_PAGE,
        totalPages: maxVisiblePage,
        source: "visible-links"
      };
    }

    return null;
  }

  function makePageUrl(page) {
    const url = new URL(location.href);

    if (page <= 1) {
      url.searchParams.delete(PAGE_PARAM);
    } else {
      url.searchParams.set(PAGE_PARAM, String(page));
    }

    url.hash = "";
    return url.toString();
  }

  function isCollapsed() {
    try {
      return localStorage.getItem(COLLAPSED_KEY) === "true";
    } catch {
      return false;
    }
  }

  function setCollapsed(value) {
    try {
      localStorage.setItem(COLLAPSED_KEY, value ? "true" : "false");
    } catch {
      // Storage can be unavailable in restricted browsing contexts.
    }
  }

  function pageRange(totalPages, currentPage) {
    if (totalPages <= 13) {
      return Array.from({ length: totalPages }, (_, index) => index + 1);
    }

    const pages = [1, 2, 3, totalPages - 2, totalPages - 1, totalPages];

    for (let page = currentPage - 4; page <= currentPage + 4; page += 1) {
      pages.push(page);
    }

    return Array.from(new Set(pages))
      .filter((page) => page >= 1 && page <= totalPages)
      .sort((a, b) => a - b);
  }

  function buildStyles() {
    return `
      :host {
        all: initial;
        font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }

      .pc-panel {
        position: fixed;
        left: 16px;
        right: 16px;
        bottom: 16px;
        z-index: 2147483647;
        box-sizing: border-box;
        padding: 12px;
        color: #111;
        background: #fff;
        border: 1px solid #c9c9c9;
        border-radius: 10px;
        box-shadow: 0 8px 28px rgba(0, 0, 0, 0.2);
        font-size: 14px;
        line-height: 1.35;
      }

      .pc-collapsed {
        left: auto;
        right: 16px;
        width: auto;
        padding: 0;
        overflow: hidden;
      }

      .pc-top {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        margin-bottom: 10px;
      }

      .pc-title {
        font-weight: 700;
      }

      .pc-subtitle {
        color: #555;
        font-size: 12px;
        margin-top: 2px;
      }

      .pc-controls {
        display: flex;
        align-items: center;
        gap: 8px;
        flex-wrap: wrap;
      }

      .pc-pages {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        max-height: 28vh;
        overflow: auto;
      }

      .pc-link,
      .pc-button {
        box-sizing: border-box;
        border: 1px solid #bbb;
        border-radius: 7px;
        background: #fff;
        color: #111;
        cursor: pointer;
        font: inherit;
        text-decoration: none;
      }

      .pc-link {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 32px;
        height: 32px;
        padding: 0 8px;
      }

      .pc-link[aria-current="page"] {
        background: #e7e7e7;
        border-color: #888;
        font-weight: 800;
      }

      .pc-link[aria-disabled="true"],
      .pc-button[disabled] {
        cursor: default;
        opacity: 0.45;
      }

      .pc-button {
        padding: 6px 9px;
      }

      .pc-button:not([disabled]):hover,
      .pc-link:not([aria-disabled="true"]):hover {
        background: #f3f3f3;
      }

      .pc-input {
        width: 72px;
        box-sizing: border-box;
        border: 1px solid #aaa;
        border-radius: 7px;
        padding: 6px 8px;
        font: inherit;
      }

      .pc-ellipsis {
        display: inline-flex;
        align-items: center;
        height: 32px;
        color: #555;
      }

      .pc-open {
        padding: 9px 12px;
        font-weight: 700;
      }

      @media (max-width: 700px) {
        .pc-panel {
          left: 8px;
          right: 8px;
          bottom: 8px;
        }

        .pc-top {
          align-items: flex-start;
          flex-direction: column;
        }
      }
    `;
  }

  function addPageLink(container, page, currentPage, label) {
    const link = document.createElement("a");
    link.className = "pc-link";
    link.href = makePageUrl(page);
    link.textContent = label || String(page);
    link.setAttribute("aria-label", label ? `Go to ${label.toLowerCase()} page` : `Go to page ${page}`);

    if (page === currentPage && !label) {
      link.setAttribute("aria-current", "page");
    }

    container.appendChild(link);
  }

  function addDisabledLink(container, label) {
    const link = document.createElement("a");
    link.className = "pc-link";
    link.textContent = label;
    link.setAttribute("aria-disabled", "true");
    link.tabIndex = -1;
    link.addEventListener("click", (event) => event.preventDefault());
    container.appendChild(link);
  }

  function addEllipsis(container) {
    const span = document.createElement("span");
    span.className = "pc-ellipsis";
    span.textContent = "...";
    container.appendChild(span);
  }

  function renderExpanded(root, info) {
    const currentPage = Math.min(getCurrentPage(), info.totalPages);

    const panel = document.createElement("section");
    panel.className = "pc-panel";
    panel.setAttribute("aria-label", "Perfect Circuit numbered pagination");

    const top = document.createElement("div");
    top.className = "pc-top";

    const titleWrap = document.createElement("div");

    const title = document.createElement("div");
    title.className = "pc-title";
    title.textContent = `Page ${currentPage} of ${info.totalPages}`;

    const subtitle = document.createElement("div");
    subtitle.className = "pc-subtitle";
    subtitle.textContent = info.totalItems
      ? `${info.totalItems.toLocaleString()} items, ${info.perPage} per page`
      : "Using visible page links only";

    titleWrap.append(title, subtitle);

    const controls = document.createElement("form");
    controls.className = "pc-controls";

    if (currentPage > 1) {
      addPageLink(controls, currentPage - 1, currentPage, "Previous");
    } else {
      addDisabledLink(controls, "Previous");
    }

    if (currentPage < info.totalPages) {
      addPageLink(controls, currentPage + 1, currentPage, "Next");
    } else {
      addDisabledLink(controls, "Next");
    }

    const input = document.createElement("input");
    input.className = "pc-input";
    input.type = "number";
    input.inputMode = "numeric";
    input.min = "1";
    input.max = String(info.totalPages);
    input.placeholder = "Page";

    const jump = document.createElement("button");
    jump.className = "pc-button";
    jump.type = "submit";
    jump.textContent = "Go";

    controls.addEventListener("submit", (event) => {
      event.preventDefault();

      const page = toNumber(input.value);
      if (!Number.isInteger(page) || page < 1 || page > info.totalPages) return;

      location.href = makePageUrl(page);
    });

    const collapse = document.createElement("button");
    collapse.className = "pc-button";
    collapse.type = "button";
    collapse.textContent = "Hide";
    collapse.addEventListener("click", () => {
      setCollapsed(true);
      render(info);
    });

    controls.append(input, jump, collapse);
    top.append(titleWrap, controls);

    const pages = document.createElement("nav");
    pages.className = "pc-pages";
    pages.setAttribute("aria-label", "Page numbers");

    const range = pageRange(info.totalPages, currentPage);
    let previousPage = 0;

    for (const page of range) {
      if (previousPage && page > previousPage + 1) addEllipsis(pages);
      addPageLink(pages, page, currentPage);
      previousPage = page;
    }

    panel.append(top, pages);
    root.appendChild(panel);
  }

  function renderCollapsed(root, info) {
    const panel = document.createElement("section");
    panel.className = "pc-panel pc-collapsed";

    const open = document.createElement("button");
    open.className = "pc-button pc-open";
    open.type = "button";
    open.textContent = `Page ${getCurrentPage()} / ${info.totalPages}`;
    open.addEventListener("click", () => {
      setCollapsed(false);
      render(info);
    });

    panel.appendChild(open);
    root.appendChild(panel);
  }

  function render(info) {
    document.getElementById(HOST_ID)?.remove();

    if (!info || info.totalPages <= 1) return;

    const host = document.createElement("div");
    host.id = HOST_ID;

    const shadow = host.attachShadow({ mode: "open" });

    const style = document.createElement("style");
    style.textContent = buildStyles();

    shadow.appendChild(style);

    if (isCollapsed()) {
      renderCollapsed(shadow, info);
    } else {
      renderExpanded(shadow, info);
    }

    document.body.appendChild(host);
  }

  async function tryRender() {
    const info = await getPaginationInfo();
    render(info);
    return Boolean(info);
  }

  async function boot() {
    if (await tryRender()) return;

    let attempts = 0;
    const maxAttempts = 8;
    let pending = false;

    const timer = setInterval(async () => {
      if (pending) return;

      pending = true;
      attempts += 1;

      try {
        if ((await tryRender()) || attempts >= maxAttempts) {
          clearInterval(timer);
        }
      } finally {
        pending = false;
      }
    }, 500);
  }

  boot();
})();