WIP: Claude Code progress on #15
Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation.
This commit is contained in:
@@ -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<string> {
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
148
artifacts/api-server/src/routes/zap.ts
Normal file
148
artifacts/api-server/src/routes/zap.ts
Normal file
@@ -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<void> {
|
||||
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;
|
||||
13
lib/db/migrations/0009_zaps.sql
Normal file
13
lib/db/migrations/0009_zaps.sql
Normal file
@@ -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
|
||||
);
|
||||
@@ -14,3 +14,4 @@ export * from "./relay-accounts";
|
||||
export * from "./relay-event-queue";
|
||||
export * from "./job-debates";
|
||||
export * from "./session-messages";
|
||||
export * from "./zaps";
|
||||
|
||||
14
lib/db/src/schema/zaps.ts
Normal file
14
lib/db/src/schema/zaps.ts
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -541,6 +644,7 @@
|
||||
<div id="top-buttons">
|
||||
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
|
||||
<button id="open-session-btn">⚡ FUND SESSION</button>
|
||||
<button id="open-zap-btn">⚡ ZAP TIMMY</button>
|
||||
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
|
||||
</div>
|
||||
|
||||
|
||||
234
the-matrix/js/zap.js
Normal file
234
the-matrix/js/zap.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user