feat: Lightning invoice generator — zap Timmy from Nexus (#276)
Some checks failed
CI / validate (pull_request) Failing after 13s
CI / auto-merge (pull_request) Has been skipped

Adds a  ZAP button to the HUD that opens a Lightning payment modal.
Visitors enter an amount in sats, click Generate, and get a BOLT11
invoice + QR code via LNURL-pay from Timmy's Lightning address. Includes
a 3D electric-yellow flash effect on invoice generation.

- index.html: QRCode library via unpkg, zap HUD button, modal markup
- style.css: modal overlay, zap button, invoice/QR display styles
- app.js: fetchLnurlInvoice (LNURL-pay flow), renderQR, triggerZapFlash,
  modal open/close/generate/copy handlers

Fixes #276

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 01:03:13 -04:00
parent db8e9802bc
commit d76479917d
3 changed files with 360 additions and 0 deletions

194
app.js
View File

@@ -2009,3 +2009,197 @@ function showTimmySpeech(text) {
timmySpeechSprite = sprite;
timmySpeechState = { startTime: clock.getElapsedTime(), sprite };
}
// === LIGHTNING ZAP ===
const TIMMY_LIGHTNING_ADDRESS = 'timmy@timmy.foundation';
/**
* Resolves a Lightning address (user@domain) to a BOLT11 invoice via LNURL-pay.
* @param {string} address
* @param {number} sats
* @returns {Promise<string>} BOLT11 invoice string
*/
async function fetchLnurlInvoice(address, sats) {
const [user, domain] = address.split('@');
if (!user || !domain) throw new Error('Invalid Lightning address');
const metaUrl = `https://${domain}/.well-known/lnurlp/${user}`;
const metaRes = await fetch(metaUrl);
if (!metaRes.ok) throw new Error(`LNURL fetch failed (${metaRes.status})`);
const meta = await metaRes.json();
if (meta.status === 'ERROR') throw new Error(meta.reason || 'LNURL error');
if (!meta.callback) throw new Error('No callback in LNURL metadata');
const msats = sats * 1000;
if (meta.minSendable && msats < meta.minSendable)
throw new Error(`Minimum: ${Math.ceil(meta.minSendable / 1000)} sats`);
if (meta.maxSendable && msats > meta.maxSendable)
throw new Error(`Maximum: ${Math.floor(meta.maxSendable / 1000)} sats`);
const callbackUrl = new URL(meta.callback);
callbackUrl.searchParams.set('amount', String(msats));
const invoiceRes = await fetch(callbackUrl.toString());
if (!invoiceRes.ok) throw new Error(`Invoice fetch failed (${invoiceRes.status})`);
const invoiceData = await invoiceRes.json();
if (invoiceData.status === 'ERROR') throw new Error(invoiceData.reason || 'Invoice error');
if (!invoiceData.pr) throw new Error('No invoice in response');
return invoiceData.pr;
}
/**
* Renders a string as a QR code inside the given container div.
* Clears any previous QR code first.
* @param {HTMLElement} container
* @param {string} text
*/
function renderQR(container, text) {
container.innerHTML = '';
if (typeof window.QRCode === 'undefined') throw new Error('QR library not loaded');
new window.QRCode(container, {
text: text.toUpperCase(),
width: 220,
height: 220,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: window.QRCode.CorrectLevel.M,
});
}
/**
* Lightning flash: stars and constellation lines burst electric yellow,
* then fade back over ~1.2 seconds.
*/
function triggerZapFlash() {
const origLineColor = constellationLines.material.color.getHex();
constellationLines.material.color.setHex(0xffee00);
constellationLines.material.opacity = 1.0;
const origStarColor = starMaterial.color.getHex();
const origStarOpacity = starMaterial.opacity;
starMaterial.color.setHex(0xffee00);
starMaterial.opacity = 1.0;
const origVoidIntensity = voidLight.intensity;
voidLight.intensity = 3.5;
const startTime = performance.now();
const DURATION = 1200;
function fadeBack() {
const t = Math.min((performance.now() - startTime) / DURATION, 1);
const eased = t * t;
const zapR = 1.0, zapG = 0.933, zapB = 0.0;
const origStar = new THREE.Color(origStarColor);
starMaterial.color.setRGB(
zapR + (origStar.r - zapR) * eased,
zapG + (origStar.g - zapG) * eased,
zapB + (origStar.b - zapB) * eased
);
starMaterial.opacity = 1.0 + (origStarOpacity - 1.0) * eased;
const origLine = new THREE.Color(origLineColor);
constellationLines.material.color.setRGB(
zapR + (origLine.r - zapR) * eased,
zapG + (origLine.g - zapG) * eased,
zapB + (origLine.b - zapB) * eased
);
constellationLines.material.opacity = 1.0 + (0.18 - 1.0) * eased;
voidLight.intensity = 3.5 + (origVoidIntensity - 3.5) * eased;
if (t < 1) {
requestAnimationFrame(fadeBack);
} else {
starMaterial.color.setHex(origStarColor);
starMaterial.opacity = origStarOpacity;
constellationLines.material.color.setHex(origLineColor);
constellationLines.material.opacity = 0.18;
voidLight.intensity = origVoidIntensity;
}
}
requestAnimationFrame(fadeBack);
}
// === ZAP MODAL WIRING ===
const zapModal = document.getElementById('zap-modal');
const zapCloseBtn = document.getElementById('zap-close');
const zapAmountEl = document.getElementById('zap-amount');
const zapGenerateBtn = document.getElementById('zap-generate');
const zapStatus = document.getElementById('zap-status');
const zapQrDiv = document.getElementById('zap-qr');
const zapInvoiceWrap = document.getElementById('zap-invoice-wrap');
const zapInvoiceText = document.getElementById('zap-invoice-text');
const zapCopyBtn = document.getElementById('zap-copy');
const zapHudBtn = document.getElementById('zap-btn');
function openZapModal() {
zapStatus.textContent = '';
zapStatus.className = '';
zapQrDiv.style.display = 'none';
zapQrDiv.innerHTML = '';
zapInvoiceWrap.style.display = 'none';
zapInvoiceText.value = '';
zapGenerateBtn.disabled = false;
zapModal.classList.add('visible');
}
function closeZapModal() {
zapModal.classList.remove('visible');
}
zapHudBtn.addEventListener('click', openZapModal);
zapCloseBtn.addEventListener('click', closeZapModal);
zapModal.addEventListener('click', (e) => { if (e.target === zapModal) closeZapModal(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && zapModal.classList.contains('visible')) closeZapModal();
});
zapGenerateBtn.addEventListener('click', async () => {
const sats = parseInt(zapAmountEl.value, 10);
if (!sats || sats < 1) {
zapStatus.textContent = 'Enter a valid amount.';
zapStatus.className = 'error';
return;
}
zapGenerateBtn.disabled = true;
zapStatus.className = '';
zapStatus.textContent = 'Contacting Lightning node\u2026';
zapQrDiv.style.display = 'none';
zapQrDiv.innerHTML = '';
zapInvoiceWrap.style.display = 'none';
try {
const invoice = await fetchLnurlInvoice(TIMMY_LIGHTNING_ADDRESS, sats);
zapQrDiv.style.display = 'block';
renderQR(zapQrDiv, invoice);
zapInvoiceText.value = invoice;
zapInvoiceWrap.style.display = 'flex';
zapStatus.textContent = `${sats} sats \u2014 scan or copy below`;
triggerZapFlash();
} catch (err) {
zapStatus.textContent = `Error: ${err.message}`;
zapStatus.className = 'error';
zapGenerateBtn.disabled = false;
}
});
zapCopyBtn.addEventListener('click', async () => {
const text = zapInvoiceText.value;
if (!text) return;
try {
await navigator.clipboard.writeText(text);
const orig = zapCopyBtn.textContent;
zapCopyBtn.textContent = 'COPIED!';
setTimeout(() => { zapCopyBtn.textContent = orig; }, 1500);
} catch {
zapInvoiceText.select();
}
});

View File

@@ -23,6 +23,7 @@
}
}
</script>
<script src="https://unpkg.com/qrcode@1.5.3/build/qrcode.min.js"></script>
</head>
<body>
<!-- Top Right: Audio Toggle -->
@@ -36,6 +37,9 @@
<button id="export-session" class="chat-toggle-btn" aria-label="Export session as markdown" title="Export session log as Markdown">
📥
</button>
<button id="zap-btn" class="chat-toggle-btn" aria-label="Zap Timmy — send a Lightning tip" title="Zap Timmy ⚡">
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
@@ -66,5 +70,26 @@
<div id="loading-bar" style="height: 100%; background: var(--color-accent); width: 0;"></div>
</div>
<div class="crt-overlay"></div>
<!-- Lightning Zap Modal -->
<div id="zap-modal" role="dialog" aria-modal="true" aria-label="Zap Timmy">
<div id="zap-modal-inner">
<div id="zap-modal-header">
<span>⚡ ZAP TIMMY</span>
<button id="zap-close" aria-label="Close"></button>
</div>
<div id="zap-modal-body">
<label for="zap-amount">AMOUNT (SATS)</label>
<input id="zap-amount" type="number" min="1" value="21" autocomplete="off">
<button id="zap-generate">GENERATE INVOICE</button>
<div id="zap-status"></div>
<div id="zap-qr"></div>
<div id="zap-invoice-wrap">
<textarea id="zap-invoice-text" readonly></textarea>
<button id="zap-copy">COPY</button>
</div>
</div>
</div>
</div>
</body>
</html>

