// ==UserScript==
// @name Camerax Floating Live Multi-Term Search + Price Sort (Stable)
// @namespace http://tampermonkey.net/
// @version 2.21
// @description Stable: floating purple search, multi-term partial AND filtering, re-sorts visible items by price, preserves grid without assumptions about wrapper classes.
// @match https://camerax.com/product-category/*
// @grant none
// @license GNU GPLv3
// ==/UserScript==
(function() {
'use strict';
const FLOAT_WIDTH = '320px';
const PURPLE = '#b200ff';
const DEBOUNCE_MS = 160;
function log(...args) { try { console.log('[CamX-userscript]', ...args); } catch (e) {} }
function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
function findProductsContainer() {
return document.querySelector('.products')
|| document.querySelector('.products.row')
|| document.querySelector('ul.products')
|| document.querySelector('#main .content')
|| document.querySelector('.shop-container')
|| null;
}
// For each product element (.product-small), find the wrapper that is a direct child of container.
// This preserves the exact structure the theme expects.
function collectProductEntries(container) {
if (!container) return [];
const productEls = Array.from(container.querySelectorAll('.product-small'));
const entries = productEls.map(productEl => {
// climb ancestors until we reach an element whose parent is the container (or we stop at root)
let wrapper = productEl;
try {
while (wrapper.parentElement && wrapper.parentElement !== container && wrapper !== document.body) {
wrapper = wrapper.parentElement;
}
} catch (e) { /* defensive */ }
// If we didn't find a wrapper whose parent is container, fallback to the productEl itself
if (!wrapper || wrapper.parentElement !== container) {
wrapper = productEl;
}
return { productEl, wrapperEl: wrapper };
});
// Remove duplicates (in case multiple product-small select the same wrapper)
const seen = new Set();
const dedup = [];
for (const e of entries) {
if (!seen.has(e.wrapperEl)) {
seen.add(e.wrapperEl);
dedup.push(e);
}
}
return dedup;
}
// Parse numeric price from wrapper: take last .woocommerce-Price-amount (handles ins/del)
function parsePriceFromWrapper(el) {
if (!el) return Infinity;
try {
const amounts = Array.from(el.querySelectorAll('.woocommerce-Price-amount'));
if (amounts.length === 0) return Infinity;
const node = amounts[amounts.length - 1];
const raw = (node.textContent || '').replace(/[^0-9.]/g, '');
const n = parseFloat(raw);
return Number.isFinite(n) ? n : Infinity;
} catch (e) {
return Infinity;
}
}
function getTitleAndMeta(entry) {
try {
const titleNode = entry.productEl.querySelector('.product-title a, .woocommerce-loop-product__title, .name.product-title');
const title = (titleNode && titleNode.textContent || '').trim();
const condNode = entry.productEl.querySelector('.condition-grade-shop, .condition-grade, .product-info .stock, .price-wrapper, .product-meta');
const condText = condNode ? condNode.textContent.trim() : '';
return (title + ' ' + condText).replace(/\s+/g, ' ').trim();
} catch (e) {
return '';
}
}
// filter & resort: always re-collect entries to stay in sync with dynamic changes
function filterAndResort(container, desc = false, query = '') {
if (!container) return;
try {
const entries = collectProductEntries(container);
const tokens = (query || '').toLowerCase().trim().split(/\s+/).filter(t => t.length > 0);
// Show/hide wrappers based on tokens (AND semantics)
entries.forEach(entry => {
const text = (getTitleAndMeta(entry) || '').toLowerCase();
let visible = true;
for (const tok of tokens) {
if (!text.includes(tok)) { visible = false; break; }
}
// Use inline style display to hide wrapper
try { entry.wrapperEl.style.display = visible ? '' : 'none'; } catch (e) {}
});
// Collect visible wrappers and sort them by price
const visible = entries
.filter(e => e.wrapperEl && e.wrapperEl.offsetParent !== null) // visible in layout
.sort((a, b) => {
const pa = parsePriceFromWrapper(a.wrapperEl);
const pb = parsePriceFromWrapper(b.wrapperEl);
return desc ? pb - pa : pa - pb;
});
// Re-append visible wrappers in sorted order (preserves existing nodes/classes)
visible.forEach(e => {
try { container.appendChild(e.wrapperEl); } catch (err) {}
});
} catch (err) {
log('filterAndResort error', err);
}
}
function createFloatingUI(onChangeCallback) {
// Avoid creating duplicate UI
if (document.getElementById('camx-floating-filter')) {
const existingInput = document.querySelector('#camx-floating-filter input[type="search"]');
return { root: document.getElementById('camx-floating-filter'), input: existingInput, getDesc: () => document.getElementById('camx-toggle')?.dataset.desc === '1' };
}
const wrap = document.createElement('div');
wrap.id = 'camx-floating-filter';
wrap.style.position = 'fixed';
wrap.style.top = '18px';
wrap.style.right = '18px';
wrap.style.zIndex = '999999';
wrap.style.width = FLOAT_WIDTH;
wrap.style.maxWidth = '46vw';
wrap.style.background = PURPLE;
wrap.style.padding = '12px';
wrap.style.borderRadius = '10px';
wrap.style.boxShadow = '0 6px 18px rgba(0,0,0,0.25)';
wrap.style.fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial';
wrap.style.color = '#fff';
wrap.style.display = 'flex';
wrap.style.flexDirection = 'column';
wrap.style.gap = '8px';
wrap.style.alignItems = 'stretch';
const header = document.createElement('div');
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.alignItems = 'center';
header.style.gap = '8px';
const title = document.createElement('div');
title.textContent = 'Filter & Sort';
title.style.fontWeight = '700';
title.style.fontSize = '13px';
const small = document.createElement('div');
small.style.fontSize = '11px';
small.textContent = 'price';
small.style.opacity = '0.95';
header.appendChild(title);
header.appendChild(small);
wrap.appendChild(header);
const input = document.createElement('input');
input.type = 'search';
input.placeholder = 'space-separated terms (AND). partial words OK';
input.style.padding = '8px';
input.style.fontSize = '14px';
input.style.border = '2px solid rgba(255,255,255,0.95)';
input.style.borderRadius = '6px';
input.style.background = PURPLE;
input.style.color = '#fff';
input.style.outline = 'none';
input.style.width = '100%';
input.autocomplete = 'off';
const controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.gap = '6px';
controls.style.alignItems = 'center';
const toggle = document.createElement('button');
toggle.id = 'camx-toggle';
toggle.dataset.desc = '0';
toggle.textContent = 'Asc';
toggle.title = 'Toggle Asc/Desc';
toggle.style.flex = '1';
toggle.style.padding = '7px 8px';
toggle.style.border = 'none';
toggle.style.borderRadius = '6px';
toggle.style.background = '#fff';
toggle.style.color = PURPLE;
toggle.style.fontWeight = '600';
toggle.style.cursor = 'pointer';
const clear = document.createElement('button');
clear.textContent = 'Clear';
clear.title = 'Clear filter';
clear.style.padding = '7px 8px';
clear.style.border = 'none';
clear.style.borderRadius = '6px';
clear.style.background = 'rgba(255,255,255,0.15)';
clear.style.color = '#fff';
clear.style.cursor = 'pointer';
controls.appendChild(toggle);
controls.appendChild(clear);
wrap.appendChild(input);
wrap.appendChild(controls);
document.body.appendChild(wrap);
input.addEventListener('focus', () => input.style.boxShadow = '0 0 0 4px rgba(255,255,255,0.06)');
let desc = false;
toggle.addEventListener('click', () => {
desc = !desc;
toggle.dataset.desc = desc ? '1' : '0';
toggle.textContent = desc ? 'Desc' : 'Asc';
onChangeCallback(input.value, desc);
});
clear.addEventListener('click', () => {
input.value = '';
onChangeCallback('', desc);
input.focus();
});
input.addEventListener('input', debounce(() => onChangeCallback(input.value, desc), DEBOUNCE_MS));
return { root: wrap, input, toggle, clear, getDesc: () => toggle.dataset.desc === '1' };
}
function initWhenReady(retries = 50) {
const container = findProductsContainer();
if (container) {
initApp(container);
return;
}
if (retries <= 0) {
log('products container not found; giving up.');
return;
}
setTimeout(() => initWhenReady(retries - 1), 300);
}
function initApp(container) {
try {
const ui = createFloatingUI((query, desc) => {
filterAndResort(container, desc, query);
});
// initial run
filterAndResort(container, ui.getDesc(), ui.input.value || '');
// observe container for dynamic changes (safe debounce)
const mo = new MutationObserver(debounce(() => {
try {
// on DOM changes, re-run filter/resort with current UI state
filterAndResort(container, ui.getDesc(), ui.input.value || '');
} catch (e) { log('MO callback error', e); }
}, 300));
mo.observe(container, { childList: true, subtree: true });
// re-run after history navigation
window.addEventListener('popstate', () => {
setTimeout(() => filterAndResort(container, ui.getDesc(), ui.input.value || ''), 300);
});
log('CamX userscript initialized');
} catch (e) {
log('initApp error', e);
}
}
// Start
initWhenReady();
})();