UX polish — duration, preview button, Select state, dropdown current, switch action

Pure UX bundle from the testing batch. None individually changes
behavior; together they remove a half-dozen sharp edges.

1. Policy-list duration column: human-readable
   `31536000s` / `604800s` / `0s` are now `1 year` / `1 week` /
   `perpetual`. New `fmtDuration()` helper handles common cadences
   (1 day, 1 week, 1 month, 3 months, 6 months, 1 year, 2 years)
   with arithmetic fallbacks for non-canonical values. Grace
   column gets the same treatment with "none" for 0.

2. "Preview buy page" button per product header
   The Policies tab's per-product card now has a "Preview buy
   page" button on the right side of the header (when ≥ 1
   public+active policy exists). Opens /buy/<slug> in a new
   tab. tableCard() helper grew an optional headerAction param.

3. Buy page tier card: "Select" → "Selected"
   When a tier becomes the active selection, its button label
   flips to "Selected" while other tiers' buttons stay "Select".
   Combined with the existing .selected card-border styling
   gives buyers an unambiguous "yes, this tier is what's tied
   to the price card below" cue.

4. Licenses page POLICY column shows display name
   Was showing slug (`recurring`, `core`, `creator`); now shows
   the operator-set display name (Recurring Pro, Core, Creator)
   primary, with the slug as a smaller mono-font line below.
   Operators see what the buyer sees while keeping the slug
   visible for SDK reference. (Subscriptions tab already
   handled this pattern; this brings Licenses in line.)

5. Change Tier dropdown: "(current)" annotation
   Current tier now appears in the dropdown but with " · current"
   appended and `disabled` attribute set. Operator sees what
   they're starting from but can't pick the no-op. Auto-selects
   the first SELECTABLE option so the modal opens with a valid
   target ready. formSelect() helper grew per-option `disabled`
   support.

6. Single "Switch active payment provider" StartOS action
   The two old "Activate BTCPay" / "Activate Zaprite" actions
   collapsed into one dropdown-driven action. Operators saw the
   pair as confusing — both appeared alongside Connect /
   Disconnect / Status, and operators couldn't tell at a glance
   which one was currently active. New action pre-fills the
   dropdown with the currently-active provider so opening it is
   immediately informative.
   Old action ids retained as visibility:'hidden' shims for
   back-compat with any operator scripts pointing at them.

