// ==UserScript==
// @name CoffeeMonsterz-OrderSort-v3
// @namespace http://tampermonkey.net/
// @version 3.4
// @description Auto-sort Shopify order items by SKU with full SPA refresh support and safe DOM handling (compatible with AfterDiscount plugin)
// @author Sam Tang / Alex Yuan
// @match https://admin.shopify.com/store/thecoffeemonsterzco/*/*
// @grant none
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
console.log("🚀 CoffeeMonsterz v3.5 Active (Stable Auto-Refresh + Safe DOM)");
let lastUrl = location.href;
let processing = false;
// 🧠 Debounce helper
const debounce = (fn, delay = 500) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
// 🧩 Main process logic
async function process(force = false) {
if (processing && !force) return;
processing = true;
const url = location.href;
if (!url.match(/\/orders\/\d+/)) {
processing = false;
return;
}
// Wait for Shopify to fully render order content
await waitFor(() => document.querySelector('span') && document.body.innerText.includes('SKU'), 800, 8);
const container = findItemContainer();
if (!container) {
console.warn("⚠️ Item container not found yet, will retry...");
setTimeout(() => process(true), 1000);
processing = false;
return;
}
sortItems(container);
safeRun(addDiscountPrice);
observeExpandCollapse(container);
ensureRefreshButton();
processing = false;
}
// 🕐 Wait for condition
function waitFor(condition, interval = 500, retries = 10) {
return new Promise(resolve => {
let tries = 0;
const timer = setInterval(() => {
if (condition() || tries++ > retries) {
clearInterval(timer);
resolve(true);
}
}, interval);
});
}
// 🧱 Find order item container
function findItemContainer() {
const skuLabels = Array.from(document.querySelectorAll('span')).filter(span => span.textContent.includes('SKU'));
if (!skuLabels.length) return null;
let best = null, maxCount = 0;
for (const el of skuLabels) {
let cur = el;
for (let i = 0; i < 10; i++) {
cur = cur.parentElement;
if (!cur) break;
const skuCount = cur.textContent.split('SKU').length - 1;
if (skuCount > maxCount) {
maxCount = skuCount;
best = cur;
}
}
}
return best;
}
// 🔢 Sort order items
function sortItems(container) {
const items = Array.from(container.children);
if (items.length < 2) return;
const data = items.map(el => ({
el,
...extractData(el)
}));
data.sort((a, b) => compareSKU(a.sku, b.sku) || a.title.localeCompare(b.title));
const fragment = document.createDocumentFragment();
for (const d of data) fragment.appendChild(d.el);
container.replaceChildren(fragment);
console.log("✅ Sorted by SKU");
}
function extractData(el) {
const text = el.textContent || '';
const skuMatch = text.match(/SKU[::]?\s*([A-Z0-9\-]+)/i);
const sku = skuMatch ? skuMatch[1] : '';
const titleEl = el.querySelector('a');
const title = titleEl ? titleEl.textContent.trim() : text.slice(0, 50);
return { sku, title };
}
function compareSKU(a, b) {
if (!a && !b) return 0;
if (!a) return 1;
if (!b) return -1;
// 拆解成 ["AB", 12, "C", 34, "D"] 这种数组
const tokenize = s => s.toUpperCase().match(/[A-Z]+|\d+/g) || [];
const aTokens = tokenize(a);
const bTokens = tokenize(b);
const len = Math.max(aTokens.length, bTokens.length);
for (let i = 0; i < len; i++) {
const aPart = aTokens[i] || "";
const bPart = bTokens[i] || "";
const aNum = /^\d+$/.test(aPart);
const bNum = /^\d+$/.test(bPart);
// 数字 vs 数字 → 数值比较
if (aNum && bNum) {
const diff = parseInt(aPart, 10) - parseInt(bPart, 10);
if (diff !== 0) return diff;
continue;
}
// 数字 vs 字母 → 数字排前
if (aNum && !bNum) return -1;
if (!aNum && bNum) return 1;
// 字母 vs 字母 → 字母序比较
const strDiff = aPart.localeCompare(bPart);
if (strDiff !== 0) return strDiff;
}
// 完全相同 → 保持稳定
return 0;
}
// 💰 Add discounted price safely
function addDiscountPrice() {
const subtotalNode = Array.from(document.querySelectorAll('span')).find(n => n.textContent.includes('Subtotal'));
if (!subtotalNode) return;
const section = subtotalNode.closest('section, div, li') || document.body;
const text = section.textContent;
const subtotal = extractDollar(text, 'Subtotal');
const discount = extractDollar(text, 'Discount');
const shipping = extractDollar(text, 'Shipping');
if (!subtotal) return;
if (discount) {
const after = subtotal - discount;
const node = subtotalNode.parentElement.cloneNode(true);
node.id = 'discountPriceRow';
updateRow(node, 'After Discount', `$${after.toFixed(2)}`);
safeAppend(section, node);
}
if (shipping) {
const before = subtotal - discount;
const node = subtotalNode.parentElement.cloneNode(true);
node.id = 'beforeShippingRow';
updateRow(node, 'Before Shipping', `$${before.toFixed(2)}`);
const shippingNode = Array.from(section.querySelectorAll('span')).find(s => s.textContent.includes('Shipping'));
if (shippingNode) {
safeInsertBefore(section, node, shippingNode.closest('div, li') || shippingNode);
} else {
safeAppend(section, node);
}
}
}
function extractDollar(text, keyword) {
const regex = new RegExp(`${keyword}[^$]*\\$\\s*([\\d,]+\\.?\\d*)`, 'i');
const match = text.match(regex);
return match ? parseFloat(match[1].replace(/,/g, '')) : 0;
}
function updateRow(node, label, value) {
node.querySelectorAll('span').forEach(s => {
if (s.textContent.includes('Subtotal')) s.textContent = label;
if (s.textContent.match(/\$\s*[\d,]+/)) s.textContent = value;
});
}
// 🧩 Safe DOM helpers
function safeAppend(parent, node) {
try {
if (parent && node && parent.appendChild) parent.appendChild(node);
} catch (err) {
console.warn("⚠️ safeAppend failed:", err);
}
}
function safeInsertBefore(parent, node, ref) {
try {
if (parent && node && ref && parent.insertBefore) parent.insertBefore(node, ref);
else safeAppend(parent, node);
} catch (err) {
console.warn("⚠️ safeInsertBefore failed:", err);
}
}
function safeRun(fn) {
try { fn(); } catch (e) { console.warn("⚠️ Safe run failed:", e); }
}
// 📦 Observe expand/collapse
function observeExpandCollapse(container) {
if (container._observed) return;
container._observed = true;
const debouncedSort = debounce(() => sortItems(container), 400);
const obs = new MutationObserver(muts => {
if (muts.some(m => m.type === 'attributes' || m.addedNodes.length || m.removedNodes.length))
debouncedSort();
});
obs.observe(container, { childList: true, subtree: true, attributes: true });
}
// 🔘 Add manual button
function ensureRefreshButton() {
if (document.getElementById('orderRefreshButton')) return;
const btn = document.createElement('button');
btn.textContent = '🔄 Resort Items';
btn.id = 'orderRefreshButton';
Object.assign(btn.style, {
position: 'fixed',
top: '80px',
right: '30px',
zIndex: 9999,
background: '#4CAF50',
color: '#fff',
border: 'none',
borderRadius: '8px',
padding: '8px 14px',
cursor: 'pointer',
fontWeight: 'bold',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
});
btn.addEventListener('click', () => {
console.log("🔁 Manual re-sort triggered");
process(true);
});
document.body.appendChild(btn);
}
// 🧭 Hook Shopify SPA navigation (pushState/replaceState)
function hookHistory() {
const push = history.pushState;
const replace = history.replaceState;
const trigger = debounce(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
console.log("🔄 Detected SPA navigation, reprocessing...");
process(true);
}
}, 600);
history.pushState = function (...args) { push.apply(this, args); trigger(); };
history.replaceState = function (...args) { replace.apply(this, args); trigger(); };
window.addEventListener('popstate', trigger);
}
// 🧠 Init
function init() {
if (window._coffeeOrderInit) return;
window._coffeeOrderInit = true;
hookHistory();
const watch = new MutationObserver(debounce(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
console.log("🔍 DOM URL change detected, reprocessing...");
process(true);
}
}, 800));
watch.observe(document.body, { childList: true, subtree: true });
process(true);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();