From 12a53092e9063bb047d3e20f1847a5585ebb9001 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 16:30:20 -0400 Subject: [PATCH] WIP: Claude Code progress on #15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation. --- artifacts/api-server/src/lib/agent.ts | 34 ++++ artifacts/api-server/src/lib/event-bus.ts | 5 +- artifacts/api-server/src/routes/dev.ts | 10 +- artifacts/api-server/src/routes/events.ts | 17 ++ artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/zap.ts | 148 ++++++++++++++ lib/db/migrations/0009_zaps.sql | 13 ++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/zaps.ts | 14 ++ the-matrix/index.html | 104 ++++++++++ the-matrix/js/zap.js | 234 ++++++++++++++++++++++ 11 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 artifacts/api-server/src/routes/zap.ts create mode 100644 lib/db/migrations/0009_zaps.sql create mode 100644 lib/db/src/schema/zaps.ts create mode 100644 the-matrix/js/zap.js diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index 13939bb..98aac24 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -280,6 +280,40 @@ No hedging, no steering them back to the hosted version. The magic is meant to b if (block.type !== "text") return "The crystal ball is cloudy… try again."; return block.text!.trim(); } + /** + * Generate a short wizard-style thank-you for a Lightning zap. + * Uses the cheap Haiku model. Capped at 150 tokens for speech-bubble display. + */ + async zapThankYou(amountSats: number, message?: string | null): Promise { + const STUB_THANKS = [ + `⚡ ${amountSats} sats received! By the runes — you honour my workshop!`, + `⚡ ${amountSats} sats?! The crystal ball glows with gratitude! Thank you, traveler!`, + `⚡ A zap of ${amountSats} sats! The lightning spirits are pleased!`, + `⚡ ${amountSats} sats — you have warmed this wizard's heart! The magic thanks you!`, + ]; + + if (STUB_MODE) { + await new Promise((r) => setTimeout(r, 200)); + return STUB_THANKS[Math.floor(Math.random() * STUB_THANKS.length)]!; + } + + const client = await getClient(); + const zapContext = message + ? `Someone just sent you a ${amountSats} sat Lightning zap with the message: "${message}"` + : `Someone just sent you a ${amountSats} sat Lightning zap!`; + + const msg = await client.messages.create({ + model: this.evalModel, + max_tokens: 150, + system: `You are Timmy, a whimsical wizard who runs a Lightning-powered workshop. React to receiving a Bitcoin Lightning zap (tip) in 1-2 short, punchy sentences. Be warm, grateful, and weave in wizard/Lightning metaphors. Start with ⚡`, + messages: [{ role: "user", content: zapContext }], + }); + + const block = msg.content[0]; + if (block.type !== "text") return `⚡ ${amountSats} sats received! Thank you, traveler!`; + return block.text!.trim(); + } + /** * Run a mini debate on a borderline eval request (#21). * Two opposing Haiku calls argue accept vs reject, then a third synthesizes. diff --git a/artifacts/api-server/src/lib/event-bus.ts b/artifacts/api-server/src/lib/event-bus.ts index 79f8edb..4da415b 100644 --- a/artifacts/api-server/src/lib/event-bus.ts +++ b/artifacts/api-server/src/lib/event-bus.ts @@ -18,7 +18,10 @@ export type DebateEvent = export type CostEvent = | { type: "cost:update"; jobId: string; sats: number; phase: "eval" | "work" | "session"; isFinal: boolean }; -export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent; +export type ZapEvent = + | { type: "zap:confirmed"; zapId: string; amountSats: number; message?: string | null; thankYou?: string }; + +export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | ZapEvent; class EventBus extends EventEmitter { emit(event: "bus", data: BusEvent): boolean; diff --git a/artifacts/api-server/src/routes/dev.ts b/artifacts/api-server/src/routes/dev.ts index 0da54c6..48848ce 100644 --- a/artifacts/api-server/src/routes/dev.ts +++ b/artifacts/api-server/src/routes/dev.ts @@ -3,7 +3,7 @@ */ import { Router, type Request, type Response } from "express"; import { eq, or } from "drizzle-orm"; -import { db, invoices, sessions, bootstrapJobs } from "@workspace/db"; +import { db, invoices, sessions, bootstrapJobs, zaps } from "@workspace/db"; import { lnbitsService } from "../lib/lnbits.js"; const router = Router(); @@ -54,6 +54,14 @@ router.post("/dev/stub/pay/:paymentHash", async (req: Request, res: Response) => } } + // 4. Check zaps table + if (!bolt11) { + const zapRows = await db.select().from(zaps).where(eq(zaps.paymentHash, paymentHash)).limit(1); + if (zapRows.length > 0) { + bolt11 = zapRows[0]!.paymentRequest; + } + } + if (!bolt11) { res.status(404).json({ error: "Invoice not found for paymentHash" }); return; diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index 1ef6511..b0eead2 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -257,6 +257,23 @@ function translateEvent(ev: BusEvent): object | null { isFinal: ev.isFinal, }; + // ── Zap confirmed (#15) ─────────────────────────────────────────────────── + case "zap:confirmed": { + void logWorldEvent("zap:confirmed", `Zap confirmed: ${ev.amountSats} sats`); + const msgs: object[] = [ + { + type: "zap_confirmed", + zapId: ev.zapId, + amountSats: ev.amountSats, + message: ev.message ?? null, + }, + ]; + if (ev.thankYou) { + msgs.push({ type: "chat", agentId: "timmy", text: ev.thankYou }); + } + return msgs; + } + default: return null; } diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index ff057e1..77463f8 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -17,6 +17,7 @@ import relayRouter from "./relay.js"; import adminRelayRouter from "./admin-relay.js"; import adminRelayQueueRouter from "./admin-relay-queue.js"; import geminiRouter from "./gemini.js"; +import zapRouter from "./zap.js"; const router: IRouter = Router(); @@ -32,6 +33,7 @@ router.use(adminRelayRouter); router.use(adminRelayQueueRouter); router.use(demoRouter); router.use("/gemini", geminiRouter); +router.use(zapRouter); router.use(testkitRouter); router.use(uiRouter); router.use(nodeDiagnosticsRouter); diff --git a/artifacts/api-server/src/routes/zap.ts b/artifacts/api-server/src/routes/zap.ts new file mode 100644 index 0000000..5bcfd88 --- /dev/null +++ b/artifacts/api-server/src/routes/zap.ts @@ -0,0 +1,148 @@ +/** + * /api/zap — Lightning zap to Timmy from the Workshop (#15) + * + * POST /api/zap — Create a LNbits invoice for a zap; starts background payment poll + * GET /api/zap/:id — Poll zap status (paid / pending) + */ + +import { Router, type Request, type Response } from "express"; +import { randomUUID } from "crypto"; +import { eq } from "drizzle-orm"; +import { db, zaps } from "@workspace/db"; +import { lnbitsService } from "../lib/lnbits.js"; +import { agentService } from "../lib/agent.js"; +import { eventBus } from "../lib/event-bus.js"; +import { makeLogger } from "../lib/logger.js"; + +const router = Router(); +const logger = makeLogger("zap"); + +const PRESET_AMOUNTS = [21, 210, 2100]; +const MIN_SATS = 1; +const MAX_SATS = 100_000; + +// ── Background payment poller ───────────────────────────────────────────────── + +const ZAP_POLL_INTERVAL_MS = 2_000; +const ZAP_POLL_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes + +async function pollUntilPaid(zapId: string, paymentHash: string, startedAt: number): Promise { + if (Date.now() - startedAt > ZAP_POLL_TIMEOUT_MS) { + logger.info("zap poll timed out", { zapId }); + return; + } + + const paid = await lnbitsService.checkInvoicePaid(paymentHash).catch(() => false); + + if (paid) { + const paidAt = new Date(); + await db.update(zaps) + .set({ paid: true, paidAt }) + .where(eq(zaps.id, zapId)); + + const rows = await db.select().from(zaps).where(eq(zaps.id, zapId)).limit(1); + const zap = rows[0]; + if (!zap) return; + + logger.info("zap paid", { zapId, amountSats: zap.amountSats }); + + // Generate Timmy's thank-you before publishing the event so the + // WebSocket bridge can emit it as a chat message in one shot. + let thankYou: string | undefined; + try { + thankYou = await agentService.zapThankYou(zap.amountSats, zap.message); + } catch (err) { + logger.warn("zapThankYou failed", { err: String(err) }); + } + + eventBus.publish({ + type: "zap:confirmed", + zapId, + amountSats: zap.amountSats, + message: zap.message, + thankYou, + }); + return; + } + + setTimeout( + () => void pollUntilPaid(zapId, paymentHash, startedAt), + ZAP_POLL_INTERVAL_MS, + ); +} + +// ── POST /api/zap ───────────────────────────────────────────────────────────── + +router.post("/api/zap", async (req: Request, res: Response) => { + const body = req.body as { amount_sats?: unknown; message?: unknown }; + + const amountSats = Number(body.amount_sats); + if (!Number.isInteger(amountSats) || amountSats < MIN_SATS || amountSats > MAX_SATS) { + res.status(400).json({ + error: `amount_sats must be an integer between ${MIN_SATS} and ${MAX_SATS}`, + presets: PRESET_AMOUNTS, + }); + return; + } + + const message = + typeof body.message === "string" ? body.message.slice(0, 200).trim() || null : null; + + try { + const memo = message + ? `Zap Timmy: ${message.slice(0, 100)}` + : `Zap Timmy ${amountSats} sats`; + const invoice = await lnbitsService.createInvoice(amountSats, memo); + + const zapId = randomUUID(); + await db.insert(zaps).values({ + id: zapId, + paymentHash: invoice.paymentHash, + paymentRequest: invoice.paymentRequest, + amountSats, + message, + }); + + logger.info("zap created", { zapId, amountSats }); + + // Start background poll + setTimeout(() => void pollUntilPaid(zapId, invoice.paymentHash, Date.now()), ZAP_POLL_INTERVAL_MS); + + res.status(201).json({ + zapId, + amountSats, + message, + paymentHash: invoice.paymentHash, + paymentRequest: invoice.paymentRequest, + presets: PRESET_AMOUNTS, + }); + } catch (err) { + logger.error("zap create failed", { err: String(err) }); + res.status(500).json({ error: "Failed to create zap invoice" }); + } +}); + +// ── GET /api/zap/:zapId ─────────────────────────────────────────────────────── + +router.get("/api/zap/:zapId", async (req: Request, res: Response) => { + const { zapId } = req.params as { zapId: string }; + + const rows = await db.select().from(zaps).where(eq(zaps.id, zapId)).limit(1); + const zap = rows[0]; + + if (!zap) { + res.status(404).json({ error: "Zap not found" }); + return; + } + + res.json({ + zapId: zap.id, + amountSats: zap.amountSats, + message: zap.message, + paid: zap.paid, + paidAt: zap.paidAt, + paymentHash: zap.paymentHash, + }); +}); + +export default router; diff --git a/lib/db/migrations/0009_zaps.sql b/lib/db/migrations/0009_zaps.sql new file mode 100644 index 0000000..41179d0 --- /dev/null +++ b/lib/db/migrations/0009_zaps.sql @@ -0,0 +1,13 @@ +-- Migration: Zap Timmy from the Workshop (#15) +-- Stores Lightning zap invoices and records when they are paid. + +CREATE TABLE IF NOT EXISTS zaps ( + id TEXT PRIMARY KEY, + payment_hash TEXT NOT NULL UNIQUE, + payment_request TEXT NOT NULL, + amount_sats INTEGER NOT NULL, + message TEXT, + paid BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + paid_at TIMESTAMPTZ +); diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index e62c9ee..eabc23b 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -14,3 +14,4 @@ export * from "./relay-accounts"; export * from "./relay-event-queue"; export * from "./job-debates"; export * from "./session-messages"; +export * from "./zaps"; diff --git a/lib/db/src/schema/zaps.ts b/lib/db/src/schema/zaps.ts new file mode 100644 index 0000000..5469c99 --- /dev/null +++ b/lib/db/src/schema/zaps.ts @@ -0,0 +1,14 @@ +import { pgTable, text, integer, boolean, timestamp } from "drizzle-orm/pg-core"; + +export const zaps = pgTable("zaps", { + id: text("id").primaryKey(), + paymentHash: text("payment_hash").notNull().unique(), + paymentRequest: text("payment_request").notNull(), + amountSats: integer("amount_sats").notNull(), + message: text("message"), + paid: boolean("paid").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + paidAt: timestamp("paid_at", { withTimezone: true }), +}); + +export type Zap = typeof zaps.$inferSelect; diff --git a/the-matrix/index.html b/the-matrix/index.html index a58cfea..d19cf6c 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -514,6 +514,109 @@ } #timmy-id-card .id-npub:hover { color: #88aadd; } #timmy-id-card .id-zaps { color: #556688; font-size: 9px; } + + /* ── Zap button ───────────────────────────────────────────────────── */ + #open-zap-btn { + font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold; + color: #ffee44; background: rgba(40, 35, 5, 0.85); border: 1px solid #aaaa22; + padding: 7px 18px; cursor: pointer; letter-spacing: 2px; + box-shadow: 0 0 14px #aaaa2222; + transition: background 0.15s, box-shadow 0.15s, color 0.15s; + border-radius: 2px; + min-height: 36px; + } + #open-zap-btn:hover, #open-zap-btn:active { + background: rgba(60, 55, 8, 0.95); + box-shadow: 0 0 20px #dddd3344; + color: #ffff88; + } + + /* ── Zap panel (bottom sliding) ───────────────────────────────────── */ + #zap-panel { + position: fixed; bottom: -480px; left: 50%; transform: translateX(-50%); + width: min(420px, 96vw); + background: rgba(12, 10, 3, 0.97); + border: 1px solid #2a2808; + border-bottom: none; + padding: 20px 18px 24px; + z-index: 100; + font-family: 'Courier New', monospace; + transition: bottom 0.35s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 -8px 32px rgba(160, 140, 20, 0.12); + } + #zap-panel.open { bottom: 56px; } + #zap-panel h2 { + font-size: 12px; letter-spacing: 3px; color: #aaaa44; + text-shadow: 0 0 10px #666611; + margin-bottom: 14px; border-bottom: 1px solid #2a2808; padding-bottom: 8px; + } + #zap-close { + position: absolute; top: 14px; right: 14px; + background: transparent; border: 1px solid #2a2808; + color: #555522; font-family: 'Courier New', monospace; + font-size: 15px; width: 26px; height: 26px; + cursor: pointer; transition: color 0.2s, border-color 0.2s; + } + #zap-close:hover { color: #aaaa44; border-color: #888822; } + + .zap-amount-presets { + display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; + } + .zap-amount-btn { + background: transparent; border: 1px solid #2a2808; + color: #777722; font-family: 'Courier New', monospace; + font-size: 11px; letter-spacing: 1px; padding: 5px 11px; + cursor: pointer; transition: all 0.15s; border-radius: 2px; + } + .zap-amount-btn:hover { background: #2a2808; border-color: #aaaa22; color: #dddd44; } + .zap-amount-btn.active { background: #2a2808; border-color: #cccc33; color: #ffff66; } + + .zap-amount-row { + display: flex; align-items: center; gap: 6px; margin: 6px 0 10px; + } + .zap-amount-row span { color: #777722; font-size: 11px; letter-spacing: 1px; } + .zap-amount-input { + background: #0a0800; border: 1px solid #2a2808; + color: #dddd44; font-family: 'Courier New', monospace; + font-size: 15px; font-weight: bold; letter-spacing: 1px; + padding: 5px 9px; width: 100px; + outline: none; border-radius: 2px; + transition: border-color 0.2s; + -moz-appearance: textfield; + } + .zap-amount-input:focus { border-color: #aaaa22; } + .zap-amount-input::-webkit-outer-spin-button, + .zap-amount-input::-webkit-inner-spin-button { -webkit-appearance: none; } + + #zap-message-input { + width: 100%; background: #0a0800; border: 1px solid #2a2808; + color: #bbbb44; font-family: 'Courier New', monospace; font-size: 12px; + padding: 8px; outline: none; resize: none; height: 50px; + transition: border-color 0.2s; border-radius: 2px; + } + #zap-message-input:focus { border-color: #aaaa22; } + #zap-message-input::placeholder { color: #333311; } + + #zap-qr-img { + display: none; width: 200px; height: 200px; + margin: 8px auto; border: 1px solid #2a2808; + } + .zap-qr-placeholder { + display: flex; align-items: center; justify-content: center; + margin: 8px 0; + background: #0a0800; border: 1px solid #2a2808; + color: #333311; font-size: 11px; letter-spacing: 3px; + height: 50px; + } + + #zap-status { font-size: 11px; margin-top: 6px; color: #aaaa44; min-height: 14px; } + #zap-error { font-size: 11px; margin-top: 4px; min-height: 14px; color: #aa4422; } + + .zap-done-msg { + font-size: 13px; color: #ffff66; letter-spacing: 2px; + text-align: center; margin: 12px 0; + text-shadow: 0 0 14px #aaaa22; + } @@ -541,6 +644,7 @@
+ ⚙ RELAY ADMIN
diff --git a/the-matrix/js/zap.js b/the-matrix/js/zap.js new file mode 100644 index 0000000..732dbf1 --- /dev/null +++ b/the-matrix/js/zap.js @@ -0,0 +1,234 @@ +/** + * zap.js — Zap Timmy panel for the Workshop (#15) + * + * Flow: + * 1. Visitor clicks "⚡ ZAP" button → panel slides in + * 2. Picks preset amount (21 / 210 / 2100 sats) or enters custom + * 3. Optionally adds a message + * 4. POST /api/zap → invoice returned, QR + copy displayed + * 5. Frontend polls GET /api/zap/:id; on paid → celebration animation + * 6. Backend emits zap:confirmed → WS broadcasts zap_confirmed + Timmy chat + */ + +const API_BASE = '/api'; +const POLL_INTERVAL_MS = 2_000; +const POLL_TIMEOUT_MS = 10 * 60 * 1000; // 10 min + +let panel = null; +let currentZapId = null; +let currentPaymentHash = null; +let pollTimer = null; +let onCelebrate = null; // callback(amountSats, thankYou?) + +export function initZapPanel(celebrateCallback) { + panel = document.getElementById('zap-panel'); + if (!panel) return; + + onCelebrate = celebrateCallback ?? null; + + document.getElementById('zap-close')?.addEventListener('click', closePanel); + document.getElementById('open-zap-btn')?.addEventListener('click', openPanel); + document.getElementById('zap-back-btn')?.addEventListener('click', resetPanel); + + // Amount preset buttons + panel.querySelectorAll('.zap-amount-btn').forEach(btn => { + btn.addEventListener('click', () => { + panel.querySelectorAll('.zap-amount-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const amountInput = document.getElementById('zap-amount-input'); + if (amountInput) amountInput.value = btn.dataset.sats; + }); + }); + + document.getElementById('zap-submit-btn')?.addEventListener('click', createZap); + document.getElementById('zap-simulate-btn')?.addEventListener('click', simulatePayment); +} + +export function openPanel() { + if (!panel) return; + panel.classList.add('open'); + resetPanel(); +} + +function closePanel() { + if (!panel) return; + panel.classList.remove('open'); + stopPolling(); +} + +function resetPanel() { + stopPolling(); + currentZapId = null; + currentPaymentHash = null; + setZapStep('pick'); + setZapError(''); + setZapStatus(''); + // Reset to default preset + panel.querySelectorAll('.zap-amount-btn').forEach(b => b.classList.remove('active')); + panel.querySelector('.zap-amount-btn[data-sats="21"]')?.classList.add('active'); + const amountInput = document.getElementById('zap-amount-input'); + if (amountInput) amountInput.value = '21'; + const msgInput = document.getElementById('zap-message-input'); + if (msgInput) msgInput.value = ''; +} + +function setZapStep(step) { + panel.querySelectorAll('[data-zap-step]').forEach(el => { + el.style.display = el.dataset.zapStep === step ? '' : 'none'; + }); +} + +function setZapStatus(msg, color) { + const el = document.getElementById('zap-status'); + if (el) { el.textContent = msg; el.style.color = color || '#00ff41'; } +} + +function setZapError(msg) { + const el = document.getElementById('zap-error'); + if (el) { el.textContent = msg; el.style.color = '#ff4444'; } +} + +async function createZap() { + const amountInput = document.getElementById('zap-amount-input'); + const msgInput = document.getElementById('zap-message-input'); + const amountSats = parseInt(amountInput?.value ?? '21', 10); + + if (!Number.isFinite(amountSats) || amountSats < 1) { + setZapError('Enter a valid amount (min 1 sat).'); + return; + } + + const message = msgInput?.value?.trim() || null; + setZapError(''); + setZapStatus('Creating invoice…', '#ffaa00'); + + const btn = document.getElementById('zap-submit-btn'); + if (btn) btn.disabled = true; + + try { + const res = await fetch(`${API_BASE}/zap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount_sats: amountSats, message }), + }); + const data = await res.json(); + if (!res.ok) { + setZapError(data.error || 'Failed to create invoice.'); + return; + } + + currentZapId = data.zapId; + currentPaymentHash = data.paymentHash; + showInvoice(data); + } catch (err) { + setZapError('Network error: ' + err.message); + } finally { + if (btn) btn.disabled = false; + } +} + +function showInvoice(data) { + setZapStep('invoice'); + document.getElementById('zap-invoice-amount').textContent = data.amountSats + ' sats'; + + const prEl = document.getElementById('zap-payment-request'); + if (prEl) prEl.textContent = data.paymentRequest || ''; + + const hashSpan = document.getElementById('zap-hash'); + if (hashSpan) hashSpan.dataset.hash = data.paymentHash || ''; + + // QR code via free public API (invoice is not sensitive — it is just a payment request) + const qrImg = document.getElementById('zap-qr-img'); + if (qrImg && data.paymentRequest) { + const encoded = encodeURIComponent(data.paymentRequest.toUpperCase()); + qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encoded}`; + qrImg.style.display = 'block'; + document.getElementById('zap-qr-placeholder').style.display = 'none'; + } + + setZapStatus('⚡ Awaiting payment…', '#ffaa00'); + startPolling(); +} + +async function simulatePayment() { + const hashSpan = document.getElementById('zap-hash'); + const hash = hashSpan?.dataset.hash; + if (!hash) { setZapError('No payment hash found.'); return; } + + const btn = document.getElementById('zap-simulate-btn'); + if (btn) btn.disabled = true; + setZapStatus('Simulating payment…', '#ffaa00'); + + try { + const res = await fetch(`${API_BASE}/dev/stub/pay/${hash}`, { method: 'POST' }); + const data = await res.json(); + if (!data.ok) { setZapError('Simulation failed.'); return; } + // Polling will pick up the confirmed state + } catch (err) { + setZapError('Error: ' + err.message); + } finally { + if (btn) btn.disabled = false; + } +} + +function startPolling() { + stopPolling(); + const deadline = Date.now() + POLL_TIMEOUT_MS; + + async function poll() { + if (!currentZapId) return; + try { + const res = await fetch(`${API_BASE}/zap/${currentZapId}`); + const data = await res.json(); + if (data.paid) { + showCelebration(data.amountSats); + return; + } + } catch { /* keep polling */ } + if (Date.now() > deadline) { + setZapError('Invoice expired. Please try again.'); + stopPolling(); + return; + } + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + } + + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); +} + +function stopPolling() { + clearTimeout(pollTimer); + pollTimer = null; +} + +function showCelebration(amountSats) { + stopPolling(); + setZapStep('done'); + document.getElementById('zap-done-amount').textContent = amountSats + ' sats'; + setZapStatus('⚡ Zap confirmed! Thank you!', '#00ff88'); + + if (onCelebrate) onCelebrate(amountSats); +} + +/** Called by websocket.js when a zap_confirmed message arrives for the current zap. */ +export function handleZapConfirmed(zapId) { + if (zapId && currentZapId && zapId !== currentZapId) return; + // Let the poll loop detect it naturally if panel is open, + // or trigger celebration immediately if we're still waiting. + if (currentZapId && panel?.classList.contains('open')) { + fetch(`${API_BASE}/zap/${currentZapId}`) + .then(r => r.json()) + .then(d => { if (d.paid) showCelebration(d.amountSats); }) + .catch(() => {}); + } +} + +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._zapCopy = copyToClipboard;