From d76479917d86a2b800120c3df4d688c444b171d1 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 01:03:13 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Lightning=20invoice=20generator=20?= =?UTF-8?q?=E2=80=94=20zap=20Timmy=20from=20Nexus=20(#276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.js | 194 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 25 +++++++ style.css | 141 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+) diff --git a/app.js b/app.js index 5a405cf..3e9e9be 100644 --- a/app.js +++ b/app.js @@ -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} 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(); + } +}); diff --git a/index.html b/index.html index a5715f5..b5b924c 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,7 @@ } } + @@ -36,6 +37,9 @@ + @@ -66,5 +70,26 @@
+ + + diff --git a/style.css b/style.css index 7912ae2..ee1795c 100644 --- a/style.css +++ b/style.css @@ -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); } -- 2.43.0