Bazaar-Markup-Tool

Set bazaar prices as a % of RRP with one click

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          Bazaar-Markup-Tool
// @namespace     https://www.torn.com/profiles.php?XID=4205356
// @version       1.1.0
// @description   Set bazaar prices as a % of RRP with one click
// @author        SoftStroker
// @match         https://www.torn.com/bazaar.php*
// @grant         GM_addStyle
// @grant         GM_getValue
// @grant         GM_setValue
// @run-at        document-idle
// @license       WTFPL
// ==/UserScript==

(function ()
{
    'use strict';

    let globalMarkupPct = parseFloat(GM_getValue('bpc_globalPct', -10));
    const perItemMarkups = {};
    let isProgrammaticUpdate = false;
    let userHasSetGlobal = false;

    GM_addStyle(`
        .bpc-toolbar {
            display: flex;
            align-items: center;
            gap: 10px;
            padding: 6px 12px;
            background: #1a1a1a;
            border-bottom: 1px solid #444;
            font-size: 13px;
            color: #ccc;
        }
        .bpc-toolbar label {
            display: flex;
            align-items: center;
            gap: 4px;
        }
        .bpc-pct-input {
            width: 64px;
            background: #111;
            border: 1px solid #666;
            color: #0f0;
            padding: 3px 6px;
            border-radius: 3px;
            text-align: center;
            font-size: 13px;
        }
        .bpc-set-all-btn {
            background: #2a2a2a;
            color: #0f0;
            border: 1px solid #555;
            padding: 3px 10px;
            border-radius: 3px;
            cursor: pointer;
            font-size: 13px;
            font-weight: bold;
        }
        .bpc-set-all-btn:hover {
            background: #383838;
            border-color: #0f0;
        }
        div[class*="row___"],
        div[class*="item___"] {
            overflow: visible !important;
        }
        div[class*="price___"] {
            position: relative !important;
        }
        .bpc-item-controls {
            position: absolute;
            left: calc(100% + 6px);
            top: 50%;
            transform: translateY(-50%);
            display: flex;
            align-items: center;
            gap: 3px;
            white-space: nowrap;
            background: #1c1c1c;
            padding: 2px 5px;
            border-radius: 3px;
            z-index: 50;
        }
        .bpc-item-pct {
            width: 50px;
            background: #111;
            border: 1px solid #555;
            color: #0f0;
            padding: 1px 4px;
            border-radius: 3px;
            text-align: center;
            font-size: 11px;
        }
        .bpc-pct-label {
            color: #888;
            font-size: 11px;
        }
        .bpc-preset-group {
            display: flex;
            gap: 3px;
            align-items: center;
        }
        .bpc-preset-btn {
            background: #1e2a1e;
            color: #0c0;
            border: 1px solid #444;
            padding: 2px 6px;
            border-radius: 3px;
            cursor: pointer;
            font-size: 12px;
        }
        .bpc-preset-btn:hover {
            background: #2a3a2a;
            border-color: #0f0;
        }
        .bpc-reset-btn {
            background: #2a1a1a;
            color: #f88;
            border: 1px solid #644;
            padding: 3px 10px;
            border-radius: 3px;
            cursor: pointer;
            font-size: 13px;
            font-weight: bold;
            margin-left: auto;
        }
        .bpc-reset-btn:hover {
            background: #3a2020;
            border-color: #f44;
        }
        .bpc-sign-btn {
            background: #2a2a2a;
            color: #0f0;
            border: 1px solid #555;
            padding: 1px 7px;
            border-radius: 3px;
            cursor: pointer;
            font-size: 11px;
            font-weight: bold;
        }
        .bpc-sign-btn:hover {
            background: #383838;
            border-color: #0f0;
        }
    `);

    /**
     * Clamps a markup percentage to a reasonable range.
     * @param {number} pct - The markup percentage to clamp.
     * @returns {number} The clamped percentage.
     */
    function clampPct(pct)
    {
        return Math.max(-99, Math.min(10000, pct));
    }

    /**
     * Pulls a unique key for an item from its DOM element.
     * @param {HTMLElement} itemEl - A bazaar item row element.
     * @returns {string|null} The item key, or null otherwise.
     */
    function getItemKey(itemEl)
    {
        return itemEl.getAttribute('aria-label') || itemEl.dataset.testid || null;
    }

    /**
     * Extracts and sanitizes the numeric recommended retail price from the given element.
     * @param {HTMLElement} rrpEl - The element containing the formatted RRP string.
     * @returns {number} The numeric RRP value, or 0 if it couldn't be parsed.
     */
    function parseRRP(rrpEl)
    {
        return parseFloat(rrpEl.textContent.replace(/[^0-9.]/g, '')) || 0;
    }

    /**
     * Sets the value of a React-controlled input element and dispatches the necessary events so React notices the change.
     * If we just set `input.value`, React won't know about it and will overwrite our change as soon as it re-renders that input.
     * @param {HTMLInputElement} input - The controlled input element to update.
     * @param {number|string} value - The new value to set.
     */
    function setReactInputValue(input, value)
    {
        const nativeValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
        nativeValueSetter.call(input, String(value));

        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.dispatchEvent(new Event('change', { bubbles: true }));
    }

    /**
     * Reads the current price from the item's price input, compares it to the RRP, and updates the % input and per-item override accordingly.
     * Used after an undo to resync the % values to whatever prices Torn restored, since those might not match what we had in memory.
     * @param {HTMLElement} itemEl - A bazaar item row element.
     * @returns {void}
     */
    function syncPctFromPrice(itemEl)
    {
        const itemId = getItemKey(itemEl);
        if (!itemId)
        {
            return;
        }

        const rrpEl = itemEl.querySelector('[class*="rrp___"]');
        const priceInput = itemEl.querySelector('input.input-money:not([type="hidden"])');
        const itemPctInput = itemEl.querySelector('.bpc-item-pct');

        if (!rrpEl || !priceInput || !itemPctInput)
        {
            return;
        }

        const rrp = parseRRP(rrpEl);
        const price = parseFloat((priceInput.value || '').replace(/[^0-9.]/g, '')) || 0;

        if (rrp > 0 && price > 0)
        {
            const pct = clampPct(Math.round((price - rrp) / rrp * 100));
            itemPctInput.value = pct;
            perItemMarkups[itemId] = pct;
        }
    }

    /**
     * Calculates the price based on the given markup percentage and RRP, then updates the price input for the item.
     * @param {HTMLElement} itemEl - A bazaar item row element.
     * @param {number} [pct=globalMarkupPct] - The markup percentage to apply, or globalMarkupPct if not specified.
     * @returns {void}
     */
    function applyPriceToItem(itemEl, pct = globalMarkupPct)
    {
        const rrpEl = itemEl.querySelector('[class*="rrp___"]');
        if (!rrpEl)
        {
            console.warn('[BPC] rrp___ element missing on item', itemEl);
            return;
        }

        const rrp = parseRRP(rrpEl);
        if (!rrp)
        {
            return;
        }

        const price = Math.round(rrp * (1 + pct / 100));
        const priceInput = itemEl.querySelector('input.input-money:not([type="hidden"])');

        if (!priceInput)
        {
            console.warn('[BPC] price input missing on item', itemEl);
            return;
        }

        isProgrammaticUpdate = true;
        setReactInputValue(priceInput, price);
        // Defer the flag reset so if React batches the input event and applies it asynchronously, we won't reset the flag too early and miss it.
        Promise.resolve().then(() =>
        {
            isProgrammaticUpdate = false;
        });
    }

    /**
     * Processes a bazaar item element by injecting the per-item markup controls and wiring up their event listeners.
     * @param {HTMLElement} itemEl - A bazaar item row element.
     * @returns {void}
     */
    function processItem(itemEl)
    {
        const priceDiv = itemEl.querySelector('[class*="price___"]');
        if (!priceDiv)
        {
            console.warn('[BPC] price___ div missing on item', itemEl);
            return;
        }

        // already processed this item so skip
        if (priceDiv.querySelector('.bpc-item-controls'))
        {
            return;
        }

        const itemId = getItemKey(itemEl);
        if (!itemId)
        {
            console.warn('[BPC] Item has no aria-label or data-testid, skipping', itemEl);
            return;
        }

        const rrpEl = itemEl.querySelector('[class*="rrp___"]');
        const priceInput = priceDiv.querySelector('input.input-money:not([type="hidden"])');

        let initialItemMarkup;
        if (perItemMarkups[itemId] !== undefined)
        {
            initialItemMarkup = perItemMarkups[itemId];
        }
        else if (userHasSetGlobal)
        {
            // user changed the global pct
            initialItemMarkup = globalMarkupPct;
            if (rrpEl && priceInput)
            {
                applyPriceToItem(itemEl, globalMarkupPct);
            }
        }
        else
        {
            initialItemMarkup = globalMarkupPct;

            if (rrpEl && priceInput)
            {
                const rrp = parseRRP(rrpEl);
                const price = parseFloat((priceInput.value || '').replace(/[^0-9.]/g, '')) || 0;

                if (rrp > 0 && price > 0)
                {
                    initialItemMarkup = clampPct(Math.round((price - rrp) / rrp * 100));
                }
            }
        }

        const itemControlsEl = document.createElement('span');

        itemControlsEl.className = 'bpc-item-controls';
        itemControlsEl.innerHTML = `
            <input type="number" class="bpc-item-pct" value="${initialItemMarkup}" step="1" min="-99" max="10000" title="Per-item RRP %">
            <span class="bpc-pct-label">%</span>
            <button type="button" class="bpc-sign-btn">+/−</button>
        `;

        const itemPctInput = itemControlsEl.querySelector('.bpc-item-pct');

        // block enter so it doesn't submit changes in the baazar
        itemPctInput.addEventListener('keydown', e =>
        {
            if (e.key === 'Enter')
            {
                e.preventDefault();
            }
        });

        if (priceInput)
        {
            priceInput.addEventListener('input', () =>
            {
                if (isProgrammaticUpdate || !rrpE1)
                {
                    return;
                }

                const rrp = parseRRP(rrpEl);
                
                if (!rrp)
                {
                    return;
                }

                const price = parseFloat(priceInput.value.replace(/[^0-9.]/g, '')) || 0;
                if (price > 0)
                {
                    const pct = Math.round((price - rrp) / rrp * 100);

                    itemPctInput.value = pct;
                    perItemMarkups[itemId] = pct;
                }
            });
        }

        itemPctInput.addEventListener('input', () =>
        {
            const itemMarkupPct = clampPct(parseFloat(itemPctInput.value) || 0);

            perItemMarkups[itemId] = itemMarkupPct;
            applyPriceToItem(itemEl, itemMarkupPct);
        });

        itemControlsEl.querySelector('.bpc-sign-btn').addEventListener('click', (e) =>
        {
            e.preventDefault();
            itemPctInput.value = -(parseFloat(itemPctInput.value) || 0);
            itemPctInput.dispatchEvent(new Event('input', { bubbles: true }));
        });

        priceDiv.appendChild(itemControlsEl);
    }

    /**
     * Injects the global markup toolbar into the bazaar manage panel and wires up its event listeners.
     * @param {HTMLElement} panel - The bazaar manage panel element (matches [data-testid="panel"]).
     * @returns {void}
     */
    function injectToolbar(panel)
    {
        if (panel.querySelector('.bpc-toolbar'))
        {
            return;
        }

        const toolbar = document.createElement('div');

        toolbar.className = 'bpc-toolbar';
        toolbar.innerHTML = `
            <span>Set Global Price:</span>
            <label>
                RRP <input type="number" class="bpc-pct-input" value="${globalMarkupPct}" step="1" min="-99" max="10000"> %
            </label>
            <button type="button" class="bpc-set-all-btn">+/−</button>
            <span class="bpc-preset-group">
                <button type="button" class="bpc-preset-btn" data-pct="-5">-5%</button>
                <button type="button" class="bpc-preset-btn" data-pct="-3">-3%</button>
                <button type="button" class="bpc-preset-btn" data-pct="-1">-1%</button>
                <button type="button" class="bpc-preset-btn" data-pct="1">1%</button>
                <button type="button" class="bpc-preset-btn" data-pct="3">3%</button>
                <button type="button" class="bpc-preset-btn" data-pct="5">5%</button>
            </span>
            <button type="button" class="bpc-reset-btn">Reset 0%</button>
        `;

        const globalPctInput = toolbar.querySelector('.bpc-pct-input');

        // Prevent Enter from submitting a parent form
        globalPctInput.addEventListener('keydown', e =>
        {
            if (e.key === 'Enter')
            {
                e.preventDefault();
            }
        });

        globalPctInput.addEventListener('input', e =>
        {
            globalMarkupPct = clampPct(parseFloat(e.target.value) || 0);
            userHasSetGlobal = true;
            GM_setValue('bpc_globalPct', globalMarkupPct);

            panel.querySelectorAll('[data-testid^="item-"]').forEach(itemEl =>
            {
                const itemId = getItemKey(itemEl);
                if (itemId)
                {
                    delete perItemMarkups[itemId];   // Remove the per-item override
                }

                const itemPctInput = itemEl.querySelector('.bpc-item-pct');
                if (itemPctInput)
                {
                    itemPctInput.value = globalMarkupPct;
                }

                applyPriceToItem(itemEl, globalMarkupPct);
            });
        });

        toolbar.querySelector('.bpc-set-all-btn').addEventListener('click', e =>
        {
            e.preventDefault();
            globalPctInput.value = -(parseFloat(globalPctInput.value) || 0);
            globalPctInput.dispatchEvent(new Event('input', { bubbles: true }));
        });

        toolbar.querySelectorAll('.bpc-preset-btn').forEach(btn =>
        {
            btn.addEventListener('click', e =>
            {
                e.preventDefault();
                globalPctInput.value = btn.dataset.pct;
                globalPctInput.dispatchEvent(new Event('input', { bubbles: true }));
            });
        });

        toolbar.querySelector('.bpc-reset-btn').addEventListener('click', e =>
        {
            e.preventDefault();
            globalPctInput.value = 0;
            globalPctInput.dispatchEvent(new Event('input', { bubbles: true }));
        });

        const header = panel.querySelector('[data-testid="panel-header"]');
        if (header)
        {
            header.after(toolbar);
        }
        else
        {
            console.warn('[BPC] panel-header not found — toolbar not injected');
        }

        panel.addEventListener('click', e =>
        {
            if (!e.target.closest('[data-testid="undo-button"]'))
            {
                return;
            }

            const syncMarkupsAfterUndo = () =>
            {
                userHasSetGlobal = false;
                for (const key of Object.keys(perItemMarkups))
                {
                    delete perItemMarkups[key];
                }

                panel.querySelectorAll('[data-testid^="item-"]').forEach(syncPctFromPrice);
            };

            if (!panel.querySelector('[class*="changed___"]'))
            {
                syncMarkupsAfterUndo();
                return;
            }

            const undoMutationWatcher = new MutationObserver(() =>
            {
                if (!panel.querySelector('[class*="changed___"]'))
                {
                    undoMutationWatcher.disconnect();
                    requestAnimationFrame(() => syncMarkupsAfterUndo());
                }
            });

            undoMutationWatcher.observe(panel, { subtree: true, attributes: true, attributeFilter: ['class'] });

            setTimeout(() =>
            {
                undoMutationWatcher.disconnect();
                syncMarkupsAfterUndo();
            }, 500);
        });
    }

    let mutationPending = false;

    const observer = new MutationObserver((mutations) =>
    {
        if (mutationPending)
        {
            return;
        }

        mutationPending = true;
        requestAnimationFrame(() =>
        {
            mutationPending = false;
            const panel = document.querySelector('[data-testid="panel"]');
            if (!panel)
            {
                return;
            }
            injectToolbar(panel);
            panel.querySelectorAll('[data-testid^="item-"]').forEach(processItem);
        });
    });

    observer.observe(document.body, { childList: true, subtree: true });
})();