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
|
* The tooltip uses the browser's native title attribute — works
|
||||||
* everywhere, no JS, accessible to screen readers.
|
* 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) {
|
function helpIcon(text) {
|
||||||
return el('span', {
|
const btn = el('button', {
|
||||||
class: 'help-icon',
|
type: 'button',
|
||||||
title: text,
|
'data-help-icon': '1',
|
||||||
tabindex: '0',
|
|
||||||
'aria-label': text,
|
'aria-label': text,
|
||||||
|
'aria-expanded': 'false',
|
||||||
style:
|
style:
|
||||||
'display:inline-flex; align-items:center; justify-content:center; ' +
|
'display:inline-flex; align-items:center; justify-content:center; ' +
|
||||||
'width:14px; height:14px; border-radius:50%; ' +
|
'width:13px; height:13px; border-radius:50%; ' +
|
||||||
'background:var(--ink-500); color:var(--cream-50); ' +
|
'background:transparent; color:var(--ink-500); ' +
|
||||||
'font-size:10px; font-weight:700; font-family:var(--font-body); ' +
|
'border:1px solid var(--ink-300, var(--border-2)); ' +
|
||||||
'cursor:help; margin-left:6px; user-select:none; flex:none;',
|
'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')
|
const target = document.getElementById('route-target')
|
||||||
target.innerHTML = ''
|
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
|
// Create form. Currency picker swaps the price-input units in
|
||||||
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
|
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
|
||||||
// we convert to cents on the way out (the backend stores
|
// 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)' }, [
|
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||||||
createCatalog.element,
|
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
|
el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product').addEventListener
|
||||||
? null : null, // dummy; the real button is below for clarity
|
? 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')
|
const target = document.getElementById('route-target')
|
||||||
target.innerHTML = ''
|
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) {
|
function amountHint(kind, currency) {
|
||||||
if (kind === 'percent') return 'percent off, e.g. 50 = 50%. Range: 1–100. (Currency-agnostic.)'
|
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.'
|
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.'),
|
'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 () {
|
el('button', { class: 'btn primary', onclick: async function () {
|
||||||
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
|
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
|
||||||
create.querySelector('.body').appendChild(status)
|
create.querySelector('.body').appendChild(status)
|
||||||
@@ -5296,6 +5382,33 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
try {
|
try {
|
||||||
const j = await api('/v1/admin/webhook-endpoints')
|
const j = await api('/v1/admin/webhook-endpoints')
|
||||||
const eps = j.endpoints || j.webhooks || []
|
const eps = j.endpoints || j.webhooks || []
|
||||||
|
// 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, [
|
const rows = eps.map((e) => el('tr', null, [
|
||||||
el('td', null, el('code', { style: 'word-break:break-all' }, e.url)),
|
el('td', null, el('code', { style: 'word-break:break-all' }, e.url)),
|
||||||
el('td', { class: 'muted' }, (e.event_types || []).join(', ')),
|
el('td', { class: 'muted' }, (e.event_types || []).join(', ')),
|
||||||
@@ -5324,6 +5437,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
rows,
|
rows,
|
||||||
'No webhooks registered.'
|
'No webhooks registered.'
|
||||||
))
|
))
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
target.appendChild(plainCard([err(e.message)]))
|
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')) })
|
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() {
|
async function refreshTierBanner() {
|
||||||
const wrap = document.getElementById('tier-banner')
|
const wrap = document.getElementById('tier-banner')
|
||||||
const current = document.getElementById('tier-banner-current')
|
const current = document.getElementById('tier-banner-current')
|
||||||
|
|||||||
@@ -58,6 +58,25 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:31 — **Four-item punchlist landed: cap-hit pre-check, grandfather banner, webhooks empty state, help-icon overhaul.** Clears the remaining outstanding admin-UI items.',
|
||||||
|
'',
|
||||||
|
'**Cap-hit pre-check (item #7).** Operators no longer have to submit-and-bounce off a 402 to learn they\'re about to hit a tier cap. The Products page and the Discount Codes page each call `/v1/admin/tier` on render and surface a gold-bordered "Approaching cap" warning inline above the create-form submit button whenever usage is at cap-1 (e.g. 4/5 products on Creator). The warning includes a direct upgrade link. The existing 402 → upgrade modal still fires if the operator goes ahead and submits.',
|
||||||
|
'',
|
||||||
|
'**Grandfather banner (item #6).** When the operator downgrades a tier and ends up with more existing rows than the new tier\'s cap allows (e.g. 8 products under Creator\'s 5-product cap), the relevant page now renders a persistent grandfather banner at the top: "Grandfathered: 8 products active vs Creator tier cap of 5. Existing products keep working. Creating new ones is blocked until you upgrade to Pro." The daemon\'s enforcement was already correct — it only blocks NEW writes, never deletes existing — but the UI was silent about it, leaving operators confused about why creates failed. Banner appears on Products + Discount Codes pages (the two surfaces with global tier caps). Per-product policy caps not yet pre-checked; that\'s a follow-up polish.',
|
||||||
|
'',
|
||||||
|
'**Webhooks empty state (item #10/15).** Previously the Webhooks tab rendered a bare "No webhooks registered." empty table on a fresh instance — no CTA, no context. Now there\'s a centered card with eyebrow + headline ("Get notified when something happens"), a 2-sentence "what\'s a webhook for?" explainer covering common use cases (license issued, code redeemed, invoice settled, fulfillment automation), and a primary "Add your first webhook" button that opens the create disclosure and focuses the URL input. Mirrors the Machines tab\'s empty state for visual consistency.',
|
||||||
|
'',
|
||||||
|
'**Help-icon click-to-toggle (item #13).** The "?" tooltips peppered through the admin UI previously used the browser\'s native `title=` attribute — hover-only, browser-styled, no keyboard access, accidentally triggered on grazes. Refactored to a small outlined `<button>` that opens a navy-styled popover anchored next to itself on click. Click outside / press Esc / click the icon again closes it. Focus + Enter / Space opens. Visually less prominent (smaller, outlined cream vs the previous filled grey). One JS function (`helpIcon`) — used everywhere — so the change ripples across every help affordance.',
|
||||||
|
'',
|
||||||
|
'**New helpers.** Three new JS helpers used by the changes above (and available for future surfaces):',
|
||||||
|
'- `loadTierStatus({ forceRefresh })` — fetches `/v1/admin/tier`, returns a cached promise within a single route render.',
|
||||||
|
'- `capPreCheckCard(tierStatus, key, label)` — inline warning when usage is at cap-1 or over.',
|
||||||
|
'- `grandfatherBanner(tierStatus, key, label)` — persistent banner when usage strictly exceeds cap.',
|
||||||
|
'',
|
||||||
|
'**Test count: 87** (unchanged — pure UI / CSS).',
|
||||||
|
'',
|
||||||
|
'**Upgrade path.** v0.2.0:30 → v0.2.0:31 is a drop-in. No schema, no SDK breaking change. The `/v1/admin/tier` endpoint already existed; this release just consumes it on more surfaces.',
|
||||||
|
'',
|
||||||
'0.2.0:30 — **Two small copy fixes.** "Embed your public key" tip now says "your product\'s source code" (not "your app\'s source") — clearer for operators distributing libraries, services, or anything that isn\'t literally an app. And the Licenses search row drops the Nostr npub mention from the placeholder, the description, and the search-field dropdown, since the purchase flow doesn\'t capture buyer npubs yet so the option has nothing to find. The npub search code-path on the backend stays — we\'ll bring the UI option back when buyer npub capture lands in the purchase flow.',
|
'0.2.0:30 — **Two small copy fixes.** "Embed your public key" tip now says "your product\'s source code" (not "your app\'s source") — clearer for operators distributing libraries, services, or anything that isn\'t literally an app. And the Licenses search row drops the Nostr npub mention from the placeholder, the description, and the search-field dropdown, since the purchase flow doesn\'t capture buyer npubs yet so the option has nothing to find. The npub search code-path on the backend stays — we\'ll bring the UI option back when buyer npub capture lands in the purchase flow.',
|
||||||
'',
|
'',
|
||||||
'0.2.0:29 — **Tier-card cross-card horizontal alignment via CSS subgrid.** Visually equivalent sections (names, prices, first feature bullet, Select button) now line up horizontally across all visible tier cards. Cards with fewer / shorter sections get extra whitespace in the rows they don\'t fill — the explicit tradeoff the operator asked for, in service of a cleaner grid.',
|
'0.2.0:29 — **Tier-card cross-card horizontal alignment via CSS subgrid.** Visually equivalent sections (names, prices, first feature bullet, Select button) now line up horizontally across all visible tier cards. Cards with fewer / shorter sections get extra whitespace in the rows they don\'t fill — the explicit tradeoff the operator asked for, in service of a cleaner grid.',
|
||||||
@@ -458,7 +477,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:30',
|
version: '0.2.0:31',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user