// ==UserScript==
// @name ArsonWarehouse
// @namespace https://arsonwarehouse.com
// @version 1.0.1
// @description Auto-calculates trade value (replaces deprecated chrome extension)
// @author Sulsay
// @match https://www.torn.com/trade.php
// @icon https://arsonwarehouse.com/images/favicon.ico
// @license MIT
// @grant GM_addElement
// @grant GM_xmlhttpRequest
// ==/UserScript==
/* global Alpine */
const TEMPLATE = `
<style>
.awh-dialog {
min-width: 320px;
max-width: 784px;
box-sizing: border-box;
z-index: 3; /* some arbitrary value that puts it above torn's native content */
.close-modal-btn {
position: absolute;
top: -10px;
right: -10px;
width: 28px;
height: 28px;
padding: 0;
border: none;
font-size: 1.5rem;
cursor: pointer;
background: #fff;
border-radius: 10px;
box-shadow: 1px 1px 3px rgba(0, 0, 0, .1);
}
.components-header {
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 10px;
column-gap: 10px;
> :first-child {
grid-column: 2;
}
}
.components-wrap {
position: relative;
}
.scroll-indicator {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 32px;
background: linear-gradient(to top, #fff, transparent);
}
.end-of-list {
position: relative;
flex: 0 0 32px; /* for flex containers */
height: 32px; /* for non-flex containers */
list-style: none;
span {
position: absolute;
inset: 0;
background: #fff;
text-align: center;
line-height: 32px;;
z-index: 1; /* on top of scroll-indicator */
}
}
.components {
display: flex;
flex-direction: column;
max-height: 30vh;
/*padding-bottom: 32px;*/
overflow: auto;
}
.component {
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 10px;
column-gap: 10px;
&:nth-child(even) {
background: #0001;
}
}
.grand-total {
display: flex;
flex-direction: column;
row-gap: 5px;
align-items: flex-end;
padding: 10px;
span {
font-weight: bold;
font-size: 1rem;
}
.apply-total {
padding: 0;
border: none;
cursor: pointer;
}
}
.text-right {
text-align: right;
}
.warnings-wrap {
position: relative;
}
.warnings {
margin-top: 1rem;
padding-left: .75rem;
max-height: 10vh;
overflow: auto;
list-style: disc;
li {
padding: 2px 0;
}
}
}
.calculate-price-button {
display: inline-flex;
align-items: center;
column-gap: 0.5rem;
padding: 0.5rem 1rem 0.5rem 0.75rem;
background-color: transparent;
border: 2px solid #dc2626;
border-radius: 5px;
color: #000;
cursor: pointer;
transition: background-color 200ms ease, color 200ms ease;
.flame-emoji {
font-size: 1rem;
}
&:hover {
background-color: #dc2626;
color: #fff;
}
}
</style>
<button class="calculate-price-button" type="button" x-on:click="openTradeDialog">
<span class="flame-emoji">🔥</span>
<span>ArsonWarehouse: Calculate Price</span>
</button>
<dialog class="awh-dialog" :open="isDialogOpenOrUndefined">
<button class="close-modal-btn" type="button" x-on:click="closeDialog">×</button>
<template x-if="tradeModel">
<div>
<div class="components-header">
<div class="text-right">Unit price</div>
<div class="text-right">Total</div>
</div>
<div class="components-wrap">
<div class="components">
<template x-for="cmp in tradeModel.trade.components" :key="cmp.key">
<div class="component">
<div x-text="cmp._formatted.nameWithQuantity"></div>
<div class="text-right" x-text="cmp._formatted.appliedPrice"></div>
<div class="text-right" x-text='cmp._formatted.totalPrice'></div>
</div>
</template>
<div class="end-of-list">
<span>-- end of list --</span>
</div>
</div>
<div class="scroll-indicator"></div>
</div>
<div class="grand-total">
<span x-text="tradeModel.trade._formatted.grandTotal"></span>
<button class="apply-total" type="button" x-on:click="applyTotal">Apply</button>
</div>
<template x-if="tradeModel.trade._computed.hasWarnings">
<div class="warnings-wrap">
<ul class="warnings">
<template x-for="warning in tradeModel.trade.warnings">
<li x-text="warning"></li>
</template>
<li class="end-of-list">
<span>-- end of list --</span>
</li>
</ul>
<div class="scroll-indicator"></div>
</div>
</template>
</div>
</template>
</dialog>`;
(function () {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
const addedElements = Array.from(mutation.addedNodes ?? []).filter(
(node) => node.nodeType === Node.ELEMENT_NODE,
);
if (addedElements.some((el) => el.classList.contains('trade-cont'))) {
void tradeDetected();
}
if (addedElements.some((el) => el.classList.contains('add-money'))) {
inputMoneyAndSave();
}
}
});
observer.observe(getTradeContainer(), { childList: true });
})();
function getTradeContainer() {
return document.querySelector('#trade-container');
}
function inputMoneyAndSave() {
const tradeContainer = getTradeContainer();
const amount = tradeContainer.dataset.grandTotal;
if (typeof amount === 'undefined') {
return;
}
delete tradeContainer.dataset.grandTotal;
const fields = Array.from(tradeContainer.querySelectorAll('input.input-money'));
for (let field of fields) {
field.value = amount;
}
const submitBtn = tradeContainer.querySelector('input[type=submit]');
submitBtn.removeAttribute('disabled');
submitBtn.classList.remove('disabled');
submitBtn.click();
}
async function tradeDetected() {
await loadAlpine();
Alpine.data('awh-trade', () => ({
dialogVisible: false,
tradeModel: null,
isDialogOpenOrUndefined() {
return this.dialogVisible ? true : undefined;
},
closeDialog() {
this.dialogVisible = false;
},
async openTradeDialog() {
const theirPanel = tradeContainer.querySelector('.user.right');
this.tradeModel = await fetchTrade({
theirItems: getItems(theirPanel),
theirName: getName(theirPanel),
});
this.dialogVisible = true;
this.tradeModel.trade = {
...this.tradeModel.trade,
_computed: {
hasWarnings: this.tradeModel.trade.warnings.length > 0,
grandTotal: this.tradeModel.trade.components.reduce(
(sum, cmp) => sum + cmp.applied_price * cmp.quantity,
0,
),
},
};
this.tradeModel.trade = {
...this.tradeModel.trade,
_formatted: {
grandTotal: formatCurrency(this.tradeModel.trade._computed.grandTotal),
},
};
this.tradeModel.trade.components.forEach((cmp) => {
cmp._formatted = {
nameWithQuantity: `${cmp.name} x${formatNumber(cmp.quantity)}`,
appliedPrice: formatCurrency(cmp.applied_price),
totalPrice: formatCurrency(cmp.applied_price * cmp.quantity),
};
});
},
applyTotal() {
// store grand total in the #trade-container so we can grab it from there once the money field renders
getTradeContainer().dataset.grandTotal = this.tradeModel.trade._computed.grandTotal;
const myPanel = tradeContainer.querySelector('.user.left');
const addMoneyBtn = myPanel.querySelector('.color1 .add a');
addMoneyBtn.click();
},
}));
const tradeContainer = getTradeContainer();
tradeContainer.setAttribute('x-data', 'awh-trade');
tradeContainer.insertAdjacentHTML('afterbegin', TEMPLATE);
}
function fetchTrade({ theirItems, theirName }) {
// todo add support for pda, where GM_xmlhttpRequest does not exist
return new Promise((resolve) => {
GM_xmlhttpRequest({
url: 'https://arsonwarehouse.com/api/v1/trades',
method: 'post',
data: JSON.stringify({
trade_id: getTradeId(),
plugin_version: 'userscript_v1.0.0',
buyer: parseInt(getCookie('uid'), 10),
seller: theirName,
items: theirItems,
}),
headers: { Accept: 'application/json' },
onload(response) {
resolve(JSON.parse(response.responseText));
},
});
});
}
function getItems(userDiv) {
return Array.from(userDiv.querySelectorAll('.color2 li'))
.filter((li) => li.textContent.trim() !== 'No items in trade')
.map((li) => {
const match = li.textContent.trim().match(/^(.*)\s+x(\d+)$/);
return match ? { name: match[1].trim(), quantity: parseInt(match[2], 10) } : null;
})
.filter(Boolean);
}
function getName(userDiv) {
return userDiv.querySelector('.title-black').textContent.trim();
}
function getTradeId() {
const match = window.location.hash.match(/ID=([^&]+)/);
if (!match[1]) {
throw Error('unable to get trade id');
}
return parseInt(match[1], 10);
}
const { format: formatNumber } = Intl.NumberFormat('en-US');
function formatCurrency(num) {
const formatted = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 2,
}).format(num);
// Try to reverse-parse the formatted string
const match = formatted.match(/^([\d.]+)([KMBT])$/);
if (match) {
const [, value, unit] = match;
const factor = { K: 1e3, M: 1e6, B: 1e9, T: 1e12 }[unit];
const approx = parseFloat(value) * factor;
if (Math.round(approx) !== Math.round(num)) {
// Rounding detected
return '$' + formatNumber(num); // fallback to raw number
}
}
return '$' + formatted;
}
function loadAlpine() {
return new Promise((resolve) => {
if (typeof Alpine !== 'undefined') {
// Alpine is already loaded (player may have gone back and forth between the trade index and a trade details page)
resolve();
return;
}
// todo add support for pda, where GM_addElement does not exist
GM_addElement('script', {
src: 'https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js',
type: 'text/javascript',
});
document.addEventListener('alpine:init', resolve);
});
}