From 927ac2be538bf7f895649b1f2bfbad11610a5b1f Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 9 May 2026 14:02:20 -0500 Subject: [PATCH] =?UTF-8?q?UX=20polish=20=E2=80=94=20duration,=20preview?= =?UTF-8?q?=20button,=20Select=20state,=20dropdown=20current,=20switch=20a?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- licensing-service/src/api/buy_page.rs | 10 +- licensing-service/web/index.html | 99 ++++++++++++++--- startos/actions/activatePaymentProvider.ts | 121 ++++++++++++++++----- startos/actions/index.ts | 8 +- 4 files changed, 193 insertions(+), 45 deletions(-) diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index 2c70fd9..ffbdb85 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -603,10 +603,16 @@ footer.kfooter a:hover {{ color:var(--navy-900); }} function selectTier(slug) {{ if (!TIERS[slug]) return; 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) {{ - 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'); + 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 // honor the same code (server validates again on the next Apply). diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 791a9a2..69029ce 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -701,10 +701,13 @@ The request will be refused if there are licenses or invoices tied to it — use function plainCard(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' }, [ el('h3', null, title), 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) { 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() { policiesHolder.innerHTML = '' - const opts = allPolicies - .filter((p) => p.slug !== currentPolicySlug) - .map((p) => ({ + // Show ALL policies but mark the current one as disabled with + // "(current)" suffix — operator sees what they're starting from + // 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, + disabled: isCurrent, label: p.name + ' (' + p.slug + ')' + (p.tier_rank != null ? ' · rank ' + p.tier_rank : '') + (p.is_recurring ? ' · recurring' : '') + - (p.is_trial ? ' · trial' : ''), - })) - if (opts.length === 0) { + (p.is_trial ? ' · trial' : '') + + (isCurrent ? ' · current' : ''), + } + }) + const selectableOpts = opts.filter((o) => !o.disabled) + if (selectableOpts.length === 0) { policiesHolder.appendChild(plainCard([ el('p', { class: 'muted', style: 'margin:0' }, 'No other policies on this product. Create one first under Policies → ' + (license.product_slug || '') + '.'), @@ -1394,8 +1405,8 @@ The request will be refused if there are licenses or invoices tied to it — use selectedTargetSlug = selEl.value runQuote() }) - // Auto-pick first option + run quote. - selectedTargetSlug = opts[0].value + // Auto-pick first SELECTABLE option (skip the disabled current-tier). + selectedTargetSlug = selectableOpts[0].value selEl.value = selectedTargetSlug 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 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) { try { 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, [ el('td', null, el('code', null, pol.slug)), 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, pol.grace_seconds + 's'), + el('td', null, fmtDuration(pol.duration_seconds)), + el('td', null, fmtGrace(pol.grace_seconds)), el('td', null, pol.max_machines === 0 ? '∞' : String(pol.max_machines)), el('td', null, // 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'), ])), ])) + // 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( p.name + ' — ' + p.slug, policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies'), ['Slug', 'Name', 'Duration', 'Grace', 'Seats', 'Trial', 'Entitlements', 'Licenses', 'Status', 'On buy page', ''], rows, - '(no policies yet)' + '(no policies yet)', + previewBtn, )) } catch (e) { 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) : shortId(l.product_id)), 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('td', null, entitlementsCell(l.entitlements)), 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 lbl = el('label', { class: 'lbl', for: id }, [label, opts.required ? el('span', { class: 'req' }, '*') : null]) 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 return el('div', { class: 'field' }, [lbl, sel]) } diff --git a/startos/actions/activatePaymentProvider.ts b/startos/actions/activatePaymentProvider.ts index eb2b70e..726479d 100644 --- a/startos/actions/activatePaymentProvider.ts +++ b/startos/actions/activatePaymentProvider.ts @@ -1,11 +1,15 @@ // Switch the active payment provider WITHOUT re-running Connect. // Use case: operator has both BTCPay and Zaprite configured (i.e., // they ran Connect on both at some point) and wants to flip which -// one currently handles purchases. Two convenience actions — -// "Activate BTCPay" / "Activate Zaprite" — each POSTs to the -// daemon's /v1/admin/payment-provider/activate endpoint. +// one currently handles purchases. // -// 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 // operator unchanged. @@ -13,6 +17,24 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store' 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') { const storeData = await store.read().once() if (!storeData) throw new Error('Store not initialized — restart the service.') @@ -32,29 +54,81 @@ async function activate(provider: 'btcpay' | 'zaprite') { 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( 'activate-btcpay', async () => ({ - name: 'Activate BTCPay', + name: 'Activate BTCPay (legacy)', description: - 'Switch the active payment provider to BTCPay. Use this if both ' + - 'BTCPay and Zaprite are already connected and you want to flip ' + - "which one handles new purchases. Existing license keys aren't " + - 'affected by the swap.', + 'Deprecated — use "Switch active payment provider" instead. ' + + 'Kept for backward compatibility with old scripts.', warning: null, allowedStatuses: 'only-running', - group: 'BTCPay', - visibility: 'enabled', + group: 'Payment provider', + visibility: 'hidden', }), async () => { const body = await activate('btcpay') return { version: '1', title: 'BTCPay is now the active provider', - message: - `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".`, + message: `Active payment provider is now ${body.active}.`, result: null, } }, @@ -63,26 +137,21 @@ export const activateBtcpay = sdk.Action.withoutInput( export const activateZaprite = sdk.Action.withoutInput( 'activate-zaprite', async () => ({ - name: 'Activate Zaprite', + name: 'Activate Zaprite (legacy)', description: - 'Switch the active payment provider to Zaprite. Use this if both ' + - 'BTCPay and Zaprite are already connected and you want to flip ' + - "which one handles new purchases. Existing license keys aren't " + - 'affected by the swap.', + 'Deprecated — use "Switch active payment provider" instead. ' + + 'Kept for backward compatibility with old scripts.', warning: null, allowedStatuses: 'only-running', - group: 'Zaprite', - visibility: 'enabled', + group: 'Payment provider', + visibility: 'hidden', }), async () => { const body = await activate('zaprite') return { version: '1', title: 'Zaprite is now the active provider', - message: - `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".`, + message: `Active payment provider is now ${body.active}.`, result: null, } }, diff --git a/startos/actions/index.ts b/startos/actions/index.ts index 794b66c..0caf81b 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -20,7 +20,7 @@ import { sdk } from '../sdk' import { activateLicense, showLicenseStatus } from './activateLicense' -import { activateBtcpay, activateZaprite } from './activatePaymentProvider' +import { switchPaymentProvider } from './activatePaymentProvider' import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay' import { configureZaprite, @@ -39,14 +39,16 @@ export const actions = sdk.Actions.of() // BTCPay setup (Bitcoin-only payments via your own BTCPay Server) .addAction(configureBtcpay) .addAction(btcpayStatus) - .addAction(activateBtcpay) .addAction(disconnectBtcpay) // Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker) .addAction(configureZaprite) .addAction(zapriteStatus) .addAction(showZapriteWebhookSetup) - .addAction(activateZaprite) .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) .addAction(activateLicense) .addAction(showLicenseStatus)