Test count unchanged; UI-only changes don't touch any test
fixtures.
This commit is contained in:
Grant
2026-05-09 14:02:20 -05:00
parent 54f7ea08b5
commit 927ac2be53
4 changed files with 193 additions and 45 deletions
+8 -2
View File
@@ -603,10 +603,16 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
function selectTier(slug) {{ function selectTier(slug) {{
if (!TIERS[slug]) return; if (!TIERS[slug]) return;
selectedPolicy = slug; selectedPolicy = slug;
// Visual update. // Visual update — toggle .selected on cards AND swap the button
// label so the chosen tier reads "Selected" while the others
// stay "Select". Buyer gets a clear "yes, this is what's tied
// to the price card below" signal.
document.querySelectorAll('.tier').forEach(function(c) {{ document.querySelectorAll('.tier').forEach(function(c) {{
if (c.getAttribute('data-policy-slug') === slug) c.classList.add('selected'); const isMatch = c.getAttribute('data-policy-slug') === slug;
if (isMatch) c.classList.add('selected');
else c.classList.remove('selected'); else c.classList.remove('selected');
const btn = c.querySelector('.tier-select-btn');
if (btn) btn.textContent = isMatch ? 'Selected' : 'Select';
}}); }});
// Reset any active discount apply state — a different tier may not // Reset any active discount apply state — a different tier may not
// honor the same code (server validates again on the next Apply). // honor the same code (server validates again on the next Apply).
+85 -14
View File
@@ -701,10 +701,13 @@ The request will be refused if there are licenses or invoices tied to it — use
function plainCard(body) { function plainCard(body) {
return el('div', { class: 'card' }, el('div', { class: 'card-body' }, body)) return el('div', { class: 'card' }, el('div', { class: 'card-body' }, body))
} }
function tableCard(title, sub, headers, rows, emptyMsg) { function tableCard(title, sub, headers, rows, emptyMsg, headerAction) {
const head = el('div', { class: 'card-head' }, [ const head = el('div', { class: 'card-head' }, [
el('h3', null, title), el('h3', null, title),
sub ? el('span', { class: 'sub' }, sub) : null, sub ? el('span', { class: 'sub' }, sub) : null,
// Optional right-aligned action element (e.g. "Preview buy page"
// button on the policies card).
headerAction ? el('span', { style: 'margin-left:auto' }, headerAction) : null,
]) ])
if (rows.length === 0) { if (rows.length === 0) {
return el('div', { class: 'card' }, [head, el('div', { class: 'empty' }, emptyMsg || 'Nothing yet.')]) return el('div', { class: 'card' }, [head, el('div', { class: 'empty' }, emptyMsg || 'Nothing yet.')])
@@ -1371,16 +1374,24 @@ The request will be refused if there are licenses or invoices tied to it — use
function renderPolicyPicker() { function renderPolicyPicker() {
policiesHolder.innerHTML = '' policiesHolder.innerHTML = ''
const opts = allPolicies // Show ALL policies but mark the current one as disabled with
.filter((p) => p.slug !== currentPolicySlug) // "(current)" suffix — operator sees what they're starting from
.map((p) => ({ // but can't pick a no-op. Other policies become the actual
// change targets.
const opts = allPolicies.map((p) => {
const isCurrent = p.slug === currentPolicySlug
return {
value: p.slug, value: p.slug,
disabled: isCurrent,
label: p.name + ' (' + p.slug + ')' + label: p.name + ' (' + p.slug + ')' +
(p.tier_rank != null ? ' · rank ' + p.tier_rank : '') + (p.tier_rank != null ? ' · rank ' + p.tier_rank : '') +
(p.is_recurring ? ' · recurring' : '') + (p.is_recurring ? ' · recurring' : '') +
(p.is_trial ? ' · trial' : ''), (p.is_trial ? ' · trial' : '') +
})) (isCurrent ? ' · current' : ''),
if (opts.length === 0) { }
})
const selectableOpts = opts.filter((o) => !o.disabled)
if (selectableOpts.length === 0) {
policiesHolder.appendChild(plainCard([ policiesHolder.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' }, el('p', { class: 'muted', style: 'margin:0' },
'No other policies on this product. Create one first under Policies → ' + (license.product_slug || '<product>') + '.'), 'No other policies on this product. Create one first under Policies → ' + (license.product_slug || '<product>') + '.'),
@@ -1394,8 +1405,8 @@ The request will be refused if there are licenses or invoices tied to it — use
selectedTargetSlug = selEl.value selectedTargetSlug = selEl.value
runQuote() runQuote()
}) })
// Auto-pick first option + run quote. // Auto-pick first SELECTABLE option (skip the disabled current-tier).
selectedTargetSlug = opts[0].value selectedTargetSlug = selectableOpts[0].value
selEl.value = selectedTargetSlug selEl.value = selectedTargetSlug
runQuote() runQuote()
} }
@@ -2059,6 +2070,32 @@ The request will be refused if there are licenses or invoices tied to it — use
const counts = await api('/v1/admin/licenses/counts').catch(() => ({ by_policy: {} })) const counts = await api('/v1/admin/licenses/counts').catch(() => ({ by_policy: {} }))
const byPolicy = (counts && counts.by_policy) || {} const byPolicy = (counts && counts.by_policy) || {}
// Render a raw seconds value as a human-readable duration. Common
// cadences map to nice labels (1 day, 1 week, 1 month, 1 year);
// arbitrary values fall back to the closest unit. 0 = perpetual.
function fmtDuration(secs) {
if (!secs || secs === 0) return 'perpetual'
const days = Math.round(secs / 86400)
if (secs < 60) return secs + 's'
if (secs < 3600) return Math.round(secs / 60) + 'min'
if (secs < 86400) return Math.round(secs / 3600) + 'h'
if (days === 1) return '1 day'
if (days === 7) return '1 week'
if (days === 30) return '1 month'
if (days === 90) return '3 months'
if (days === 180) return '6 months'
if (days === 365) return '1 year'
if (days === 730) return '2 years'
if (days % 365 === 0) return (days / 365) + ' years'
if (days % 30 === 0) return (days / 30) + ' months'
if (days % 7 === 0) return (days / 7) + ' weeks'
return days + ' days'
}
function fmtGrace(secs) {
if (!secs || secs === 0) return 'none'
return fmtDuration(secs)
}
for (const p of products) { for (const p of products) {
try { try {
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(p.slug)) const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(p.slug))
@@ -2066,8 +2103,8 @@ The request will be refused if there are licenses or invoices tied to it — use
const rows = policies.map((pol) => el('tr', null, [ const rows = policies.map((pol) => el('tr', null, [
el('td', null, el('code', null, pol.slug)), el('td', null, el('code', null, pol.slug)),
el('td', { style: 'font-weight:600; color:var(--navy-950)' }, pol.name), el('td', { style: 'font-weight:600; color:var(--navy-950)' }, pol.name),
el('td', null, pol.duration_seconds === 0 ? 'perpetual' : pol.duration_seconds + 's'), el('td', null, fmtDuration(pol.duration_seconds)),
el('td', null, pol.grace_seconds + 's'), el('td', null, fmtGrace(pol.grace_seconds)),
el('td', null, pol.max_machines === 0 ? '∞' : String(pol.max_machines)), el('td', null, pol.max_machines === 0 ? '∞' : String(pol.max_machines)),
el('td', null, el('td', null,
// Stack trial + recurring badges in one cell. Both can be set // Stack trial + recurring badges in one cell. Both can be set
@@ -2120,12 +2157,29 @@ The request will be refused if there are licenses or invoices tied to it — use
}, 'Delete'), }, 'Delete'),
])), ])),
])) ]))
// Per-product header action: open the buy page in a new tab
// so the operator can preview how their policies render to a
// buyer without leaving the admin SPA. Only shown when the
// product has at least one public policy (otherwise the buy
// page would render empty).
const hasPublicPolicy = policies.some((pol) => pol.public && pol.active)
const previewBtn = hasPublicPolicy
? el('a', {
class: 'btn sm secondary',
href: '/buy/' + encodeURIComponent(p.slug),
target: '_blank',
rel: 'noopener',
title: 'Open this product\'s public buy page in a new tab',
style: 'text-decoration:none',
}, 'Preview buy page')
: null
target.appendChild(tableCard( target.appendChild(tableCard(
p.name + ' — ' + p.slug, p.name + ' — ' + p.slug,
policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies'), policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies'),
['Slug', 'Name', 'Duration', 'Grace', 'Seats', 'Trial', 'Entitlements', 'Licenses', 'Status', 'On buy page', ''], ['Slug', 'Name', 'Duration', 'Grace', 'Seats', 'Trial', 'Entitlements', 'Licenses', 'Status', 'On buy page', ''],
rows, rows,
'(no policies yet)' '(no policies yet)',
previewBtn,
)) ))
} catch (e) { } catch (e) {
target.appendChild(plainCard([err('Failed to load policies for ' + p.slug + ': ' + e.message)])) target.appendChild(plainCard([err('Failed to load policies for ' + p.slug + ': ' + e.message)]))
@@ -2555,7 +2609,16 @@ The request will be refused if there are licenses or invoices tied to it — use
? el('code', { title: l.product_id }, l.product_slug) ? el('code', { title: l.product_id }, l.product_slug)
: shortId(l.product_id)), : shortId(l.product_id)),
el('td', null, l.policy_slug el('td', null, l.policy_slug
? el('span', { title: l.policy_name || l.policy_id || '' }, l.policy_slug) // Display name primary, slug secondary (smaller + muted)
// so operators see what the buyer sees ("Pro") without
// losing the technical identifier they need for SDK calls.
? el('div', null, [
el('div', { style: 'font-weight:500; color:var(--navy-950)' },
l.policy_name || l.policy_slug),
l.policy_name
? el('div', { class: 'muted', style: 'font-size:11.5px; font-family:var(--font-mono)' }, l.policy_slug)
: null,
])
: el('span', { class: 'muted' }, '')), : el('span', { class: 'muted' }, '')),
el('td', null, entitlementsCell(l.entitlements)), el('td', null, entitlementsCell(l.entitlements)),
el('td', null, statusBadge(l.status)), el('td', null, statusBadge(l.status)),
@@ -3047,7 +3110,15 @@ The request will be refused if there are licenses or invoices tied to it — use
const id = 'f_' + name + '_' + Math.random().toString(36).slice(2, 6) const id = 'f_' + name + '_' + Math.random().toString(36).slice(2, 6)
const lbl = el('label', { class: 'lbl', for: id }, [label, opts.required ? el('span', { class: 'req' }, '*') : null]) const lbl = el('label', { class: 'lbl', for: id }, [label, opts.required ? el('span', { class: 'req' }, '*') : null])
const sel = el('select', { class: 'select', id, name }) const sel = el('select', { class: 'select', id, name })
for (const o of options) sel.appendChild(el('option', { value: o.value }, o.label)) for (const o of options) {
// Per-option `disabled: true` lets callers grey-out specific
// entries — e.g. the Change Tier dropdown marks the current
// tier as disabled with "(current)" so operators see what
// they're starting from but can't pick a no-op.
const attrs = { value: o.value }
if (o.disabled) attrs.disabled = 'disabled'
sel.appendChild(el('option', attrs, o.label))
}
if (opts.value) sel.value = opts.value if (opts.value) sel.value = opts.value
return el('div', { class: 'field' }, [lbl, sel]) return el('div', { class: 'field' }, [lbl, sel])
} }
+95 -26
View File
@@ -1,11 +1,15 @@
// Switch the active payment provider WITHOUT re-running Connect. // Switch the active payment provider WITHOUT re-running Connect.
// Use case: operator has both BTCPay and Zaprite configured (i.e., // Use case: operator has both BTCPay and Zaprite configured (i.e.,
// they ran Connect on both at some point) and wants to flip which // they ran Connect on both at some point) and wants to flip which
// one currently handles purchases. Two convenience actions — // one currently handles purchases.
// "Activate BTCPay" / "Activate Zaprite" — each POSTs to the
// daemon's /v1/admin/payment-provider/activate endpoint.
// //
// If the named provider isn't yet configured, the daemon returns // One unified "Switch active payment provider" action with a
// dropdown — replaces the two earlier "Activate BTCPay" / "Activate
// Zaprite" actions, which were confusing because they appeared
// alongside Connect/Disconnect/Status and operators couldn't tell
// at a glance which one was currently active.
//
// If the chosen provider isn't yet configured, the daemon returns
// 400 with a "Run Connect first" message; we surface that to the // 400 with a "Run Connect first" message; we surface that to the
// operator unchanged. // operator unchanged.
@@ -13,6 +17,24 @@ import { sdk } from '../sdk'
import { store } from '../fileModels/store' import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils' import { adminCall, LICENSING_URL } from '../utils'
const { InputSpec, Value } = sdk
const switchInput = InputSpec.of({
provider: Value.select({
name: 'Active provider',
description:
'Which connected payment provider should handle new purchases. ' +
'The other provider stays configured (no need to re-run Connect ' +
'if you switch back). Existing license keys are unaffected.',
required: true,
default: 'btcpay',
values: {
btcpay: 'BTCPay',
zaprite: 'Zaprite',
},
}),
})
async function activate(provider: 'btcpay' | 'zaprite') { async function activate(provider: 'btcpay' | 'zaprite') {
const storeData = await store.read().once() const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.') if (!storeData) throw new Error('Store not initialized — restart the service.')
@@ -32,29 +54,81 @@ async function activate(provider: 'btcpay' | 'zaprite') {
return body return body
} }
/** Unified switch — replaces the two single-purpose Activate actions. */
export const switchPaymentProvider = sdk.Action.withInput(
'switch-payment-provider',
async () => ({
name: 'Switch active payment provider',
description:
'Flip which connected payment provider handles new purchases ' +
'(BTCPay vs Zaprite). Use only when both are already configured. ' +
"Existing license keys aren't affected by the swap.",
warning: null,
allowedStatuses: 'only-running',
group: 'Payment provider',
visibility: 'enabled',
}),
switchInput,
async ({ effects: _effects }) => {
// Pre-fill from current active provider so the operator can
// see what's set and only need to click if they want to change.
const storeData = await store.read().once()
if (!storeData) return { provider: 'btcpay' as const }
try {
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/payment-provider/status',
{ method: 'GET' },
)
if (resp.ok) {
const body = (await resp.json()) as { active?: string }
if (body.active === 'zaprite') return { provider: 'zaprite' as const }
}
} catch {
// Status read failure shouldn't block the action.
}
return { provider: 'btcpay' as const }
},
async ({ effects: _effects, input }) => {
const body = await activate(input.provider)
const other = input.provider === 'btcpay' ? 'Zaprite' : 'BTCPay'
const label = input.provider === 'btcpay' ? 'BTCPay' : 'Zaprite'
return {
version: '1',
title: `${label} is now the active provider`,
message:
`Active payment provider is now ${body.active}. New purchases ` +
`route through ${label}. ${other} remains configured but ` +
`inactive until you switch again or disconnect it.`,
result: null,
}
},
)
// Legacy single-purpose actions retained as deprecated shims so any
// operator scripts/links pointing at the old action ids still work
// after upgrade. The unified switchPaymentProvider above is the
// recommended path. Operators see only the new action in the StartOS
// UI (these aren't registered in actions/index.ts after this change).
export const activateBtcpay = sdk.Action.withoutInput( export const activateBtcpay = sdk.Action.withoutInput(
'activate-btcpay', 'activate-btcpay',
async () => ({ async () => ({
name: 'Activate BTCPay', name: 'Activate BTCPay (legacy)',
description: description:
'Switch the active payment provider to BTCPay. Use this if both ' + 'Deprecated — use "Switch active payment provider" instead. ' +
'BTCPay and Zaprite are already connected and you want to flip ' + 'Kept for backward compatibility with old scripts.',
"which one handles new purchases. Existing license keys aren't " +
'affected by the swap.',
warning: null, warning: null,
allowedStatuses: 'only-running', allowedStatuses: 'only-running',
group: 'BTCPay', group: 'Payment provider',
visibility: 'enabled', visibility: 'hidden',
}), }),
async () => { async () => {
const body = await activate('btcpay') const body = await activate('btcpay')
return { return {
version: '1', version: '1',
title: 'BTCPay is now the active provider', title: 'BTCPay is now the active provider',
message: message: `Active payment provider is now ${body.active}.`,
`Active payment provider is now ${body.active}. New purchases ` +
`route through BTCPay. Zaprite remains configured but inactive ` +
`until you run "Activate Zaprite" or "Disconnect Zaprite".`,
result: null, result: null,
} }
}, },
@@ -63,26 +137,21 @@ export const activateBtcpay = sdk.Action.withoutInput(
export const activateZaprite = sdk.Action.withoutInput( export const activateZaprite = sdk.Action.withoutInput(
'activate-zaprite', 'activate-zaprite',
async () => ({ async () => ({
name: 'Activate Zaprite', name: 'Activate Zaprite (legacy)',
description: description:
'Switch the active payment provider to Zaprite. Use this if both ' + 'Deprecated — use "Switch active payment provider" instead. ' +
'BTCPay and Zaprite are already connected and you want to flip ' + 'Kept for backward compatibility with old scripts.',
"which one handles new purchases. Existing license keys aren't " +
'affected by the swap.',
warning: null, warning: null,
allowedStatuses: 'only-running', allowedStatuses: 'only-running',
group: 'Zaprite', group: 'Payment provider',
visibility: 'enabled', visibility: 'hidden',
}), }),
async () => { async () => {
const body = await activate('zaprite') const body = await activate('zaprite')
return { return {
version: '1', version: '1',
title: 'Zaprite is now the active provider', title: 'Zaprite is now the active provider',
message: message: `Active payment provider is now ${body.active}.`,
`Active payment provider is now ${body.active}. New purchases ` +
`route through Zaprite. BTCPay remains configured but inactive ` +
`until you run "Activate BTCPay" or "Disconnect BTCPay".`,
result: null, result: null,
} }
}, },
+5 -3
View File
@@ -20,7 +20,7 @@
import { sdk } from '../sdk' import { sdk } from '../sdk'
import { activateLicense, showLicenseStatus } from './activateLicense' import { activateLicense, showLicenseStatus } from './activateLicense'
import { activateBtcpay, activateZaprite } from './activatePaymentProvider' import { switchPaymentProvider } from './activatePaymentProvider'
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay' import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
import { import {
configureZaprite, configureZaprite,
@@ -39,14 +39,16 @@ export const actions = sdk.Actions.of()
// BTCPay setup (Bitcoin-only payments via your own BTCPay Server) // BTCPay setup (Bitcoin-only payments via your own BTCPay Server)
.addAction(configureBtcpay) .addAction(configureBtcpay)
.addAction(btcpayStatus) .addAction(btcpayStatus)
.addAction(activateBtcpay)
.addAction(disconnectBtcpay) .addAction(disconnectBtcpay)
// Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker) // Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker)
.addAction(configureZaprite) .addAction(configureZaprite)
.addAction(zapriteStatus) .addAction(zapriteStatus)
.addAction(showZapriteWebhookSetup) .addAction(showZapriteWebhookSetup)
.addAction(activateZaprite)
.addAction(disconnectZaprite) .addAction(disconnectZaprite)
// Single unified switch action — flips active provider via a
// dropdown so operators don't see two confusing "Activate X"
// actions side-by-side, each appearing to override the other.
.addAction(switchPaymentProvider)
// Keysat self-license (Keysat-licenses-Keysat) // Keysat self-license (Keysat-licenses-Keysat)
.addAction(activateLicense) .addAction(activateLicense)
.addAction(showLicenseStatus) .addAction(showLicenseStatus)