Adds a property management dashboard to Torn's properties page with expiration tracking, offer status, and pagination
// ==UserScript==
// @name Torn Properties Manager
// @namespace http://tampermonkey.net/
// @version 4.2.0
// @description Adds a property management dashboard to Torn's properties page with expiration tracking, offer status, and pagination
// @author beans_ [174079]
// @match https://www.torn.com/properties.php*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// Constants for styling and configuration
const STYLES = {
container: 'margin: 20px; background: #2d2d2d; padding: 15px; border-radius: 5px;',
button: 'background: #444; color: #fff; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;',
tableCell: 'padding: 16px 8px; border-bottom: 1px solid #444; color: #fff;',
statusColors: {
offered: 'rgba(0, 255, 0, 0.1)',
expired: 'rgba(255, 0, 0, 0.1)',
warning: 'rgba(255, 165, 0, 0.1)',
hover: 'rgba(255, 255, 255, 0.1)'
},
stats: {
section: 'margin-top: 15px; text-align: center;',
toggleButton: 'background: #444; color: #fff; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;',
content: 'margin-top: 10px; padding: 10px; background: rgba(0,0,0,0.2); border-radius: 4px; text-align: left;',
flexContainer: `
display: flex;
gap: 20px;
@media (max-width: 768px) {
flex-direction: column;
}
`.replace(/\s+/g, ' ').trim(),
column: 'flex: 1;',
divider: `
width: 1px;
background: #444;
@media (max-width: 768px) {
width: 100%;
height: 1px;
margin: 10px 0;
}
`.replace(/\s+/g, ' ').trim(),
heading: 'color: #fff; margin: 0 0 15px 0;',
subheading: 'color: #888; font-size: 0.8em; margin: -10px 0 15px 0;',
grid: 'display: grid; gap: 10px;',
card: 'background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px; transition: background 0.2s;',
cardLabel: 'font-size: 0.9em; color: #888; margin-bottom: 5px;',
cardValue: 'font-size: 1.2em; color: #fff; font-weight: 500;',
input: {
container: 'display: flex;',
field: 'flex: 1; padding: 8px; background: #444; color: #fff; border: 1px solid #666; border-radius: 4px; font-size: 1.1em;',
fieldLeft: 'border-radius: 4px 0 0 4px; border-right: none;',
fieldFull: 'width: calc(100% - 18px);',
button: 'background: #444; color: #fff; border: 1px solid #666; border-radius: 0 4px 4px 0; padding: 8px 12px; text-decoration: none; display: flex; align-items: center;'
},
calculateButton: 'background: #444; color: #fff; border: 1px solid #666; padding: 10px; border-radius: 4px; cursor: pointer; width: 100%; font-size: 1.1em; transition: background 0.2s;',
results: {
container: 'display: none; background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px;',
grid: 'display: grid; gap: 12px; grid-template-columns: repeat(2, 1fr);'
}
},
common: {
flexCenter: 'display: flex; justify-content: center; align-items: center;',
flexBetween: 'display: flex; justify-content: space-between; align-items: center;',
white: 'color: #fff;',
inputField: 'padding: 5px; background: #444; color: #fff; border: 1px solid #666; border-radius: 3px;',
marginBottom15: 'margin-bottom: 15px;',
textCenter: 'text-align: center;',
tableHeader: 'padding: 16px 8px; text-align: left; border-bottom: 1px solid #444; font-weight: bold;'
},
marketBar: {
bar: 'margin: 10px 16px; padding: 12px 14px; background: #1e2a1e; border: 1px solid #3a5a3a; border-radius: 6px; color: #fff;',
title: 'font-size: 0.85em; color: #aaa; margin-bottom: 6px;',
rate: 'font-size: 1.15em; font-weight: bold; color: #7ecf7e; margin-bottom: 8px;',
useBtn: 'background: #2d5a2d; color: #fff; border: 1px solid #4a8a4a; border-radius: 4px; padding: 6px 14px; cursor: pointer; font-size: 0.9em;',
warning: 'margin-top: 8px; font-size: 0.82em; color: #c8a040; padding: 6px 8px; background: rgba(200,160,64,0.1); border-radius: 4px;',
warningLink: 'color: #d4a84b; text-decoration: underline; margin-left: 4px;'
},
priceModal: {
overlay: 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 99999; display: flex; justify-content: center; align-items: center;',
box: 'background: #2a2a2a; border: 1px solid #555; border-radius: 8px; padding: 24px 28px; max-width: 380px; width: 90%; color: #fff; font-family: inherit;',
title: 'font-size: 1.1em; font-weight: bold; color: #e8a040; margin-bottom: 12px;',
body: 'font-size: 0.95em; color: #ddd; margin-bottom: 18px; line-height: 1.5;',
highlight: 'font-weight: bold; color: #fff;',
btnRow: 'display: flex; gap: 10px; justify-content: flex-end;',
btnCancel: 'background: #444; color: #fff; border: 1px solid #666; border-radius: 4px; padding: 8px 18px; cursor: pointer; font-size: 0.9em;',
btnConfirm: 'background: #7a2020; color: #fff; border: 1px solid #b03030; border-radius: 4px; padding: 8px 18px; cursor: pointer; font-size: 0.9em;'
},
mobileTable: `
@media screen and (max-width: 768px) {
table, thead, tbody, th, td {
display: block;
}
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
margin-bottom: 15px;
background: rgba(0,0,0,0.2);
border-radius: 5px;
padding: 10px;
}
td {
border-bottom: none !important;
padding: 2px 0 !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
td::before {
display: block;
font-size: 0.75em;
color: #aaa;
margin-bottom: 1px;
}
/* Property Name - spans full width */
td:nth-of-type(1) {
grid-column: 1 / -1;
font-weight: bold;
font-size: 1.05em;
max-width: 100%;
}
/* Status */
td:nth-of-type(2)::before { content: "Status"; }
/* Rented By */
td:nth-of-type(3)::before { content: "Rented By"; }
/* Days Left */
td:nth-of-type(4)::before { content: "Days Left"; }
/* Daily Rent */
td:nth-of-type(5)::before { content: "Daily Rent"; }
/* Renew button - spans full width */
td:nth-of-type(6) {
grid-column: 1 / -1;
padding: 0 !important;
margin-top: 4px;
white-space: normal;
}
td:nth-of-type(6) a {
width: 100%;
padding: 10px !important;
text-align: center;
font-size: 1.1em;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
border-radius: 3px;
min-height: 37px;
line-height: 1.2;
}
/* Adjust filter section */
.filter-section {
flex-direction: column;
gap: 10px;
}
.filter-section > div {
width: 100% !important;
max-width: 100% !important;
}
/* Adjust pagination */
.page-info-row {
margin: 15px 0;
}
}
`
};
const CONFIG = {
ITEMS_PER_PAGE: 15,
REFRESH_COOLDOWN: 60000, // 1 minute in milliseconds
MAX_RETRIES: 30,
RETRY_DELAY: 100,
API_ENDPOINT: 'https://api.torn.com/v2',
API_BATCH_SIZE: 100,
MIN_API_KEY_LENGTH: 16,
MAX_RENTAL_PERIOD: 365,
MIN_RENTAL_PERIOD: 1,
OBSERVER_DELAY: 500,
WARNING_DAYS_THRESHOLD: 10,
RENTAL_MARKET_CACHE_DURATION: 30 * 60 * 1000,
PRIVATE_ISLAND_TYPE: 13,
MAX_HAPPINESS: 4225
};
const STORAGE_KEYS = {
API_KEY: 'tornApiKey',
CURRENT_USER_ID: 'property_currentUserId',
HIDE_AVAILABLE: 'hideAvailableProperties',
HIDE_OFFERED: 'hideOfferedProperties',
DEFAULT_RENTAL_PERIOD: 'defaultRentalPeriod',
DEFAULT_RENTAL_AMOUNT: 'defaultRentalAmount',
PROPERTY_ID_LAST_FETCHED: 'propertyId_lastFetched',
RENTAL_MARKET_CACHE: 'rentalMarketCache_13',
RENTAL_MARKET_CACHE_TIME: 'rentalMarketCacheTime_13',
UNDERCUT_PERCENT: 'undercutPercent'
};
const STATUS_DISPLAY = {
'rented': 'Rented',
'none': 'Empty',
'for_rent': 'For Rent'
};
// ==================== UTILITY FUNCTIONS ====================
/**
* Debounces a function
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in milliseconds
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Creates a statistics card element
*/
function createStatsCard(label, className, prefix = '', suffix = '') {
return `
<div style="${STYLES.stats.card}">
<div style="${STYLES.stats.cardLabel}">${label}</div>
<div style="${STYLES.stats.cardValue}">
${prefix}<span class="${className}">-</span>${suffix}
</div>
</div>
`;
}
/**
* Creates an input card element
*/
function createInputCard(label, className, hasSearchButton = false, defaultValue = '') {
const inputStyle = hasSearchButton ? STYLES.stats.input.fieldLeft : STYLES.stats.input.fieldFull;
const valueAttr = defaultValue ? `value="${defaultValue}"` : '';
// Add delete button for API key
const deleteButton = className === 'api-key-input' ? `
<button class="delete-api-key"
style="${STYLES.stats.input.button}; background: #662222; margin-left: 5px;"
title="Delete API Key">
🗑️
</button>
` : '';
return `
<div style="${STYLES.stats.card}">
<div style="${STYLES.stats.cardLabel}">${label}</div>
<div style="${STYLES.stats.input.container}">
<input type="${className === 'api-key-input' ? 'text' : 'number'}" class="${className}"
style="${STYLES.stats.input.field} ${inputStyle}"
placeholder="Enter ${label.toLowerCase()}"
${valueAttr}>
${hasSearchButton ? `
<a href="https://www.torn.com/properties.php?step=sellingmarket#/property=13"
target="_blank"
style="${STYLES.stats.input.button}">🔍</a>
` : ''}
${deleteButton}
</div>
</div>
`;
}
// ==================== CORE FUNCTIONS ===================="
// Optimize observers with weak references
function setupNavigationObserver() {
// Create a more specific observer for the properties content
const contentObserver = new MutationObserver(
debounce((mutations) => {
const propertiesContainer = document.querySelector('.properties-container');
const propertiesPageWrap = document.querySelector('#properties-page-wrap');
// If our container is gone but we're still on the properties page, recreate it
if (!propertiesContainer && propertiesPageWrap) {
createPropertiesTable();
// Pre-fetch user ID to cache it for later use
getUserId().catch(error => {
console.error('Failed to cache user ID:', error);
});
observeOfferSubmissions();
}
}, 100)
);
// Start observing the body for React navigation changes
contentObserver.observe(document.body, {
childList: true,
subtree: true
});
// Also watch for URL hash changes which might indicate React navigation
window.addEventListener('hashchange', () => {
setTimeout(() => {
const propertiesContainer = document.querySelector('.properties-container');
const propertiesPageWrap = document.querySelector('#properties-page-wrap');
if (!propertiesContainer && propertiesPageWrap) {
createPropertiesTable();
// Pre-fetch user ID to cache it for later use
getUserId().catch(error => {
console.error('Failed to cache user ID:', error);
});
observeOfferSubmissions();
}
}, 100); // Small delay to let React render
});
}
/**
* Determines the background color for a property row
* @param {Object} property - Property data
* @returns {string} CSS background-color value
*/
function getPropertyRowColor(property) {
// Green: Has lease extension OR is for_rent status with no renter
if (property.lease_extension !== null && property.lease_extension !== undefined) return STYLES.statusColors.offered;
if (property.status === "for_rent" && !property.rented_by) return STYLES.statusColors.offered;
// Red: Status is "none" with no renter (unused empty property)
if (property.status === "none" && !property.rented_by) return STYLES.statusColors.expired;
// Orange: No lease extension but lease has few days remaining
if ((property.lease_extension === null || property.lease_extension === undefined) && property.daysLeft <= CONFIG.WARNING_DAYS_THRESHOLD && property.daysLeft > 0) return STYLES.statusColors.warning;
return '';
}
function createApiKeyForm(isIncorrectKey = false) {
return `
<div class="properties-container" style="${STYLES.container}">
<div style="${STYLES.common.flexBetween}; ${STYLES.common.marginBottom15}">
<h2 style="${STYLES.common.white}; margin: 0;">Properties Manager</h2>
</div>
<div style="${STYLES.common.textCenter}">
${isIncorrectKey ?
`<p style="color: #ff6666; margin-bottom: 15px;">Incorrect API Key detected. Please enter a new one:</p>` :
`<p style="color: #fff; margin-bottom: 15px;">Please enter your Torn API key to continue:</p>`
}
<input type="text" id="torn-api-key" style="${STYLES.common.inputField}; margin-right: 10px;">
<button id="submit-api-key" style="${STYLES.button}">Submit</button>
</div>
</div>`;
}
function createPropertiesTable() {
// Remove any existing containers that might be stale
const existingContainers = document.querySelectorAll('.properties-container');
existingContainers.forEach(container => container.remove());
// Check for API key first
const apiKey = localStorage.getItem(STORAGE_KEYS.API_KEY);
const targetElement = document.querySelector('#properties-page-wrap');
// Wait for target element to exist with a maximum number of retries
if (!targetElement) {
if (!window.propertiesRetryCount) {
window.propertiesRetryCount = 0;
}
if (window.propertiesRetryCount < 30) { // Try for up to 3 seconds (30 * 100ms)
window.propertiesRetryCount++;
setTimeout(createPropertiesTable, 100);
} else {
console.error('Properties Manager: Failed to find #properties-page-wrap after 30 attempts');
window.propertiesRetryCount = 0;
}
return;
}
// Reset retry count on success
window.propertiesRetryCount = 0;
if (!apiKey && targetElement) {
targetElement.insertAdjacentHTML('afterbegin', createApiKeyForm());
// Add API key submission handler after a small delay to ensure DOM is ready
setTimeout(() => {
const submitButton = document.getElementById('submit-api-key');
if (submitButton) {
submitButton.addEventListener('click', function() {
const apiKeyInput = document.getElementById('torn-api-key');
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
alert('Please enter an API key');
return;
}
if (apiKey.length < CONFIG.MIN_API_KEY_LENGTH || !/^[a-zA-Z0-9]+$/.test(apiKey)) {
alert(`API Key must be at least ${CONFIG.MIN_API_KEY_LENGTH} characters and contain only letters and numbers`);
return;
}
try {
localStorage.setItem(STORAGE_KEYS.API_KEY, apiKey);
document.querySelector('.properties-container').remove();
createPropertiesTable();
} catch (error) {
alert('Error saving API key: ' + error.message);
}
});
}
}, 0);
return;
}
const tableHTML = `
<style>${STYLES.mobileTable}</style>
<div class="properties-container" style="${STYLES.container}">
<div class="properties-header" style="${STYLES.common.flexBetween}; ${STYLES.common.marginBottom15}">
<h2 style="${STYLES.common.white}; margin: 0; cursor: pointer;">Properties Manager</h2>
<div style="display: flex; align-items: center; gap: 10px;">
<span class="collapse-icon" style="${STYLES.common.white}; font-size: 20px; cursor: pointer;">▶</span>
</div>
</div>
<div class="properties-content" style="display: none;">
<div class="filter-section" style="margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 10px; flex: 0 1 auto; max-width: 200px; width: 100%;">
<input type="text"
id="player-id-search"
placeholder="Search ID or Name"
style="padding: 5px; background: #444; color: #fff; border: 1px solid #666; border-radius: 3px; width: calc(100% - 70px);">
<button id="clear-search" style="${STYLES.button}">Clear</button>
</div>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<label style="color: #fff; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="hide-available" style="cursor: pointer;">
Hide Available
</label>
<label style="color: #fff; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="hide-offered" style="cursor: pointer;">
Hide Offered
</label>
<button id="refresh-properties" style="${STYLES.button}">Refresh</button>
</div>
</div>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; color: #fff;">
<thead>
<tr>
<th style="display: none;">Property ID</th>
<th style="${STYLES.common.tableHeader}; max-width: 120px; width: 120px;">Property Name</th>
<th style="${STYLES.common.tableHeader}">Status</th>
<th style="${STYLES.common.tableHeader}">Rented By</th>
<th style="${STYLES.common.tableHeader}">Days Left</th>
<th style="${STYLES.common.tableHeader}">Daily Rent</th>
<th style="${STYLES.common.tableHeader}">Renew</th>
</tr>
</thead>
<tbody id="properties-table-body">
</tbody>
</table>
</div>
<div class="page-info-row" style="text-align: center; margin: 10px 0;">
<span id="page-info" style="color: #fff; display: inline-block; padding: 5px 10px; background: rgba(0,0,0,0.2); border-radius: 4px;">Page 1</span>
</div>
<div class="pagination" style="margin-top: 15px; display: flex; justify-content: center; gap: 10px; width: 100%; max-width: 100%; overflow: hidden;">
<button id="prev-page" style="${STYLES.button}">Previous</button>
<button id="next-page" style="${STYLES.button}">Next</button>
</div>
</div>
</div>`;
if (targetElement) {
targetElement.insertAdjacentHTML('afterbegin', tableHTML);
// Add variable declarations at the top
const header = document.querySelector('.properties-header');
const content = document.querySelector('.properties-content');
const icon = document.querySelector('.collapse-icon');
const refreshButton = document.getElementById('refresh-properties');
let dataFetched = false;
let lastRefreshTime = 0;
header.addEventListener('click', async () => {
const isVisible = content.style.display !== 'none';
content.style.display = isVisible ? 'none' : 'block';
icon.textContent = isVisible ? '▶' : '▼';
// Only fetch data the first time we expand
if (!isVisible && !dataFetched) {
await getPropertyData();
dataFetched = true;
}
});
refreshButton.addEventListener('click', async () => {
const currentTime = Date.now();
const timeSinceLastRefresh = currentTime - lastRefreshTime;
if (timeSinceLastRefresh >= 60000) { // 60000ms = 1 minute
await getPropertyData();
lastRefreshTime = currentTime;
} else {
const secondsRemaining = Math.ceil((60000 - timeSinceLastRefresh) / 1000);
alert(`Please wait ${secondsRemaining} seconds before refreshing again.`);
}
});
}
}
/**
* Handles API errors and displays appropriate messages
* @param {Object} error - Error object
*/
function handleApiError(error) {
console.error('Error fetching property data:', error);
// Check if it's an incorrect API key error
if (error.message.includes('Incorrect key')) {
// Clear the invalid API key
localStorage.removeItem('tornApiKey');
// Remove existing container if present
const existingContainer = document.querySelector('.properties-container');
if (existingContainer) {
existingContainer.remove();
}
// Show the API key form
const targetElement = document.querySelector('#properties-page-wrap');
if (targetElement) {
targetElement.insertAdjacentHTML('afterbegin', createApiKeyForm(true));
// Add API key submission handler after a small delay to ensure DOM is ready
setTimeout(() => {
const submitButton = document.getElementById('submit-api-key');
if (submitButton) {
submitButton.addEventListener('click', function() {
const apiKeyInput = document.getElementById('torn-api-key');
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
alert('Please enter an API key');
return;
}
if (apiKey.length < CONFIG.MIN_API_KEY_LENGTH || !/^[a-zA-Z0-9]+$/.test(apiKey)) {
alert(`API Key must be at least ${CONFIG.MIN_API_KEY_LENGTH} characters and contain only letters and numbers`);
return;
}
try {
localStorage.setItem(STORAGE_KEYS.API_KEY, apiKey);
document.querySelector('.properties-container').remove();
createPropertiesTable();
} catch (error) {
alert('Error saving API key: ' + error.message);
}
});
}
}, 0);
}
} else {
// Handle other errors with alert
const message = error.message.includes('API Error')
? error.message
: 'Error fetching property data. Please check your API key and try again.';
alert(message);
}
}
async function getAllProperties(apiKey) {
let allProperties = [];
let offset = 0;
let batchSize = CONFIG.API_BATCH_SIZE;
let hasMore = true;
while (hasMore) {
const response = await fetch(`https://api.torn.com/v2/user/properties?filters=ownedByUser&key=${apiKey}&offset=${offset}`);
const data = await response.json();
if (data.error) throw new Error(`API Error: ${data.error.error}`);
if (!data.properties) throw new Error('Invalid API response: missing properties data');
// Convert the object to an array and add to allProperties
allProperties = allProperties.concat(Object.entries(data.properties));
console.log(`Fetched batch at offset ${offset}:`, Object.keys(data.properties).length, 'properties');
const batchCount = Object.keys(data.properties).length;
if (batchCount < batchSize) {
hasMore = false;
} else {
offset += batchSize;
}
}
console.log('Total properties fetched:', allProperties.length); //4837907
return allProperties;
}
async function getPropertyData() {
const apiKey = localStorage.getItem('tornApiKey');
if (!apiKey) return;
try {
// Ensure we have the current user ID before proceeding
const currentUserId = await getUserId();
if (!currentUserId) {
console.error('Failed to get current user ID');
return;
}
const allProperties = await getAllProperties(apiKey);
// No localStorage cleanup needed - using API lease_extension data
let properties = allProperties
.filter(([id, prop]) =>
// Keep properties that are not "none" status owned by others
!(prop.status === "none" && Number(prop.owner.id) !== Number(currentUserId)) &&
// Exclude in_use properties
prop.status !== "in_use"
)
.map(([id, prop]) => ({
propertyId: prop.id,
name: prop.property.name,
status: prop.status,
daysLeft: prop.status !== "none" ? (prop.rental_period_remaining || 0) : 0,
renew: prop.status == "rented" ?`https://www.torn.com/properties.php#/p=options&ID=${prop.id}&tab=offerExtension` : `https://www.torn.com/properties.php#/p=options&ID=${prop.id}&tab=lease`,
lease_extension: prop.lease_extension,
costPerDay: prop.status == "rented" ? prop.cost_per_day : 0,
buttonValue: prop.status == "rented" ? "Renew" : "Lease",
rented_by: prop.status == "rented" ? prop.rented_by : null
}));
console.log('Properties after filtering:', properties.length);
properties = properties.sort((a, b) => a.daysLeft - b.daysLeft);
updateTable(properties);
// Property days left is now handled directly from API data
} catch (err) {
handleApiError(err);
}
}
function updateTable(properties) {
const tbody = document.getElementById('properties-table-body');
const prevButton = document.getElementById('prev-page');
const nextButton = document.getElementById('next-page');
const pageInfo = document.getElementById('page-info');
if (!tbody) return;
const itemsPerPage = 15;
let currentPage = 1;
const totalPages = Math.ceil(properties.length / itemsPerPage);
// Add statistics section after pagination
let statsSection = document.querySelector('.stats-section');
if (!statsSection) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = `
<div class="stats-section" style="${STYLES.stats.section}">
<style>
@media (max-width: 768px) {
.stats-flex-container {
flex-direction: column !important;
}
.stats-divider {
width: 100% !important;
height: 1px !important;
margin: 10px 0 !important;
}
}
</style>
<div style="display: flex; gap: 10px; justify-content: center;">
<button class="stats-toggle" style="${STYLES.stats.toggleButton}">
Show Statistics ▼
</button>
<button class="settings-toggle" style="${STYLES.stats.toggleButton}">
Settings ⚙️
</button>
</div>
<!-- Settings Card -->
<div class="settings-content" style="display: none; ${STYLES.stats.content}; margin-bottom: 15px;">
<h3 style="${STYLES.stats.heading}">Settings</h3>
<div style="${STYLES.stats.grid}">
${createInputCard('API Key', 'api-key-input', false, localStorage.getItem('tornApiKey') || '')}
${createInputCard('Default Rental Period (days)', 'default-rental-period', false, localStorage.getItem('defaultRentalPeriod') || '30')}
${createInputCard('Default Amount ($)', 'default-rental-amount', false, localStorage.getItem('defaultRentalAmount') || '23000000')}
${createInputCard('Undercut % (e.g. 1 = 1%)', 'undercut-percent', false, localStorage.getItem('undercutPercent') || '1')}
<button class="save-settings" style="${STYLES.stats.calculateButton}">
Save Settings 💾
</button>
</div>
</div>
<!-- Existing Stats Content -->
<div class="stats-content" style="display: none; ${STYLES.stats.content}">
<div class="stats-flex-container" style="${STYLES.stats.flexContainer}">
<!-- Revenue Stats Section -->
<div style="${STYLES.stats.column}">
<h3 style="${STYLES.stats.heading}">Revenue Stats</h3>
<div style="${STYLES.stats.subheading}">(Based on current daily rental rates)</div>
<div style="${STYLES.stats.grid}">
${createStatsCard('🏘️ Total Properties', 'total-properties')}
${createStatsCard('💰 Daily Revenue', 'daily-revenue', '$')}
${createStatsCard('📅 Monthly Revenue', 'monthly-revenue', '$')}
${createStatsCard('📈 Annual Revenue', 'annual-revenue', '$')}
</div>
</div>
<div class="stats-divider" style="${STYLES.stats.divider}"></div>
<!-- ROI Calculator Section -->
<div style="${STYLES.stats.column}">
<h3 style="${STYLES.stats.heading}">ROI Calculator</h3>
<div style="${STYLES.stats.grid}">
${createInputCard('Property Cost ($) - Default value not up to date', 'pi-cost', true, 1667000000)}
${createInputCard('Daily Rent ($)', 'daily-rent', false, Math.max(...properties.map(prop => prop.costPerDay || 0)))}
<button class="calculate-roi" style="${STYLES.stats.calculateButton}">
Calculate ROI 📊
</button>
<div class="roi-result" style="${STYLES.stats.results.container}">
<div style="${STYLES.stats.results.grid}">
${createStatsCard('⏱️ Days to ROI', 'days-to-roi')}
${createStatsCard('📅 Months to ROI', 'months-to-roi')}
${createStatsCard('📆 Years to ROI', 'years-to-roi')}
${createStatsCard('📈 Annual ROI', 'annual-roi', '', '%')}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`.trim();
statsSection = tempDiv.firstElementChild;
}
// Add stats section if it doesn't exist AND if pagination exists
const paginationElement = document.querySelector('.pagination');
if (!document.querySelector('.stats-section') && paginationElement) {
paginationElement.insertAdjacentElement('afterend', statsSection);
// Add toggle functionality
const toggleBtn = statsSection.querySelector('.stats-toggle');
const content = statsSection.querySelector('.stats-content');
if (toggleBtn && content) {
toggleBtn.addEventListener('click', () => {
const isVisible = content.style.display !== 'none';
content.style.display = isVisible ? 'none' : 'block';
toggleBtn.textContent = `${isVisible ? 'Show' : 'Hide'} Statistics ${isVisible ? '▼' : '▲'}`;
});
}
// Add calculation functionality
const calculateBtn = statsSection.querySelector('.calculate-roi');
const resultDiv = statsSection.querySelector('.roi-result');
if (calculateBtn && resultDiv) {
calculateBtn.addEventListener('click', () => {
const propertyCost = parseFloat(statsSection.querySelector('.pi-cost')?.value) || 0;
const dailyRent = parseFloat(statsSection.querySelector('.daily-rent')?.value) || 0;
if (propertyCost && dailyRent) {
const daysToRoi = Math.ceil(propertyCost / dailyRent);
const monthsToRoi = (daysToRoi / 30).toFixed(1);
const yearsToRoi = (daysToRoi / 365).toFixed(1);
// Calculate annual ROI percentage
const annualReturn = (dailyRent * 365);
const annualRoiPercentage = ((annualReturn / propertyCost) * 100).toFixed(2);
resultDiv.style.display = 'block';
resultDiv.querySelector('.days-to-roi').textContent = daysToRoi.toLocaleString();
resultDiv.querySelector('.months-to-roi').textContent = monthsToRoi;
resultDiv.querySelector('.years-to-roi').textContent = yearsToRoi;
resultDiv.querySelector('.annual-roi').textContent = annualRoiPercentage;
}
});
}
}
// Update statistics if elements exist
const totalPropertiesElement = document.querySelector('.total-properties');
const dailyRevenueElement = document.querySelector('.daily-revenue');
const monthlyRevenueElement = document.querySelector('.monthly-revenue');
const annualRevenueElement = document.querySelector('.annual-revenue');
if (totalPropertiesElement && dailyRevenueElement && monthlyRevenueElement && annualRevenueElement) {
const totalProperties = properties.length;
const dailyRevenue = properties.reduce((sum, prop) => sum + (prop.costPerDay || 0), 0);
const monthlyRevenue = dailyRevenue * 30;
const annualRevenue = dailyRevenue * 365;
totalPropertiesElement.textContent = totalProperties;
dailyRevenueElement.textContent = dailyRevenue.toLocaleString();
monthlyRevenueElement.textContent = monthlyRevenue.toLocaleString();
annualRevenueElement.textContent = annualRevenue.toLocaleString();
}
// Initialize filter functionality
function initializeFilters(properties) {
const availableCount = properties.filter(prop => prop.status === "Available").length;
const offeredCount = properties.filter(prop => prop.lease_extension !== null && prop.lease_extension !== undefined).length;
const hideAvailableLabel = document.getElementById('hide-available').parentElement;
const hideOfferedLabel = document.getElementById('hide-offered').parentElement;
// Update labels with counts
hideAvailableLabel.innerHTML = `
<input type="checkbox" id="hide-available" style="cursor: pointer;">
Hide Available (${availableCount})
`;
hideOfferedLabel.innerHTML = `
<input type="checkbox" id="hide-offered" style="cursor: pointer;">
Hide Offered (${offeredCount})
`;
// Restore checkbox states and attach event listeners
const newAvailableCheckbox = document.getElementById('hide-available');
const newOfferedCheckbox = document.getElementById('hide-offered');
newAvailableCheckbox.checked = localStorage.getItem('hideAvailableProperties') === 'true';
newOfferedCheckbox.checked = localStorage.getItem('hideOfferedProperties') === 'true';
newAvailableCheckbox.addEventListener('change', () => {
localStorage.setItem('hideAvailableProperties', newAvailableCheckbox.checked);
currentPage = 1;
displayPage(currentPage);
});
newOfferedCheckbox.addEventListener('change', () => {
localStorage.setItem('hideOfferedProperties', newOfferedCheckbox.checked);
currentPage = 1;
displayPage(currentPage);
});
}
// Initialize filters
initializeFilters(properties);
function getFilteredProperties() {
const searchId = document.getElementById('player-id-search')?.value.trim();
let filtered = [...properties]; // Create a copy of the properties array
// Get current checkbox states directly from the elements
const hideAvailable = document.getElementById('hide-available')?.checked;
const hideOffered = document.getElementById('hide-offered')?.checked;
if (hideAvailable) {
filtered = filtered.filter(prop => prop.status !== "Available");
}
if (hideOffered) {
filtered = filtered.filter(prop => !(prop.lease_extension !== null && prop.lease_extension !== undefined));
}
if (searchId) {
filtered = filtered.filter(prop =>
prop.rented_by && (
prop.rented_by.id && prop.rented_by.id.toString() === searchId ||
prop.rented_by.name && prop.rented_by.name.toLowerCase().includes(searchId.toLowerCase())
)
);
}
return filtered;
}
function displayPage(page) {
const filteredProperties = getFilteredProperties();
const totalPages = Math.ceil(filteredProperties.length / itemsPerPage);
const start = (page - 1) * itemsPerPage;
const end = Math.min(start + itemsPerPage, filteredProperties.length);
const pageProperties = filteredProperties.slice(start, end);
tbody.innerHTML = ''; // Clear existing rows
pageProperties.forEach((prop, index) => {
const row = document.createElement('tr');
// Determine display status based on lease extension
let displayStatus = prop.status;
if (prop.lease_extension && prop.lease_extension.period) {
displayStatus = `Offered`;
} else if (STATUS_DISPLAY[prop.status]) {
displayStatus = STATUS_DISPLAY[prop.status];
}
const baseColor = getPropertyRowColor(prop);
row.style.cssText = `transition: background-color 0.2s ease; cursor: pointer; background-color: ${baseColor};`;
// Add hover handlers
row.addEventListener('mouseenter', () => {
row.style.backgroundColor = STYLES.statusColors.hover;
});
row.addEventListener('mouseleave', () => {
row.style.backgroundColor = baseColor;
});
row.innerHTML = `
<td style="${STYLES.tableCell}; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${prop.name}">${prop.name}</td>
<td style="${STYLES.tableCell}">${displayStatus}</td>
<td style="${STYLES.tableCell}">${prop.rented_by ? `<a href="https://www.torn.com/profiles.php?XID=${prop.rented_by.id}" target="_blank" style="color: #e8d5a3; text-decoration: none;">${prop.rented_by.name}</a>` : '-'}</td>
<td style="${STYLES.tableCell}">${prop.daysLeft}</td>
<td style="${STYLES.tableCell}">$${prop.costPerDay.toLocaleString()}</td>
<td style="${STYLES.tableCell}">
<a href="${prop.renew}" target="_blank" style="${STYLES.button}; text-decoration: none;">${prop.buttonValue}</a>
</td>
`;
tbody.appendChild(row);
});
// Update page info
pageInfo.textContent = `Showing ${start + 1}-${end} of ${filteredProperties.length} (Page ${page} of ${totalPages})`;
prevButton.disabled = page === 1;
nextButton.disabled = page === totalPages;
prevButton.style.opacity = page === 1 ? '0.5' : '1';
nextButton.style.opacity = page === totalPages ? '0.5' : '1';
}
// Add click handlers for pagination
prevButton.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
displayPage(currentPage);
}
});
nextButton.addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
displayPage(currentPage);
}
});
// Display first page
displayPage(1);
// Add search functionality
const searchInput = document.getElementById('player-id-search');
const clearButton = document.getElementById('clear-search');
if (searchInput && clearButton) {
searchInput.addEventListener('input', () => {
currentPage = 1; // Reset to first page when searching
displayPage(currentPage);
});
clearButton.addEventListener('click', () => {
searchInput.value = '';
currentPage = 1;
displayPage(currentPage);
});
}
// Add after the stats toggle event listener:
const settingsToggle = statsSection.querySelector('.settings-toggle');
const settingsContent = statsSection.querySelector('.settings-content');
const saveSettingsBtn = statsSection.querySelector('.save-settings');
if (settingsToggle && settingsContent) {
settingsToggle.addEventListener('click', () => {
const isVisible = settingsContent.style.display !== 'none';
settingsContent.style.display = isVisible ? 'none' : 'block';
settingsToggle.textContent = `Settings ${isVisible ? '⚙️' : '▼'}`;
// Hide stats content when showing settings
if (!isVisible) {
statsSection.querySelector('.stats-content').style.display = 'none';
statsSection.querySelector('.stats-toggle').textContent = 'Show Statistics ▼';
}
});
}
if (saveSettingsBtn) {
// Add event listener for delete API key button
const deleteApiKeyBtn = statsSection.querySelector('.delete-api-key');
if (deleteApiKeyBtn) {
deleteApiKeyBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to delete your API key?')) {
localStorage.removeItem('tornApiKey');
statsSection.querySelector('.api-key-input').value = '';
alert('API key deleted successfully!');
location.reload();
}
});
}
saveSettingsBtn.addEventListener('click', () => {
const apiKey = statsSection.querySelector('.api-key-input').value.trim();
const rentalPeriod = statsSection.querySelector('.default-rental-period').value.trim();
const defaultRentalAmount = statsSection.querySelector('.default-rental-amount').value.trim();
const undercutPercent = statsSection.querySelector('.undercut-percent').value.trim();
// Validate inputs
const errors = [];
if (apiKey && (apiKey.length < CONFIG.MIN_API_KEY_LENGTH || !/^[a-zA-Z0-9]+$/.test(apiKey))) {
errors.push(`API Key must be at least ${CONFIG.MIN_API_KEY_LENGTH} characters and contain only letters and numbers`);
}
if (rentalPeriod && (isNaN(rentalPeriod) || parseInt(rentalPeriod) < CONFIG.MIN_RENTAL_PERIOD || parseInt(rentalPeriod) > CONFIG.MAX_RENTAL_PERIOD)) {
errors.push(`Rental Period must be a number between ${CONFIG.MIN_RENTAL_PERIOD} and ${CONFIG.MAX_RENTAL_PERIOD} days`);
}
if (defaultRentalAmount && (isNaN(defaultRentalAmount) || parseInt(defaultRentalAmount) < 1)) {
errors.push('Rental Amount must be a positive number');
}
if (undercutPercent && (isNaN(undercutPercent) || parseFloat(undercutPercent) <= 0 || parseFloat(undercutPercent) > 100)) {
errors.push('Undercut % must be a number between 0 and 100');
}
if (errors.length > 0) {
alert('Validation errors:\\n' + errors.join('\\n'));
return;
}
// Save valid values
try {
if (apiKey) {
localStorage.setItem('tornApiKey', apiKey);
}
if (rentalPeriod) {
localStorage.setItem('defaultRentalPeriod', rentalPeriod);
}
if (defaultRentalAmount) {
localStorage.setItem('defaultRentalAmount', defaultRentalAmount);
}
if (undercutPercent) {
localStorage.setItem('undercutPercent', undercutPercent);
const undercutBtn = document.getElementById('torn-prop-undercut-rate');
if (undercutBtn) undercutBtn.textContent = `Undercut ${parseFloat(undercutPercent)}%`;
}
alert('Settings saved successfully!');
// Refresh if API key changed
if (apiKey && apiKey !== localStorage.getItem('tornApiKey')) {
location.reload();
}
} catch (error) {
alert('Error saving settings: ' + error.message);
}
});
}
// Property days left no longer stored in localStorage - using API data directly
}
async function fetchPrivateIslandRates() {
const cached = localStorage.getItem(STORAGE_KEYS.RENTAL_MARKET_CACHE);
const cachedTime = localStorage.getItem(STORAGE_KEYS.RENTAL_MARKET_CACHE_TIME);
if (cached && cachedTime && (Date.now() - parseInt(cachedTime)) < CONFIG.RENTAL_MARKET_CACHE_DURATION) {
console.log('Torn Props: Using cached rental market data', JSON.parse(cached));
return JSON.parse(cached);
}
const apiKey = localStorage.getItem(STORAGE_KEYS.API_KEY);
if (!apiKey) {
console.warn('Torn Props: No API key found, skipping market rate fetch');
return null;
}
try {
console.log('Torn Props: Fetching Private Island rental market data...');
const response = await fetch(`${CONFIG.API_ENDPOINT}/market/${CONFIG.PRIVATE_ISLAND_TYPE}/rentals?offset=0`, {
headers: { 'Authorization': `ApiKey ${apiKey}` }
});
const data = await response.json();
console.log('Torn Props: Raw API response:', data);
if (data.error) { console.warn('Torn Props: API error:', data.error); return null; }
if (!data.rentals) { console.warn('Torn Props: No rentals field in response'); return null; }
const rentals = Object.values(data.rentals).flat();
console.log('Torn Props: Total rentals returned:', rentals.length, '| Sample:', rentals[0]);
if (rentals.length === 0) return null;
const allMaxHappiness = rentals.every(r => r.happy === CONFIG.MAX_HAPPINESS);
const maxHappinessRentals = rentals.filter(r => r.happy === CONFIG.MAX_HAPPINESS);
console.log(`Torn Props: Max happiness (${CONFIG.MAX_HAPPINESS}) listings: ${maxHappinessRentals.length} / ${rentals.length} | allMaxHappiness: ${allMaxHappiness}`);
if (maxHappinessRentals.length === 0) { console.warn('Torn Props: No max happiness listings found'); return null; }
const lowestRate = Math.min(...maxHappinessRentals.map(r => r.cost_per_day));
console.log('Torn Props: Lowest rate found:', lowestRate);
const result = { lowestRate, allMaxHappiness, cachedAt: Date.now() };
localStorage.setItem(STORAGE_KEYS.RENTAL_MARKET_CACHE, JSON.stringify(result));
localStorage.setItem(STORAGE_KEYS.RENTAL_MARKET_CACHE_TIME, Date.now().toString());
return result;
} catch (e) {
console.error('Torn Props: Failed to fetch rental market data', e);
return null;
}
}
function showPriceWarningModal(enteredPerDay, lowestRate, onConfirm) {
const existing = document.getElementById('torn-prop-price-modal');
if (existing) existing.remove();
const pct = Math.round(Math.abs(enteredPerDay - lowestRate) / lowestRate * 100);
const direction = enteredPerDay < lowestRate ? 'below' : 'above';
const dirColor = direction === 'below' ? '#e05050' : '#e8a040';
const overlayEl = document.createElement('div');
overlayEl.id = 'torn-prop-price-modal';
overlayEl.style.cssText = STYLES.priceModal.overlay;
overlayEl.innerHTML = `
<div style="${STYLES.priceModal.box}">
<div style="${STYLES.priceModal.title}">⚠ Price Warning</div>
<div style="${STYLES.priceModal.body}">
Your price of <span style="${STYLES.priceModal.highlight}">$${enteredPerDay.toLocaleString()}/day</span>
is <span style="font-weight:bold;color:${dirColor}">${pct}% ${direction}</span>
the market low of <span style="${STYLES.priceModal.highlight}">$${lowestRate.toLocaleString()}/day</span>.
</div>
<div style="${STYLES.priceModal.btnRow}">
<button id="torn-prop-modal-cancel" style="${STYLES.priceModal.btnCancel}">Go Back</button>
<button id="torn-prop-modal-confirm" style="${STYLES.priceModal.btnConfirm}">Submit Anyway</button>
</div>
</div>`;
document.body.appendChild(overlayEl);
document.getElementById('torn-prop-modal-cancel').addEventListener('click', function() {
overlayEl.remove();
});
document.getElementById('torn-prop-modal-confirm').addEventListener('click', function() {
overlayEl.remove();
onConfirm();
});
overlayEl.addEventListener('click', function(e) {
if (e.target === overlayEl) overlayEl.remove();
});
}
function injectMarketRateBar(offerForm, costInputs, marketData) {
if (document.getElementById('torn-prop-market-bar')) {
console.log('Torn Props: Market bar already present, skipping');
return;
}
const { lowestRate, allMaxHappiness, cachedAt } = marketData;
const formattedRate = lowestRate.toLocaleString();
const nextUpdateTime = new Date((cachedAt || Date.now()) + CONFIG.RENTAL_MARKET_CACHE_DURATION).toLocaleTimeString();
const warningHtml = allMaxHappiness ? `
<div style="${STYLES.marketBar.warning}">
⚠ All listed properties have max happiness — this price may not be fully representative.
<a href="https://www.torn.com/properties.php?step=rentalmarket#/property=13"
target="_blank"
style="${STYLES.marketBar.warningLink}">View rental market</a>
</div>` : '';
const barHtml = `
<div id="torn-prop-market-bar" style="${STYLES.marketBar.bar}">
<div style="${STYLES.marketBar.title}">🏝 Private Island Market Rate (max happiness only) — <a href="https://www.torn.com/properties.php?step=rentalmarket#/property=13" target="_blank" style="${STYLES.marketBar.warningLink}">view market</a></div>
<div style="${STYLES.marketBar.rate}">Lowest: $${formattedRate} / day</div>
<div style="${STYLES.marketBar.title}">Cache updates at ${nextUpdateTime}</div>
<button id="torn-prop-use-rate" style="${STYLES.marketBar.useBtn}">Use this price</button>
<button id="torn-prop-undercut-rate" style="${STYLES.marketBar.useBtn}; margin-left: 8px;">Undercut ${parseFloat(localStorage.getItem(STORAGE_KEYS.UNDERCUT_PERCENT)) || 1}%</button>
${warningHtml}
</div>`;
offerForm.insertAdjacentHTML('afterend', barHtml);
function applyRate(rate) {
const amountInput = document.querySelector('ul.offerExtension-input li.amount input.input-money')
|| document.querySelector('#market ul.lease-input li.amount input.input-money');
const days = parseInt(amountInput?.value) || 1;
const totalCost = rate * days;
lastTrackedPerDay = rate;
costInputs.forEach(function(input) {
input.value = totalCost;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
});
}
document.getElementById('torn-prop-use-rate').addEventListener('click', function() {
applyRate(lowestRate);
});
document.getElementById('torn-prop-undercut-rate').addEventListener('click', function() {
const undercutPct = parseFloat(localStorage.getItem(STORAGE_KEYS.UNDERCUT_PERCENT)) || 1;
applyRate(Math.floor(lowestRate * (1 - undercutPct / 100)));
});
// Track the per-day cost ourselves — React-controlled inputs reset .value to their internal
// state after re-renders, so we can't reliably read it back from the DOM at submit time.
const _defaultAmt = parseInt(localStorage.getItem(STORAGE_KEYS.DEFAULT_RENTAL_AMOUNT)) || 23000000;
const _defaultDays = parseInt(localStorage.getItem(STORAGE_KEYS.DEFAULT_RENTAL_PERIOD)) || 30;
let lastTrackedPerDay = Math.round(_defaultAmt / _defaultDays);
// Keep tracking if user manually types in the cost field
const _trackInput = offerForm.querySelector('li.cost input[type="text"]');
if (_trackInput) {
_trackInput.addEventListener('input', function() {
const v = parseInt(String(_trackInput.value).replace(/[^0-9]/g, '')) || 0;
const _amt = document.querySelector('ul.offerExtension-input li.amount input.input-money')
|| document.querySelector('#market ul.lease-input li.amount input.input-money');
const d = parseInt(_amt?.value) || 1;
if (v > 0) lastTrackedPerDay = Math.max(1, Math.round(v / d));
});
}
let submitConfirmed = false;
function findSubmitButton() {
const byLi = document.querySelector('li.submit button, li.submit input[type="submit"]');
if (byLi) return byLi;
// Torn's "SEND OFFER" is <input type="submit"> inside the <form> — search the form and its parent
const formEl = offerForm.closest('form');
const ancestor = formEl
|| offerForm.closest('section, #market, .market-cont')
|| offerForm.parentElement;
const candidates = ancestor ? [...ancestor.querySelectorAll('button, input[type="submit"]')] : [];
const byText = candidates.find(btn => {
const t = (btn.value || btn.textContent).trim().toLowerCase();
return t.includes('send') || t.includes('offer') || t === 'submit' || t === 'next';
});
return byText || null;
}
function attachSubmitGuard(submitBtn) {
if (!submitBtn || submitBtn.dataset.priceGuardAttached) return;
submitBtn.dataset.priceGuardAttached = 'true';
submitBtn.addEventListener('click', function handleSubmitClick(e) {
if (submitConfirmed) {
submitConfirmed = false;
return;
}
const amountInput = document.querySelector('ul.offerExtension-input li.amount input.input-money')
|| document.querySelector('#market ul.lease-input li.amount input.input-money');
const days = parseInt(amountInput?.value) || 1;
const costLi = offerForm.querySelector('li.cost');
const allCostCandidates = costLi
? [...costLi.querySelectorAll('input')]
: [...costInputs];
const liveCostInput = allCostCandidates.find(inp => inp.type === 'text' || inp.type === 'number') || allCostCandidates[0];
const rawCost = liveCostInput?.value;
const domCost = parseInt(String(rawCost).replace(/[^0-9]/g, '')) || 0;
const domPerDay = domCost > 0 ? Math.max(1, Math.round(domCost / days)) : 0;
const enteredPerDay = domPerDay || lastTrackedPerDay || 0;
if (!enteredPerDay || !lowestRate) return;
const ratio = enteredPerDay / lowestRate;
if (ratio >= 0.75 && ratio <= 1.25) return;
e.preventDefault();
e.stopImmediatePropagation();
showPriceWarningModal(enteredPerDay, lowestRate, function() {
submitConfirmed = true;
submitBtn.click();
});
}, { capture: true });
}
const immediateBtn = findSubmitButton();
if (immediateBtn) {
attachSubmitGuard(immediateBtn);
} else {
setTimeout(function() { attachSubmitGuard(findSubmitButton()); }, 300);
}
}
// Auto-fill functionality for offer forms (keeping this for user convenience)
function observeOfferSubmissions() {
const url = new URL(window.location.href);
if (url.hash.includes('tab=offerExtension') || url.hash.includes('tab=lease')) {
const propertyId = url.hash.match(/ID=(\d+)/)?.[1];
if (!propertyId) return;
console.log('Auto-filling form for property:', propertyId);
const observer = new MutationObserver((mutations, obs) => {
const leaseMarketUl = document.querySelector('#market ul.lease-input');
if (leaseMarketUl) {
const costLi = leaseMarketUl.querySelector('li.cost');
const amountLi = leaseMarketUl.querySelector('li.amount');
if (costLi && !costLi.dataset.marketBarInjected) {
costLi.dataset.marketBarInjected = 'true';
if (amountLi && !amountLi.dataset.processed) {
const defaultPeriod = parseInt(localStorage.getItem('defaultRentalPeriod')) || 30;
const daysInput = amountLi.querySelector('input.input-money:not([type=hidden])');
if (daysInput) {
daysInput.value = defaultPeriod.toString();
daysInput.dispatchEvent(new Event('input', { bubbles: true }));
daysInput.dispatchEvent(new Event('change', { bubbles: true }));
amountLi.dataset.processed = 'true';
}
}
const isPrivateIsland = document.body.innerText.includes('Private Island');
if (isPrivateIsland) {
const costInputs = costLi.querySelectorAll('input.lease.input-money');
if (costInputs.length) {
fetchPrivateIslandRates().then(function(marketData) {
if (marketData) {
injectMarketRateBar(leaseMarketUl, costInputs, marketData);
}
});
}
}
}
}
const offerExtensionUl = document.querySelector('ul.offerExtension-input');
if (offerExtensionUl) {
const costLi = offerExtensionUl.querySelector('li.cost');
const amountLi = offerExtensionUl.querySelector('li.amount');
if (costLi && amountLi && !costLi.dataset.listenerAttached) {
console.log('Found form elements, setting default values...');
// Get default values from localStorage
const defaultPeriod = parseInt(localStorage.getItem('defaultRentalPeriod')) || 30;
const defaultAmount = parseInt(localStorage.getItem('defaultRentalAmount')) || 23000000;
// Only run if we haven't processed these inputs yet
if (!costLi.dataset.processed) {
setTimeout(() => {
// Set the cost inputs
const costInputs = costLi.querySelectorAll('input.offerExtension.input-money:not([data-processed])');
costInputs.forEach(input => {
if (!input.dataset.processed) {
input.value = defaultAmount;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dataset.processed = 'true';
}
});
// Set the amount input
const amountInput = amountLi.querySelector('input.input-money:not([data-processed])');
if (amountInput && !amountInput.dataset.processed) {
amountInput.value = defaultPeriod.toString();
amountInput.dispatchEvent(new Event('input', { bubbles: true }));
amountInput.dispatchEvent(new Event('change', { bubbles: true }));
amountInput.dataset.processed = 'true';
}
costLi.dataset.processed = 'true';
if (costLi.dataset.processed && amountInput?.dataset.processed) {
observer.disconnect();
}
// Inject Private Island market rate bar if applicable
const isPrivateIsland = document.body.innerText.includes('Private Island');
console.log('Torn Props: Private Island detection:', isPrivateIsland);
if (isPrivateIsland) {
const allCostInputs = costLi.querySelectorAll('input.offerExtension.input-money');
console.log('Torn Props: Cost inputs found:', allCostInputs.length);
fetchPrivateIslandRates().then(function(marketData) {
console.log('Torn Props: Market data result:', marketData);
if (marketData) {
const offerForm = document.querySelector('ul.offerExtension-input');
console.log('Torn Props: Offer form for injection:', offerForm);
if (offerForm && allCostInputs.length) {
injectMarketRateBar(offerForm, allCostInputs, marketData);
}
}
});
}
}, 500);
}
costLi.dataset.listenerAttached = 'true';
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
}
// Initialize the script
window.addEventListener('load', function() {
let attempts = 0;
const checkForElement = setInterval(() => {
attempts++;
const targetElement = document.querySelector('#properties-page-wrap');
if (targetElement) {
clearInterval(checkForElement);
createPropertiesTable();
// Pre-fetch user ID to cache it for later use
getUserId().catch(error => {
console.error('Failed to cache user ID:', error);
});
observeOfferSubmissions();
setupNavigationObserver();
} else if (attempts >= CONFIG.MAX_RETRIES) {
clearInterval(checkForElement);
console.error('Properties Manager: Failed to initialize');
}
}, CONFIG.RETRY_DELAY);
});
// Listen for URL changes (for single-page app navigation)
window.addEventListener('hashchange', observeOfferSubmissions);
function getUserId() {
const apiKey = localStorage.getItem('tornApiKey');
if (!apiKey) return Promise.resolve(null);
// Only fetch once per minute (60000 milliseconds)
const now = Date.now();
const lastFetched = localStorage.getItem('propertyId_lastFetched');
if (lastFetched && (now - parseInt(lastFetched) < 60000)) {
return Promise.resolve(localStorage.getItem('property_currentUserId'));
}
return fetch(`https://api.torn.com/v2/user?key=${apiKey}&selections=profile`)
.then(response => response.json())
.then(data => {
if (data.error) {
throw new Error(`API Error: ${data.error.error}`);
}
localStorage.setItem('property_currentUserId', data.profile.id);
localStorage.setItem('propertyId_lastFetched', now.toString());
return data.profile.id;
})
.catch(error => {
handleApiError(error);
return null;
});
}
})();