Files
timmy-tower/the-matrix/js/payment.js

237 lines
7.4 KiB
JavaScript

/**
* payment.js — Lightning-gated job submission panel for Timmy Tower World.
*
* Flow:
* 1. User clicks "⚡ SUBMIT JOB" → panel slides in
* 2. Enters a request → POST /api/jobs → eval invoice shown
* 3. Pays eval invoice (stub: click "Simulate Payment") → agent orbs animate
* 4. Work invoice appears → pay it
* 5. Result appears in panel + chat feed
*/
import { getOrRefreshToken } from './nostr-identity.js';
import { addHistoryEntry } from './history.js';
const API_BASE = '/api';
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 60000;
let panel = null;
let closeBtn = null;
let currentJobId = null;
let currentRequest = '';
let pollTimer = null;
export function initPaymentPanel() {
panel = document.getElementById('payment-panel');
closeBtn = document.getElementById('payment-close');
if (!panel) return;
closeBtn?.addEventListener('click', closePanel);
document.getElementById('job-submit-btn')?.addEventListener('click', submitJob);
document.getElementById('pay-eval-btn')?.addEventListener('click', () => payInvoice('eval'));
document.getElementById('pay-work-btn')?.addEventListener('click', () => payInvoice('work'));
document.getElementById('new-job-btn')?.addEventListener('click', resetPanel);
document.getElementById('open-panel-btn')?.addEventListener('click', openPanel);
}
export function openPanel() {
if (!panel) return;
panel.classList.add('open');
document.getElementById('job-input')?.focus();
}
function closePanel() {
if (!panel) return;
panel.classList.remove('open');
stopPolling();
}
function resetPanel() {
stopPolling();
currentJobId = null;
setStep('input');
document.getElementById('job-input').value = '';
document.getElementById('job-error').textContent = '';
}
function setStep(step) {
document.querySelectorAll('[data-step]').forEach(el => {
el.style.display = el.dataset.step === step ? '' : 'none';
});
}
function setStatus(msg, color = '#00ff41') {
const el = document.getElementById('job-status');
if (el) { el.textContent = msg; el.style.color = color; }
}
function setError(msg) {
const el = document.getElementById('job-error');
if (el) { el.textContent = msg; el.style.color = '#ff4444'; }
}
async function submitJob() {
const input = document.getElementById('job-input');
const request = input?.value?.trim();
if (!request) { setError('Enter a request first.'); return; }
if (request.length > 500) { setError('Max 500 characters.'); return; }
setError('');
setStatus('Creating job…', '#ffaa00');
document.getElementById('job-submit-btn').disabled = true;
try {
const token = await getOrRefreshToken('/api');
const headers = { 'Content-Type': 'application/json' };
if (token) headers['X-Nostr-Token'] = token;
const res = await fetch(`${API_BASE}/jobs`, {
method: 'POST',
headers,
body: JSON.stringify({ request }),
});
const data = await res.json();
if (!res.ok) { setError(data.error || 'Failed to create job.'); return; }
currentJobId = data.jobId;
currentRequest = request;
showEvalInvoice(data.evalInvoice);
} catch (err) {
setError('Network error: ' + err.message);
} finally {
document.getElementById('job-submit-btn').disabled = false;
}
}
function showEvalInvoice(invoice) {
setStep('eval-invoice');
document.getElementById('eval-amount').textContent = invoice.amountSats + ' sats';
document.getElementById('eval-payment-request').textContent = invoice.paymentRequest || '';
document.getElementById('eval-hash').dataset.hash = invoice.paymentHash || '';
setStatus('⚡ Awaiting eval payment…', '#ffaa00');
}
function showWorkInvoice(invoice) {
setStep('work-invoice');
document.getElementById('work-amount').textContent = invoice.amountSats + ' sats';
document.getElementById('work-payment-request').textContent = invoice.paymentRequest || '';
document.getElementById('work-hash').dataset.hash = invoice.paymentHash || '';
setStatus('⚡ Awaiting work payment…', '#ffaa00');
}
function showResult(result, state) {
stopPolling();
setStep('result');
if (state === 'rejected') {
setStatus('Request rejected by AI judge', '#ff6600');
document.getElementById('job-result').textContent = result || 'Request was rejected.';
document.getElementById('result-label').textContent = 'REJECTION REASON';
} else {
setStatus('✓ Complete', '#00ff88');
document.getElementById('job-result').textContent = result;
document.getElementById('result-label').textContent = 'AI RESULT';
}
}
async function payInvoice(type) {
const hashEl = document.getElementById(type + '-hash');
const hash = hashEl?.dataset.hash;
if (!hash) { setError('No payment hash — using real Lightning?'); return; }
const btn = document.getElementById('pay-' + type + '-btn');
if (btn) btn.disabled = true;
setStatus('Simulating payment…', '#ffaa00');
try {
const res = await fetch(`${API_BASE}/dev/stub/pay/${hash}`, { method: 'POST' });
const data = await res.json();
if (!data.ok) { setError('Payment simulation failed.'); return; }
startPolling();
} catch (err) {
setError('Payment error: ' + err.message);
} finally {
if (btn) btn.disabled = false;
}
}
function startPolling() {
stopPolling();
setStatus('Processing…', '#ffaa00');
const deadline = Date.now() + POLL_TIMEOUT_MS;
async function poll() {
if (!currentJobId) return;
try {
const token = await getOrRefreshToken('/api');
const pollHeaders = token ? { 'X-Nostr-Token': token } : {};
const res = await fetch(`${API_BASE}/jobs/${currentJobId}`, { headers: pollHeaders });
const data = await res.json();
const { state, workInvoice, result, reason, costLedger, completedAt } = data;
if (state === 'awaiting_work_payment' && workInvoice) {
showWorkInvoice(workInvoice);
stopPolling();
return;
}
if (state === 'complete') {
addHistoryEntry({
jobId: currentJobId,
request: currentRequest,
costSats: costLedger?.workAmountSats ?? costLedger?.actualAmountSats ?? 0,
result,
state: 'complete',
completedAt: completedAt ?? new Date().toISOString(),
});
showResult(result, 'complete');
return;
}
if (state === 'rejected') {
addHistoryEntry({
jobId: currentJobId,
request: currentRequest,
costSats: 0,
result: reason,
state: 'rejected',
completedAt: completedAt ?? new Date().toISOString(),
});
showResult(reason, 'rejected');
return;
}
if (state === 'failed') {
setError('Job failed: ' + (data.errorMessage || 'unknown error'));
stopPolling();
return;
}
} catch {
/* network hiccup — keep polling */
}
if (Date.now() > deadline) {
setError('Timed out waiting for response.');
stopPolling();
return;
}
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
}
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
}
function stopPolling() {
clearTimeout(pollTimer);
pollTimer = null;
}
function copyToClipboard(elId) {
const el = document.getElementById(elId);
if (!el) return;
navigator.clipboard.writeText(el.textContent).catch(() => {});
const btn = el.nextElementSibling;
if (btn) { btn.textContent = 'COPIED'; setTimeout(() => { btn.textContent = 'COPY'; }, 1500); }
}
window._timmyCopy = copyToClipboard;