// DOM helper. Text always goes in through text nodes and there is no innerHTML in// this app, because invoice content is written by strangers.export function h(tag, attrs = {}, ...kids) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v === null || v === undefined || v === false) continue; if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v); else if (k === 'class') el.className = v; else if (k === 'value') el.value = v; else if (k === 'href' || k === 'src') el.setAttribute(k, safeUrl(v)); else el.setAttribute(k, v === true ? '' : String(v)); } for (const kid of kids.flat(Infinity)) { if (kid === null || kid === undefined || kid === false) continue; el.append(kid instanceof Node ? kid : document.createTextNode(String(kid))); } return el;}/** Only same-site paths, https links, and the data URLs this app makes for QR codes. */export function safeUrl(u) { const s = String(u); if (s.startsWith('/') && !s.startsWith('//')) return s; if (s.startsWith('#') || s.startsWith('data:image/gif;base64,')) return s; try { return new URL(s).protocol === 'https:' ? s : '#'; } catch { return '#'; }}export function mount(node) { const root = document.getElementById('app'); root.replaceChildren(node); window.scrollTo(0, 0);}let toastTimer;export function toast(message, kind = 'ok') { const el = document.getElementById('toast'); el.textContent = message; el.className = `toast show ${kind}`; clearTimeout(toastTimer); toastTimer = setTimeout(() => (el.className = 'toast'), kind === 'bad' ? 6000 : 3000);}export function fmtDate(ms) { const n = Number(ms); if (!n) return ''; return new Date(n).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });}export function fmtDateTime(ms) { const n = Number(ms); if (!n) return ''; return `${new Date(n).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC`;}export const STATUS = { open: ['Not paid yet', 'pending'], overdue: ['Overdue', 'bad'], partial: ['Part paid', 'pending'], paid: ['Paid', 'good'], partially_refunded: ['Part refunded', 'neutral'], refunded: ['Refunded', 'neutral'], cancelled: ['Cancelled', 'neutral'],};export function statusChip(status) { const [label, kind] = STATUS[status] || [status, 'neutral']; return h('span', { class: `chip ${kind}` }, label);}export function short(s, head = 6, tail = 4) { const t = String(s); return t.length > head + tail + 1 ? `${t.slice(0, head)}…${t.slice(-tail)}` : t;}export async function copy(text, label = 'Copied') { try { await navigator.clipboard.writeText(text); toast(label); } catch { toast('Could not copy. Select the text and copy it.', 'bad'); }}/** Run an async action with its button disabled and any error shown plainly. */export function action(fn, busyLabel) { return async (e) => { const btn = e?.currentTarget; const original = btn?.textContent; if (btn) { btn.disabled = true; if (busyLabel) btn.textContent = busyLabel; } try { await fn(e); } catch (err) { toast(err.message || 'Something went wrong', 'bad'); } finally { if (btn) { btn.disabled = false; if (busyLabel) btn.textContent = original; } } };}export function field(label, input, hint) { return h('label', { class: 'field' }, h('span', { class: 'field-label' }, label), input, hint && h('span', { class: 'hint' }, hint));}export function qr(text, size = 216) { const q = window.qrcode(0, 'M'); q.addData(text); q.make(); const cell = Math.floor(size / (q.getModuleCount() + 8)) || 2; return h('img', { class: 'qr', src: q.createDataURL(cell, cell * 4), alt: 'QR code', width: size, height: size });}// The landing's icon set (16 px grid, 1.6 stroke), built with DOM calls; decorative only.const ICON_SHAPES = { copy: [['rect', { x: 5.5, y: 5.5, width: 8, height: 8, rx: 1.5 }], ['path', { d: 'M10.5 5.5V4A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5' }]],};export function icon(name) { const ns = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('class', 'ico'); svg.setAttribute('aria-hidden', 'true'); for (const [tag, attrs] of ICON_SHAPES[name] || []) { const el = document.createElementNS(ns, tag); for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)); svg.append(el); } return svg;}