Set bazaar prices as a % of RRP with one click
// ==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 });
})();