// ==UserScript== // @name Neopets - Shop Till Balance & Quick Withdraw // @namespace https://www.scriptneo.com/ // @version 1.0.0 // @description Shows your Shop Till balance on the Shop Stock page and withdraws the full till with an AJAX request and toast notification. // @author Scriptneo // @license MIT // @match *://www.neopets.com/market.phtml* // @match *://neopets.com/market.phtml* // @run-at document-idle // @grant none // @downloadURL https://www.scriptneo.com/scripts/download.php?id=47 // @updateURL https://www.scriptneo.com/scripts/download.php?id=47 // ==/UserScript== (function () { 'use strict'; const SCRIPT_ID = 'np-shop-till-helper'; const TILL_URL = '/market.phtml?type=till'; const WITHDRAW_URL = '/np-templates/ajax/market/process_market.php'; const REFRESH_INTERVAL_MS = 60_000; let currentBalance = 0; let isLoading = false; let refreshTimer = null; function parseNP(value) { const digits = String(value ?? '').replace(/[^\d]/g, ''); return digits ? Number.parseInt(digits, 10) : 0; } function formatNP(value) { return Math.max(0, Number(value) || 0).toLocaleString('en-US'); } function isShopStockPage() { const url = new URL(window.location.href); return url.pathname.endsWith('/market.phtml') && url.searchParams.get('type') === 'your'; } function addStyles() { if (document.getElementById(`${SCRIPT_ID}-styles`)) return; const style = document.createElement('style'); style.id = `${SCRIPT_ID}-styles`; style.textContent = ` #${SCRIPT_ID}-widget { display: inline-flex; align-items: center; gap: 7px; margin-left: 8px; vertical-align: middle; white-space: nowrap; } #${SCRIPT_ID}-balance { font-weight: 700; color: #245f2d; } #${SCRIPT_ID}-withdraw { appearance: none; border: 1px solid #32743d; border-radius: 5px; background: linear-gradient(#62b66d, #3b8e48); color: #fff; cursor: pointer; font: 700 12px Arial, sans-serif; line-height: 1; padding: 7px 10px; box-shadow: 0 1px 2px rgba(0, 0, 0, .22); } #${SCRIPT_ID}-withdraw:hover:not(:disabled) { filter: brightness(1.06); } #${SCRIPT_ID}-withdraw:disabled { cursor: not-allowed; filter: grayscale(.55); opacity: .68; } #${SCRIPT_ID}-toast-container { position: fixed; top: 88px; right: 18px; z-index: 2147483647; display: flex; flex-direction: column; gap: 10px; width: min(360px, calc(100vw - 30px)); pointer-events: none; } .${SCRIPT_ID}-toast { display: flex; align-items: flex-start; gap: 10px; padding: 13px 15px; border: 1px solid rgba(0, 0, 0, .22); border-radius: 8px; background: #fff; color: #222; font: 700 13px/1.4 Arial, sans-serif; box-shadow: 0 8px 26px rgba(0, 0, 0, .25); opacity: 0; transform: translateX(18px); transition: opacity .2s ease, transform .2s ease; pointer-events: auto; } .${SCRIPT_ID}-toast.is-visible { opacity: 1; transform: translateX(0); } .${SCRIPT_ID}-toast--success { border-left: 5px solid #339344; } .${SCRIPT_ID}-toast--error { border-left: 5px solid #c33434; } .${SCRIPT_ID}-toast--info { border-left: 5px solid #3577b8; } .${SCRIPT_ID}-toast-icon { flex: 0 0 auto; font-size: 17px; line-height: 1.1; } @media (max-width: 700px) { #${SCRIPT_ID}-widget { display: flex; width: fit-content; margin: 8px 0 0; } #${SCRIPT_ID}-toast-container { top: 72px; right: 10px; } } `; document.head.appendChild(style); } function getToastContainer() { let container = document.getElementById(`${SCRIPT_ID}-toast-container`); if (container) return container; container = document.createElement('div'); container.id = `${SCRIPT_ID}-toast-container`; container.setAttribute('aria-live', 'polite'); container.setAttribute('aria-atomic', 'true'); document.body.appendChild(container); return container; } function showToast(message, type = 'info', duration = 4200) { const icons = { success: '✓', error: '!', info: 'i' }; const toast = document.createElement('div'); toast.className = `${SCRIPT_ID}-toast ${SCRIPT_ID}-toast--${type}`; toast.setAttribute('role', type === 'error' ? 'alert' : 'status'); const icon = document.createElement('span'); icon.className = `${SCRIPT_ID}-toast-icon`; icon.textContent = icons[type] || icons.info; const text = document.createElement('span'); text.textContent = message; toast.append(icon, text); getToastContainer().appendChild(toast); requestAnimationFrame(() => toast.classList.add('is-visible')); window.setTimeout(() => { toast.classList.remove('is-visible'); window.setTimeout(() => toast.remove(), 250); }, duration); } function findShopTillLink() { return Array.from(document.querySelectorAll('.mkt-page a[href*="market.phtml?type=till"]')) .find((link) => link.textContent.trim().toLowerCase() === 'shop till') || null; } function createWidget() { if (document.getElementById(`${SCRIPT_ID}-widget`)) { return document.getElementById(`${SCRIPT_ID}-widget`); } const tillLink = findShopTillLink(); if (!tillLink) return null; const widget = document.createElement('span'); widget.id = `${SCRIPT_ID}-widget`; const balance = document.createElement('span'); balance.id = `${SCRIPT_ID}-balance`; balance.textContent = 'Till: Loading…'; balance.title = 'Automatically refreshes every 60 seconds'; const button = document.createElement('button'); button.id = `${SCRIPT_ID}-withdraw`; button.type = 'button'; button.textContent = 'Withdraw Till'; button.disabled = true; button.addEventListener('click', withdrawEntireTill); widget.append(balance, button); // The page places a period text node directly after the Shop Till link. // Insert after that punctuation so the result reads: "Shop Till. Till: ..." const punctuationNode = tillLink.nextSibling; if (punctuationNode && punctuationNode.nodeType === Node.TEXT_NODE) { tillLink.parentNode.insertBefore(widget, punctuationNode.nextSibling); } else { tillLink.insertAdjacentElement('afterend', widget); } return widget; } function setLoading(loading, label = '') { isLoading = loading; const button = document.getElementById(`${SCRIPT_ID}-withdraw`); if (!button) return; button.disabled = loading || currentBalance <= 0; button.textContent = loading ? (label || 'Loading…') : 'Withdraw Till'; } function updateBalance(balance) { currentBalance = Math.max(0, Number(balance) || 0); const balanceEl = document.getElementById(`${SCRIPT_ID}-balance`); if (balanceEl) { balanceEl.textContent = `Till: ${formatNP(currentBalance)} NP`; } const button = document.getElementById(`${SCRIPT_ID}-withdraw`); if (button && !isLoading) { button.disabled = currentBalance <= 0; button.title = currentBalance > 0 ? `Withdraw all ${formatNP(currentBalance)} NP from your till` : 'Your till is empty'; } } function extractTillData(html) { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const form = doc.querySelector('#mkt-till-form'); const balanceElement = doc.querySelector('#mkt-till-balance'); const visibleBalance = parseNP(balanceElement?.textContent || ''); const cfgMatch = html.match(/\bcoffers\s*:\s*(\d+)/i); const configBalance = cfgMatch ? parseNP(cfgMatch[1]) : 0; const balance = configBalance || visibleBalance; if (!form) { throw new Error('Could not locate the Shop Till withdrawal form.'); } return { doc, form, balance }; } async function loadTillData() { const response = await fetch(TILL_URL, { method: 'GET', credentials: 'include', cache: 'no-store', headers: { 'x-requested-with': 'XMLHttpRequest' } }); if (!response.ok) { throw new Error(`Shop Till request failed (${response.status}).`); } const html = await response.text(); return extractTillData(html); } async function refreshBalance({ silent = true } = {}) { if (isLoading) return currentBalance; setLoading(true, 'Refreshing…'); try { const tillData = await loadTillData(); updateBalance(tillData.balance); return tillData.balance; } catch (error) { console.error('[Shop Till Helper]', error); if (!silent) { showToast(error.message || 'Could not read your Shop Till balance.', 'error'); } return currentBalance; } finally { setLoading(false); } } function copyFormData(sourceForm, amount) { const formData = new FormData(); sourceForm.querySelectorAll('input, select, textarea').forEach((field) => { if (!field.name || field.disabled) return; if ((field.type === 'checkbox' || field.type === 'radio') && !field.checked) return; formData.append(field.name, field.value); }); formData.set('type', 'withdraw'); formData.set('amount', String(amount)); return formData; } function requestPinIfNeeded(formData, sourceForm) { const pinField = sourceForm.querySelector('[name="pin"]'); if (!pinField) return true; const pin = window.prompt('Enter your Neopets PIN to withdraw your Shop Till:'); if (pin === null) return false; if (!/^\d{4}$/.test(pin.trim())) { showToast('Your PIN must be exactly 4 digits.', 'error'); return false; } formData.set('pin', pin.trim()); return true; } function parseServerError(responseText) { const text = String(responseText || '').trim(); const errorIndex = text.indexOf('error|'); if (errorIndex !== -1) { return text.slice(errorIndex + 6).trim() || 'The withdrawal could not be completed.'; } return ''; } function updateHeaderNP(withdrawnAmount) { const npAnchor = document.getElementById('npanchor'); if (!npAnchor) return; const currentNP = parseNP(npAnchor.textContent); npAnchor.textContent = formatNP(currentNP + withdrawnAmount); } async function withdrawEntireTill() { if (isLoading) return; setLoading(true, 'Checking Till…'); try { // Always fetch a fresh form and balance immediately before submitting. const tillData = await loadTillData(); updateBalance(tillData.balance); if (tillData.balance <= 0) { showToast('Your Shop Till is empty.', 'info'); return; } const amount = tillData.balance; const formData = copyFormData(tillData.form, amount); if (!requestPinIfNeeded(formData, tillData.form)) return; setLoading(true, 'Withdrawing…'); const response = await fetch(WITHDRAW_URL, { method: 'POST', credentials: 'include', headers: { 'x-requested-with': 'XMLHttpRequest' }, body: formData }); if (!response.ok) { throw new Error(`Withdrawal request failed (${response.status}).`); } const responseText = await response.text(); const serverError = parseServerError(responseText); if (serverError) throw new Error(serverError); if (!responseText.includes('success')) { throw new Error('Neopets returned an unexpected response. Your till was not assumed withdrawn.'); } const successParts = responseText.split('success|'); const returnedBalance = successParts[1] !== undefined ? parseNP(successParts[1]) : 0; updateBalance(returnedBalance); updateHeaderNP(amount); showToast(`Withdrew ${formatNP(amount)} NP from till.`, 'success'); } catch (error) { console.error('[Shop Till Helper]', error); showToast(error.message || 'Could not withdraw your Shop Till.', 'error', 6000); // Re-check after a failure in case another tab changed the till. window.setTimeout(() => refreshBalance({ silent: true }), 1000); } finally { setLoading(false); } } function scheduleRefresh() { window.clearInterval(refreshTimer); refreshTimer = window.setInterval(() => { if (!document.hidden && !isLoading) { refreshBalance({ silent: true }); } }, REFRESH_INTERVAL_MS); document.addEventListener('visibilitychange', () => { if (!document.hidden && !isLoading) { refreshBalance({ silent: true }); } }); } function init() { if (!isShopStockPage()) return; addStyles(); if (!createWidget()) return; refreshBalance({ silent: false }); scheduleRefresh(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } })();