ArsonWarehouse

Auto-calculates trade value (replaces deprecated chrome extension)

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==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">&times;</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);
  });
}