CoffeeMonsterz-OrderSort-v3

Auto-sort Shopify order items by SKU with full SPA refresh support and safe DOM handling (compatible with AfterDiscount plugin)

// ==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();
})();