From 3d7cf166db3911963c76892915cfefb60b38c1dc Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 May 2026 16:27:40 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:31=20=E2=80=94=20Punchlist=20clear:=20ca?= =?UTF-8?q?p=20pre-check,=20grandfather=20banner,=20webhooks=20empty=20sta?= =?UTF-8?q?te,=20help-icon=20overhaul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- licensing-service/web/index.html | 277 +++++++++++++++++++++++++++---- startos/versions/v0.2.0.ts | 21 ++- 2 files changed, 261 insertions(+), 37 deletions(-) diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 78b8f6c..e8f9bd3 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -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') diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index ad70a4b..4e74224 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,25 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. 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 `