v0.2.0:31 — Punchlist clear: cap pre-check, grandfather banner, webhooks empty state, help-icon overhaul

Four outstanding admin-UI items shipped:

- Cap-hit pre-check. Products + Discount Codes pages fetch
  /v1/admin/tier on render and inline a gold-bordered "Approaching
  cap" warning above the submit button when usage is at cap-1.
  Includes a direct upgrade link. The existing 402 modal still
  fires if the operator submits anyway.
- Grandfather banner. When usage > current tier cap (e.g. downgrade
  from Pro to Creator with 8 products under a 5-product cap), the
  relevant page renders a persistent banner explaining the
  grandfather state and that new creates are blocked until upgrade.
  The daemon enforcement was already correct; the UI was silent.
- Webhooks empty state. Replaced the bare "No webhooks registered."
  table with a centered CTA card: eyebrow, headline, 2-sentence
  explainer of what webhooks are good for, and a primary "Add your
  first webhook" button that opens the create disclosure + focuses
  the URL input. Mirrors the Machines empty state.
- Help-icon click-to-toggle. helpIcon() now renders a small
  outlined button that opens a navy popover anchored next to it on
  click. Click outside / Esc / click again closes. Focus + Enter /
  Space opens. Visually less prominent. Replaces the prior native
  title= hover tooltip. Single function used everywhere, so the
  refactor ripples across the whole admin.

Three reusable helpers added: loadTierStatus, capPreCheckCard,
grandfatherBanner.

