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:
@@ -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: 1–100. (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')
|
||||
|
||||
Reference in New Issue
Block a user