duly / web/ui.js · raw
// 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;}