Bazaar-Markup-Tool

Set bazaar prices as a % of RRP with one click

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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 });
})();