UI-only. No schema, API, or SDK change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 16:27:40 -05:00
parent 52deb82ad2
commit 3d7cf166db
2 changed files with 261 additions and 37 deletions
+241 -36
View File
@@ -766,19 +766,82 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
* The tooltip uses the browser's native title attribute — works
* everywhere, no JS, accessible to screen readers.
*/
// Click-to-toggle help icon. Replaces the prior native `title=` tooltip
// (hover-only, no keyboard, browser-styled) with a small inline button
// that opens a popover anchored next to itself. Click anywhere outside
// the popover (or the icon again) to dismiss. Keyboard: focus + Enter
// / Space toggles, Esc closes. Less visually prominent than before
// (smaller, outlined, lighter palette).
let _openHelpPopover = null
function closeHelpPopover() {
if (_openHelpPopover) {
_openHelpPopover.remove()
_openHelpPopover = null
}
}
document.addEventListener('click', (e) => {
if (!_openHelpPopover) return
if (_openHelpPopover.contains(e.target)) return
if (e.target.closest && e.target.closest('[data-help-icon]')) return
closeHelpPopover()
})
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _openHelpPopover) closeHelpPopover()
})
function helpIcon(text) {
return el('span', {
class: 'help-icon',
title: text,
tabindex: '0',
const btn = el('button', {
type: 'button',
'data-help-icon': '1',
'aria-label': text,
'aria-expanded': 'false',
style:
'display:inline-flex; align-items:center; justify-content:center; ' +
'width:14px; height:14px; border-radius:50%; ' +
'background:var(--ink-500); color:var(--cream-50); ' +
'font-size:10px; font-weight:700; font-family:var(--font-body); ' +
'cursor:help; margin-left:6px; user-select:none; flex:none;',
'width:13px; height:13px; border-radius:50%; ' +
'background:transparent; color:var(--ink-500); ' +
'border:1px solid var(--ink-300, var(--border-2)); ' +
'font-size:9px; font-weight:600; font-family:var(--font-body); ' +
'cursor:pointer; margin-left:5px; user-select:none; flex:none; padding:0; ' +
'line-height:1; vertical-align:middle;',
}, '?')
btn.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
// Toggle: if this icon's popover is the one open, close it.
if (_openHelpPopover && _openHelpPopover._anchor === btn) {
closeHelpPopover()
btn.setAttribute('aria-expanded', 'false')
return
}
closeHelpPopover()
const pop = el('div', {
role: 'tooltip',
style:
'position:absolute; z-index:9000; max-width:280px; ' +
'background:var(--navy-950); color:var(--cream-50); ' +
'padding:8px 12px; border-radius:8px; ' +
'font-family:var(--font-body); font-size:12.5px; font-weight:400; ' +
'line-height:1.45; box-shadow:0 8px 24px rgba(14,31,51,0.20);',
}, text)
pop._anchor = btn
document.body.appendChild(pop)
// Position: anchor near the icon, then clamp to the viewport.
const r = btn.getBoundingClientRect()
const scrollY = window.scrollY || window.pageYOffset
const scrollX = window.scrollX || window.pageXOffset
// Append first so we can measure the popover's rendered size.
const pw = pop.offsetWidth
const ph = pop.offsetHeight
let left = r.left + scrollX + r.width / 2 - pw / 2
let top = r.bottom + scrollY + 6
// Clamp horizontally so the popover never escapes the viewport.
const vw = document.documentElement.clientWidth
left = Math.max(8 + scrollX, Math.min(left, scrollX + vw - pw - 8))
pop.style.left = left + 'px'
pop.style.top = top + 'px'
_openHelpPopover = pop
btn.setAttribute('aria-expanded', 'true')
})
return btn
}
/**
@@ -1546,6 +1609,14 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const target = document.getElementById('route-target')
target.innerHTML = ''
// Tier status (forced refresh so usage counts reflect any creates /
// deletes from the prior route). Used to render two surfaces:
// - Grandfather banner at top when product usage > cap.
// - Pre-check warning inside the create disclosure when at cap-1.
const tierStatus = await loadTierStatus({ forceRefresh: true })
const gfBanner = grandfatherBanner(tierStatus, 'products', 'products')
if (gfBanner) target.appendChild(gfBanner)
// Create form. Currency picker swaps the price-input units in
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
// we convert to cents on the way out (the backend stores
@@ -1605,6 +1676,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
createCatalog.element,
]),
// Pre-check warning when the operator is at cap-1 (or already
// over) for products. Renders inline above the submit so they
// know what to expect before clicking.
capPreCheckCard(tierStatus, 'products', 'products'),
el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product').addEventListener
? null : null, // dummy; the real button is below for clarity
(() => {
@@ -3773,6 +3848,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const target = document.getElementById('route-target')
target.innerHTML = ''
// Tier status (forced refresh — usage may have changed via the
// prior route). `active_codes` is the metric the daemon enforces;
// grandfather banner + pre-check warning hang off it.
const codesTierStatus = await loadTierStatus({ forceRefresh: true })
const codesGfBanner = grandfatherBanner(codesTierStatus, 'active_codes', 'active discount codes')
if (codesGfBanner) target.appendChild(codesGfBanner)
function amountHint(kind, currency) {
if (kind === 'percent') return 'percent off, e.g. 50 = 50%. Range: 1100. (Currency-agnostic.)'
if (kind === 'free_license') return 'amount is ignored — buyer pays nothing.'
@@ -3972,6 +4054,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
'When on: display on the buy page with a diagonal "LAUNCH SPECIAL" ribbon + the original price struck through. Auto-applies to buyers who don\'t type a code. Operator-typed codes still win when a buyer pastes one.'),
])
})(),
// Pre-check warning when the operator is at cap-1 (or already
// over) for active discount codes. Renders inline above the
// submit so they know what to expect before clicking.
capPreCheckCard(codesTierStatus, 'active_codes', 'active discount codes'),
el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
create.querySelector('.body').appendChild(status)
@@ -5296,34 +5382,62 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
try {
const j = await api('/v1/admin/webhook-endpoints')
const eps = j.endpoints || j.webhooks || []
const rows = eps.map((e) => el('tr', null, [
el('td', null, el('code', { style: 'word-break:break-all' }, e.url)),
el('td', { class: 'muted' }, (e.event_types || []).join(', ')),
el('td', null, activePill(e.active)),
el('td', { class: 'muted' }, fmtDate(e.created_at)),
el('td', null, el('div', { class: 'actions-row' }, [
el('button', { class: 'btn sm secondary', onclick: async () => {
try { await api('/v1/admin/webhook-endpoints/' + e.id + '/active', { method: 'PATCH', body: { active: !e.active } }); routes.webhooks() } catch (er) { alert(er.message) }
}}, e.active ? 'Disable' : 'Enable'),
el('button', { class: 'btn sm danger', onclick: async () => {
if (!await confirmModal({
eyebrow: 'Delete webhook',
title: 'Delete this webhook subscription?',
message: 'New events will no longer be delivered to this endpoint. Past delivery history is preserved.',
confirmLabel: 'Delete',
confirmVariant: 'danger',
})) return
try { await api('/v1/admin/webhook-endpoints/' + e.id, { method: 'DELETE' }); routes.webhooks() } catch (er) { alert(er.message) }
}}, 'Delete'),
])),
]))
target.appendChild(tableCard(
'Registered endpoints',
eps.length + ' total',
['URL', 'Events', 'Status', 'Created', ''],
rows,
'No webhooks registered.'
))
// Empty state: show a CTA + "what's a webhook for?" explainer
// instead of a bare empty table. Mirrors the Machines tab
// empty state for visual consistency. Clicking the primary
// CTA opens the create disclosure (the same form below).
if (eps.length === 0) {
const ctaCard = el('div', {
style: 'padding:32px 28px; background:var(--cream-50); ' +
'border:1px solid var(--border-1); border-radius:12px; ' +
'text-align:center; box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);',
}, [
el('div', { class: 'eyebrow', style: 'color:var(--gold-700); margin-bottom:6px' }, 'No webhooks yet'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 8px; color:var(--navy-950); letter-spacing:-0.01em;' },
'Get notified when something happens'),
el('p', { style: 'font-size:13.5px; color:var(--ink-700); line-height:1.55; margin:0 auto 16px; max-width:520px' },
'A webhook is a URL Keysat POSTs to when an event occurs — license issued, license revoked, code redeemed, invoice settled. Wire one up to sync your own database, post to Slack / Discord, kick off a fulfillment workflow, or trigger a CI run. Every delivery carries an HMAC-SHA256 signature in the X-Keysat-Signature header so you can verify it really came from this daemon.'),
el('button', {
class: 'btn primary',
onclick: () => {
create.open = true
const url = create.querySelector('[name=url]')
if (url) url.focus()
create.scrollIntoView({ behavior: 'smooth', block: 'center' })
},
}, 'Add your first webhook'),
])
target.appendChild(ctaCard)
} else {
const rows = eps.map((e) => el('tr', null, [
el('td', null, el('code', { style: 'word-break:break-all' }, e.url)),
el('td', { class: 'muted' }, (e.event_types || []).join(', ')),
el('td', null, activePill(e.active)),
el('td', { class: 'muted' }, fmtDate(e.created_at)),
el('td', null, el('div', { class: 'actions-row' }, [
el('button', { class: 'btn sm secondary', onclick: async () => {
try { await api('/v1/admin/webhook-endpoints/' + e.id + '/active', { method: 'PATCH', body: { active: !e.active } }); routes.webhooks() } catch (er) { alert(er.message) }
}}, e.active ? 'Disable' : 'Enable'),
el('button', { class: 'btn sm danger', onclick: async () => {
if (!await confirmModal({
eyebrow: 'Delete webhook',
title: 'Delete this webhook subscription?',
message: 'New events will no longer be delivered to this endpoint. Past delivery history is preserved.',
confirmLabel: 'Delete',
confirmVariant: 'danger',
})) return
try { await api('/v1/admin/webhook-endpoints/' + e.id, { method: 'DELETE' }); routes.webhooks() } catch (er) { alert(er.message) }
}}, 'Delete'),
])),
]))
target.appendChild(tableCard(
'Registered endpoints',
eps.length + ' total',
['URL', 'Events', 'Status', 'Created', ''],
rows,
'No webhooks registered.'
))
}
} catch (e) {
target.appendChild(plainCard([err(e.message)]))
}
@@ -6185,6 +6299,97 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
})
// 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
// (e.g. after a successful create/delete that changes usage).
let _tierStatusCache = null
async function loadTierStatus(opts) {
const forceRefresh = !!(opts && opts.forceRefresh)
if (_tierStatusCache && !forceRefresh) return _tierStatusCache
try {
_tierStatusCache = await api('/v1/admin/tier')
return _tierStatusCache
} catch (_) {
return null
}
}
// Helper: given a tier status, a usage-key (e.g. 'products'), and a
// human label, render the right pre-check warning element OR a
// grandfather banner. Returns null when no warning is needed.
//
// States:
// - usage >= cap → returns 'over' (grandfather banner)
// - usage == cap - 1 → returns 'pre' (approaching cap)
// - usage < cap - 1 returns 'ok' (no warning)
// - cap is null → returns 'unlim' (unlimited)
function tierCapState(tierStatus, key) {
if (!tierStatus || !tierStatus.caps) return 'unlim'
const cap = tierStatus.caps[key]
if (cap === null || cap === undefined) return 'unlim'
const used = (tierStatus.usage && tierStatus.usage[key]) || 0
if (used >= cap) return 'over'
if (used === cap - 1) return 'pre'
return 'ok'
}
// Render a small inline warning card for a "you're 1-away from the
// cap" pre-check. Used above create-form submit buttons.
function capPreCheckCard(tierStatus, key, label) {
if (!tierStatus) return null
const state = tierCapState(tierStatus, key)
if (state !== 'pre' && state !== 'over') return null
const cap = tierStatus.caps[key]
const used = (tierStatus.usage && tierStatus.usage[key]) || 0
const upgradeUrl = tierStatus.upgrade_url
const nextTier = (tierStatus.next_tier || 'pro').replace(/^[a-z]/, (c) => c.toUpperCase())
const overText = 'You\'re at ' + used + ' active ' + label + ' (cap: ' + cap +
'). Existing ones still work — but new ones are blocked until you upgrade to ' + nextTier + '.'
const preText = 'You\'re at ' + used + '/' + cap + ' ' + label +
'. Creating one more will hit your ' + tierStatus.tier_name + ' tier cap. ' +
'Upgrade to ' + nextTier + ' for unlimited ' + label + '.'
return el('div', {
style: 'margin:8px 0 4px; padding:10px 12px; ' +
'background:rgba(191,160,104,0.10); border:1px solid var(--gold-500); ' +
'border-radius:8px; font-size:12.5px; color:var(--ink-700); line-height:1.5;',
}, [
el('strong', { style: 'color:var(--navy-950); display:block; margin-bottom:3px' },
state === 'over' ? 'Cap reached' : 'Approaching cap'),
el('span', null, state === 'over' ? overText : preText),
upgradeUrl ? el('a', {
href: upgradeUrl, target: '_blank', rel: 'noopener',
style: 'display:inline-block; margin-left:6px; color:var(--gold-700); font-weight:600; text-decoration:none',
}, 'Upgrade →') : null,
].filter(Boolean))
}
// Render a persistent grandfather banner for over-cap scope (usage
// strictly above current tier's cap — i.e. operator downgraded but
// we're letting them keep existing rows). Returns null when not over.
function grandfatherBanner(tierStatus, key, label) {
if (!tierStatus) return null
if (tierCapState(tierStatus, key) !== 'over') return null
const cap = tierStatus.caps[key]
const used = (tierStatus.usage && tierStatus.usage[key]) || 0
const upgradeUrl = tierStatus.upgrade_url
const nextTier = (tierStatus.next_tier || 'pro').replace(/^[a-z]/, (c) => c.toUpperCase())
return el('div', {
style: 'margin:0 0 14px; padding:10px 14px; ' +
'background:rgba(191,160,104,0.10); border:1px solid var(--gold-500); ' +
'border-radius:8px; font-size:13px; color:var(--ink-700); line-height:1.55; ' +
'display:flex; gap:14px; align-items:center; flex-wrap:wrap;',
}, [
el('div', { style: 'flex:1; min-width:260px' }, [
el('strong', { style: 'color:var(--navy-950)' },
'Grandfathered: ' + used + ' ' + label + ' active vs ' + tierStatus.tier_name +
' tier cap of ' + cap + '. '),
el('span', null, 'Existing ' + label + ' keep working. Creating new ones is blocked until you upgrade to ' + nextTier + '.'),
]),
upgradeUrl ? el('a', {
href: upgradeUrl, target: '_blank', rel: 'noopener',
class: 'btn sm primary', style: 'text-decoration:none; flex:none',
}, 'Upgrade to ' + nextTier + ' →') : null,
].filter(Boolean))
}
async function refreshTierBanner() {
const wrap = document.getElementById('tier-banner')
const current = document.getElementById('tier-banner-current')