141
style.css
View File

@@ -274,3 +274,144 @@ body.photo-mode #overview-indicator {
50% { opacity: 0.15; }
100% { opacity: 0.05; }
}
/* === ZAP MODAL === */
#zap-btn {
margin-left: 8px;
background-color: var(--color-secondary);
color: var(--color-text);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
font-family: var(--font-body);
transition: background-color 0.2s ease;
}
#zap-btn:hover {
background-color: #ffcc00;
color: var(--color-bg);
}
#zap-modal {
display: none;
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 8, 0.82);
align-items: center;
justify-content: center;
}
#zap-modal.visible {
display: flex;
}
#zap-modal-inner {
background: #000814;
border: 1px solid var(--color-primary);
border-radius: 6px;
width: min(92vw, 420px);
font-family: var(--font-body);
color: var(--color-text);
overflow: hidden;
}
#zap-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--color-secondary);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--color-primary);
}
#zap-close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 16px;
cursor: pointer;
line-height: 1;
}
#zap-close:hover { color: var(--color-text); }
#zap-modal-body {
padding: 16px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
#zap-modal-body label {
font-size: 10px;
letter-spacing: 0.18em;
color: var(--color-text-muted);
}
#zap-amount {
background: #000020;
border: 1px solid var(--color-secondary);
border-radius: 3px;
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
padding: 6px 8px;
width: 100%;
}
#zap-amount:focus { outline: 1px solid var(--color-primary); }
#zap-generate {
background: var(--color-primary);
color: var(--color-bg);
border: none;
border-radius: 4px;
padding: 8px;
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.15em;
cursor: pointer;
transition: background-color 0.2s;
}
#zap-generate:hover { background: #66aaff; }
#zap-generate:disabled { background: var(--color-text-muted); cursor: wait; }
#zap-status {
font-size: 11px;
color: var(--color-text-muted);
min-height: 14px;
letter-spacing: 0.1em;
}
#zap-status.error { color: #ff6666; }
#zap-qr {
display: none;
margin: 0 auto;
background: #fff;
border-radius: 4px;
width: 220px;
height: 220px;
}
#zap-invoice-wrap {
display: none;
flex-direction: column;
gap: 6px;
}
#zap-invoice-text {
background: #000020;
border: 1px solid var(--color-secondary);
border-radius: 3px;
color: var(--color-primary);
font-family: var(--font-body);
font-size: 9px;
padding: 6px;
resize: none;
height: 60px;
word-break: break-all;
width: 100%;
}
#zap-copy {
align-self: flex-end;
background: var(--color-secondary);
border: none;
border-radius: 3px;
color: var(--color-text);
font-family: var(--font-body);
font-size: 11px;
padding: 5px 10px;
cursor: pointer;
letter-spacing: 0.1em;
}
#zap-copy:hover { background: var(--color-primary); color: var(--color-bg); }