/** * 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;