feat: Lightning invoice generator — zap Timmy from Nexus (#276)
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:
194
app.js
194
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<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();
|
||||
}
|
||||
});
|
||||
|
||||
25
index.html
25
index.html
@@ -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
141
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); }
|
||||
|
||||
Reference in New Issue
Block a user