v0.2.0:45 — Zaprite recurring auto-charge + mobile-friendly admin UI

Two routine bumps land together in this release:

:44 — Admin UI mobile pass. Adds a phone breakpoint (≤640px) and
hamburger-driven off-canvas drawer (≤720px) to the embedded
web/index.html so triage flows (status check, license lookup, revoke)
work from a phone. Tables now scroll horizontally inside their card,
tap targets bump to ~40px, stats grid collapses to 1-up, toolbar
inputs go full-width. Desktop layout unchanged. CSS + small JS toggle.

:45 — Zaprite recurring auto-charge wired end-to-end. Closes the gap
the subscriptions.rs module comment promised but never delivered:
first-cycle invoices on recurring policies set allow_save_payment_profile,
the on-settle hook captures the resulting Zaprite paymentProfileId
into four new nullable columns on the subscriptions table (migration
0019, additive only), and the renewal worker calls
POST /v1/orders/charge against the saved profile instead of waiting
for manual pay. On charge failure (declined card, expired profile,
network) the worker logs + audits + falls through to the existing
subscription.renewal_pending event so the buyer still has a recovery
path. Two new operator webhook events: subscription.auto_charge_initiated
and subscription.auto_charge_failed. BTCPay subs and Zaprite subs
whose buyer paid with Bitcoin/Lightning or declined the save-card
prompt are untouched. NOT yet end-to-end tested against the Zaprite
sandbox — control flow follows api.zaprite.com/llms.txt but exact
failure-body shapes for declined cards aren't documented; sandbox
validation pass recommended before relying in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-18 18:20:53 -05:00
parent c71345f002
commit fea6995192
9 changed files with 610 additions and 18 deletions
+102 -4
View File
@@ -180,6 +180,14 @@ table.t {
border-radius:10px; overflow:hidden;
}
.card > table.t { border:0; border-radius:0 0 10px 10px; }
/* Horizontally scrollable wrapper for tables on narrow screens. When the
table is wider than the card, the wrapper scrolls instead of the row
clipping. The wrapper carries the bottom rounding so the table itself
can stay square; otherwise the rounded table corners would clip mid-row
when scrolled. */
.t-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
.card > .t-wrap { border-radius:0 0 10px 10px; }
.card > .t-wrap > table.t { border:0; border-radius:0; }
table.t thead th {
text-align:left; font-size:11px; font-weight:700;
letter-spacing:0.12em; text-transform:uppercase; color:var(--ink-500);
@@ -315,6 +323,29 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
margin-top:22px;
}
/* ---------- Mobile nav (hamburger + off-canvas drawer) ---------- */
/* The hamburger button lives in the topbar and is hidden by default; the
≤720px breakpoint below reveals it and reframes the sidebar as a slide-in
drawer. The backdrop dims the content and provides a tap target for
closing. */
.nav-toggle {
display:none;
align-items:center; justify-content:center;
width:38px; height:38px; padding:0;
background:transparent; color:var(--navy-900);
border:1px solid var(--border-2); border-radius:7px;
cursor:pointer; transition:all 120ms;
}
.nav-toggle:hover { background:var(--cream-200); }
.nav-toggle [data-lucide] { width:20px; height:20px; }
.sidebar-backdrop {
display:none;
position:fixed; inset:0; background:rgba(14,31,51,0.45);
z-index:40; opacity:0; pointer-events:none;
transition:opacity 200ms;
}
.sidebar-backdrop.open { opacity:1; pointer-events:auto; }
@media (max-width: 980px) {
.app { grid-template-columns:1fr; }
.sidebar { position:static; max-height:none; height:auto; }
@@ -324,6 +355,41 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
.topbar { padding:14px 20px; }
}
/* Tablet/phone: collapse the sidebar into a true off-canvas drawer. Above
720px the stacked-sidebar layout from the 980px breakpoint is fine; below
720px the sidebar takes too much vertical space before content, so we
convert it to a slide-in instead. */
@media (max-width: 720px) {
.nav-toggle { display:inline-flex; }
.sidebar-backdrop { display:block; }
.sidebar {
position:fixed; top:0; left:0; bottom:0;
width:min(280px, 80vw); height:100vh; max-height:100vh;
padding:20px 12px;
transform:translateX(-100%); transition:transform 200ms ease;
z-index:50; overflow-y:auto;
}
.sidebar.open { transform:translateX(0); }
.sidebar a.nav { padding:12px 12px; font-size:14.5px; }
.sidebar a.nav [data-lucide] { width:18px; height:18px; }
}
/* Phone tier: tighten chrome, drop stats to a single column, let toolbar
inputs fill the row, bump button tap targets. */
@media (max-width: 640px) {
.stats { grid-template-columns:1fr; }
.content { padding:14px 14px 56px; }
.topbar { padding:12px 14px; gap:10px; }
.topbar h1 { font-size:18px; }
.topbar .who { display:none; }
.toolbar .input, .toolbar .select { min-width:0; width:100%; }
.card .card-head { padding:12px 14px; flex-wrap:wrap; }
.card .card-head .sub { margin-left:0; flex-basis:100%; }
.card .card-body { padding:14px; }
.btn { padding:10px 14px; }
.btn.sm { padding:8px 12px; }
}
/* Featured (launch special) pill toggle — used on the Discount Codes
create + edit forms. Click anywhere on the pill to flip the
underlying hidden checkbox. Off = muted; on = gold accent. Reads as
@@ -422,7 +488,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
<!-- Main app shell (shown after login) -->
<section id="app-view" class="hide">
<div class="app">
<aside class="sidebar">
<aside class="sidebar" id="sidebar">
<div class="brand">
<svg viewBox="0 0 100 100" fill="none" aria-hidden="true" style="width:26px; height:26px">
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
@@ -495,6 +561,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
</aside>
<main class="main">
<header class="topbar">
<button class="nav-toggle" id="nav-toggle" type="button"
aria-label="Open navigation" aria-expanded="false" aria-controls="sidebar">
<i data-lucide="menu"></i>
</button>
<div>
<div class="crumb" id="crumb">Workspace</div>
<h1 id="page-title">Overview</h1>
@@ -506,6 +576,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
</header>
<div class="content" id="route-target"></div>
</main>
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
</div>
</section>
@@ -1129,7 +1200,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const tb = el('tbody')
for (const r of rows) tb.appendChild(r)
t.appendChild(tb)
return el('div', { class: 'card' }, [head, t])
return el('div', { class: 'card' }, [head, el('div', { class: 't-wrap' }, t)])
}
function statusBadge(status) {
const map = {
@@ -3827,7 +3898,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const tb = el('tbody')
rows.forEach((r) => tb.appendChild(r))
t.appendChild(tb)
return t
return el('div', { class: 't-wrap' }, t)
}
function render() {
@@ -6467,9 +6538,36 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
}
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
a.addEventListener('click', (e) => {
e.preventDefault()
setRoute(a.getAttribute('data-route'))
// Close the off-canvas drawer on phones after navigating, otherwise
// the sidebar stays parked over the content the operator just opened.
closeSidebarDrawer()
})
})
// Mobile nav drawer. Above 720px the sidebar is a static column and these
// toggles are no-ops (the CSS keeps it visible regardless of `.open`).
const sidebarEl = document.getElementById('sidebar')
const backdropEl = document.getElementById('sidebar-backdrop')
const toggleEl = document.getElementById('nav-toggle')
function openSidebarDrawer() {
sidebarEl.classList.add('open')
backdropEl.classList.add('open')
toggleEl.setAttribute('aria-expanded', 'true')
}
function closeSidebarDrawer() {
sidebarEl.classList.remove('open')
backdropEl.classList.remove('open')
toggleEl.setAttribute('aria-expanded', 'false')
}
toggleEl.addEventListener('click', () => {
if (sidebarEl.classList.contains('open')) closeSidebarDrawer()
else openSidebarDrawer()
})
backdropEl.addEventListener('click', closeSidebarDrawer)
// Tier status (label + usage + caps) — cached after first fetch so
// multiple consumers within a single route render don't all re-hit
// /v1/admin/tier. Callers pass `forceRefresh:true` to bust the cache