This commit is contained in:
thePR0M3TH3AN
2025-06-29 19:40:47 -04:00
parent 159df3fe29
commit 7ec14061eb

View File

@@ -4,36 +4,36 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightning Tip Jar</title> <title>Lightning Tip Jar</title>
<!-- TailwindCSS (Play CDN) -->
<!-- TailwindCSS (Play CDN for demo) -->
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<!-- QRCode generator --> <!-- QRCode generator -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-Rdym6z1nIh/x6O+4QJBk/w++Q5e7oapbPo8EjS2X3tq6MUJBbiuKxVhQ5BaRPHUl2j3Ky4gGB4vJfIQDw4RGeg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style> <style>
:root { :root { --accent-purple: #a855f7; --accent-orange: #f97316; }
--accent-purple: #a855f7; body {
--accent-orange: #f97316; display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
} }
.accent-gradient { .accent-gradient {
background: linear-gradient(90deg, var(--accent-purple), var(--accent-orange)); background: linear-gradient(90deg, var(--accent-purple), var(--accent-orange));
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
} }
.btn-primary {
@apply w-full py-2 rounded-lg bg-gradient-to-r from-purple-600 to-orange-500 text-white font-semibold transition active:scale-95;
}
</style> </style>
</head> </head>
<body class="min-h-screen flex items-center justify-center bg-gray-900 text-gray-200"> <body class="bg-gray-900 text-gray-200">
<main id="app" class="w-full max-w-sm p-6 space-y-6 text-center"></main> <main id="app" class="w-full max-w-sm p-6 space-y-6 text-center"></main>
<script type="module"> <script type="module">
/******************** Imports ********************/ import { nip19, SimplePool } from "https://esm.sh/nostr-tools@1.7.5?bundle";
import { nip19 } from "https://cdn.jsdelivr.net/npm/nostr-tools@2.15.0/+esm";
import { SimplePool } from "https://cdn.jsdelivr.net/npm/nostr-tools@2.15.0/pool/+esm";
/******************** Config ********************/
const RELAYS = [ const RELAYS = [
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://nos.lol", "wss://nos.lol",
@@ -41,118 +41,125 @@
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://relay.nostr.band", "wss://relay.nostr.band",
]; ];
const TIMEOUT_MS = 7000; const TIMEOUT_MS = 8000;
/******************** Logger ********************/ const log = (...a) => console.log("[tipjar]", ...a);
const log = (...a) => console.log("[tipjar]", ...a);
const warn = (...a) => console.warn("[tipjar]", ...a); const warn = (...a) => console.warn("[tipjar]", ...a);
const err = (...a) => console.error("[tipjar]", ...a); const error = (...a) => console.error("[tipjar]", ...a);
window.addEventListener("error", e => err("Uncaught:", e.message, e.error||e)); window.addEventListener("error", e => error("Uncaught:", e.message));
window.addEventListener("unhandledrejection", e => err("Promise rejection:", e.reason)); window.addEventListener("unhandledrejection", e => error("Promise rejection:", e.reason));
/******************** Entry ********************/
init(); init();
function init() { function init() {
const { npub, basePath } = parseURL(); const { npub, base } = parseUrl();
if (!npub) return showLanding(basePath); if (!npub) return renderLanding(base);
if (!npub.startsWith("npub")) return showLanding(basePath, "Please enter a valid npub."); if (!npub.startsWith("npub")) return renderLanding(base, "Invalid npub provided.");
loadTipJar(npub); renderTipJar(npub);
} }
/******** URL helpers ********/ function parseUrl() {
function parseURL() { const parts = location.pathname.split("/").filter(Boolean);
const pathParts = location.pathname.split("/").filter(Boolean);
let npub = null; let npub = null;
if (pathParts.length >= 2 && pathParts[0] === "n") npub = pathParts[1]; if (parts[0] === "n" && parts[1]) npub = parts[1];
else npub = new URLSearchParams(location.search).get("n"); else npub = new URLSearchParams(location.search).get("n");
const idx = location.pathname.indexOf("/n/"); const idx = location.pathname.indexOf("/n/");
const base = idx >= 0 ? location.pathname.slice(0, idx) : location.pathname; const base = idx > 0 ? location.pathname.slice(0, idx) : location.pathname;
return { npub, basePath: base }; return { npub, base };
} }
/******** LANDING PAGE ********/ function renderLanding(base, msg = '') {
function showLanding(basePath, msg = "") { document.getElementById('app').innerHTML = `
document.getElementById("app").innerHTML = `
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-3xl font-extrabold accent-gradient">Lightning Tip Jar</h1> <h1 class="text-3xl font-extrabold accent-gradient">Lightning Tip Jar</h1>
<p class="text-gray-400">Generate a oneclick tipping page from any Nostr <span class="font-mono">npub</span>.</p> <p class="text-gray-400">Enter a Nostr <span class="font-mono">npub</span> to create a tipping page.</p>
${msg ? `<p class="text-red-500">${msg}</p>` : ""} ${msg ? `<p class='text-red-500'>${msg}</p>` : ''}
<form id="lookup" class="space-y-4"> <form id="form" class="space-y-4">
<input id="npubIn" type="text" placeholder="npub1…" required class="w-full px-4 py-2 rounded-lg bg-gray-800 text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-600" /> <input id="input" type="text" placeholder="npub1…" required class="w-full px-4 py-2 rounded-lg bg-gray-800 text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-600" />
<button type="submit" class="btn-primary">Create Tip Page</button> <button class="w-full py-2 rounded-lg bg-gradient-to-r from-purple-600 to-orange-500 text-white font-semibold transition active:scale-95" type="submit">Generate</button>
</form> </form>
<footer class="text-xs text-gray-500 pt-4">Works entirely clientside • No servers • 100% Lightning ⚡</footer> <footer class="text-xs text-gray-500">Client-side • No servers • Lightning ⚡</footer>
</div>`; </div>`;
document.getElementById('form').onsubmit = e => {
document.getElementById("lookup").onsubmit = (e) => {
e.preventDefault(); e.preventDefault();
const val = document.getElementById("npubIn").value.trim(); const v = document.getElementById('input').value.trim();
if (!val.startsWith("npub")) return showLanding(basePath, "npub should start with npub. Try again."); if (!v.startsWith('npub')) return renderLanding(base, 'npub must start with "npub".');
const isFile = /\.html?$/.test(basePath); const isFile = /\.html?$/.test(base);
const dest = isFile ? `${basePath}?n=${val}` : `${basePath.replace(/\/$/, "")}/n/${val}`; location.href = isFile ? `${base}?n=${v}` : `${base.replace(/\/$/, '')}/n/${v}`;
location.href = dest;
}; };
} }
/******** TIPJAR FLOW ********/ async function renderTipJar(npub) {
async function loadTipJar(npub) { const app = document.getElementById('app');
const app = document.getElementById("app");
app.innerHTML = ` app.innerHTML = `
<img id="avatar" class="w-24 h-24 rounded-full mx-auto hidden" alt="Avatar" /> <img id="avatar" class="w-24 h-24 rounded-full mx-auto hidden" alt="Avatar" />
<h1 id="name" class="text-2xl font-extrabold accent-gradient mb-2">Loading…</h1> <h1 id="name" class="text-2xl font-extrabold accent-gradient mb-2">Loading…</h1>
<p id="about" class="text-sm text-gray-400"></p> <p id="about" class="text-sm text-gray-400"></p>
<div id="qrcode" class="mx-auto"></div> <div id="qrcode" class="mx-auto bg-white p-4 rounded-xl inline-block"></div>
<p id="address" class="break-all font-mono text-lg"></p> <p id="addr" class="break-all font-mono text-lg"></p>
<button id="copy" class="btn-primary">Copy Address</button>`; <button id="copy" class="w-full py-2 rounded-lg bg-gradient-to-r from-purple-600 to-orange-500 text-white font-semibold transition active:scale-95">Copy Address</button>`;
// decode npub
let hex; let hex;
try { hex = nip19.decode(npub).data; log("Hex pubkey", hex); } try {
catch { return fail("Bad npub format"); } const dec = nip19.decode(npub);
hex = dec.data;
log('Decoded hex pubkey', hex);
} catch {
return fatal(app, 'Bad npub format');
}
const fallback = `${npub}@npub.cash`; const fallback = `${npub}@npub.cash`;
let meta = null, addr = fallback; let meta = null, ln = fallback;
const pool = new SimplePool(); const pool = new SimplePool();
const sub = pool.sub(RELAYS, [{ kinds:[0], authors:[hex], limit:1 }]); const sub = pool.sub(RELAYS, [{ kinds: [0], authors: [hex], limit: 1 }]);
let finished = false; const timer = setTimeout(() => { warn('Relay timeout'); finalize(); }, TIMEOUT_MS);
const timer = setTimeout(() => { warn("Timeout: no profile"); wrapUp(); }, TIMEOUT_MS);
sub.on("event", (ev, relay) => { sub.on('event', ev => {
log("Profile", ev.id, "from", relay); clearTimeout(timer);
clearTimeout(timer); finished = true;
try { try {
meta = JSON.parse(ev.content || "{}"); meta = JSON.parse(ev.content || '{}');
addr = meta.lud16 || meta.lud06 || fallback; ln = meta.lud16 || meta.lud06 || fallback;
} catch(e) { err("Meta parse", e); } log('Profile metadata', meta);
wrapUp(); } catch (e) {
error('Failed to parse metadata JSON', e);
}
finalize();
}); });
sub.on("notice", (n) => warn("Relay notice", n)); sub.on('eose', () => warn('EOSE reached, no event'));
sub.on("eose", () => { if (!finished) warn("EOSE no profile"); });
function wrapUp() { function finalize() {
try { sub.unsub(); pool.close(RELAYS); } catch {} try { sub.unsub(); pool.close(); } catch {};
render(meta, addr); buildUI(app, meta, ln);
}
function fatal(container, msg) {
container.innerHTML = `<p class='text-red-500'>${msg}</p>`;
} }
function fail(msg){ app.innerHTML = `<p class='text-red-500'>${msg}</p>`; }
} }
/******** RENDER TIP CARD ********/ function buildUI(app, meta, ln) {
function render(meta, ln) { document.getElementById('name').textContent = meta?.name || 'Lightning Tip Jar';
document.getElementById("name").textContent = meta?.name || "Lightning Tip Jar"; if (meta?.about) document.getElementById('about').textContent = meta.about;
document.getElementById("about").textContent = meta?.about || ""; if (meta?.picture) {
if (meta?.picture){ const img = document.getElementById("avatar"); img.src = meta.picture; img.classList.remove("hidden"); } const img = document.getElementById('avatar');
if (!meta?.lud16 && !meta?.lud06) document.getElementById("about").textContent += "\n(No lightning address on profile; using fallback)"; img.src = meta.picture;
img.classList.remove('hidden');
document.getElementById("address").textContent = ln; }
const qr = document.getElementById("qrcode"); qr.innerHTML = ""; if (!meta?.lud16 && !meta?.lud06) {
new QRCode(qr, { text: ln, width: 200, height: 200, colorDark: "#fff", colorLight: "#000", correctLevel: QRCode.CorrectLevel.H }); const about = document.getElementById('about');
about.textContent += ' (using fallback address)';
document.getElementById("copy").onclick = () => { }
document.getElementById('addr').textContent = ln;
const qrEl = document.getElementById('qrcode');
qrEl.innerHTML = '';
new QRCode(qrEl, { text: ln, width: 200, height: 200, colorDark: '#111', colorLight: '#fff' });
document.getElementById('copy').onclick = () => {
navigator.clipboard.writeText(ln).then(() => { navigator.clipboard.writeText(ln).then(() => {
const btn = document.getElementById("copy"); const old = btn.textContent; const btn = document.getElementById('copy');
btn.textContent = "Copied!"; setTimeout(()=>btn.textContent=old, 1500); const old = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = old, 1200);
}); });
}; };
} }