diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index cf2aab5..22e9c15 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -2485,17 +2485,33 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } style: 'font-size:12px; align-self:center', }, isSat ? 'sats' : product.price_currency) - // Duration preset + // Duration preset + custom-days fallback for arbitrary durations. + // Selecting "Custom (days)" reveals a number input; on submit, the + // raw days value is multiplied to seconds before sending. Matches + // the Edit-policy modal which has the same pattern (but in raw + // seconds — days is friendlier for the draft create flow since + // common cadences are day-based). const DURATION_PRESETS = [ { value: '0', label: 'Perpetual' }, { value: '604800', label: '7 days' }, { value: '2592000', label: '30 days' }, { value: '7776000', label: '90 days' }, { value: '31536000', label: '1 year' }, + { value: 'custom', label: 'Custom (days)' }, ] const durationSel = el('select', { class: 'select' }) DURATION_PRESETS.forEach((p) => durationSel.appendChild(el('option', { value: p.value }, p.label))) durationSel.value = '0' + const customDaysInput = el('input', { + class: 'input', type: 'number', min: '1', value: '14', + placeholder: 'days', + }) + const customDaysWrap = el('div', { + style: 'display:none; margin-top:6px', + }, [customDaysInput]) + durationSel.addEventListener('change', () => { + customDaysWrap.style.display = durationSel.value === 'custom' ? 'block' : 'none' + }) const maxMachinesInput = el('input', { class: 'input', type: 'number', min: '0', value: '1', @@ -2552,7 +2568,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } fieldRow('Slug', 'Stable id used by SDK; auto-fills from name. Lowercase, digits, hyphens.', slugInput), fieldRow('Price', 'Override for this tier. Pre-filled with product base price.', el('div', { style: 'display:flex; gap:6px' }, [priceInput, priceUnit])), - fieldRow('Duration', 'How long the issued license is valid. Perpetual = no expiry.', durationSel), + fieldRow('Duration', 'How long the issued license is valid. Perpetual = no expiry. Pick "Custom (days)" to enter an arbitrary number.', el('div', null, [durationSel, customDaysWrap])), fieldRow('Max devices', '1 = single seat; 0 = unlimited; n = n-seat.', maxMachinesInput), fieldRow('Entitlements', cat.length > 0 ? 'Click to toggle. Defined on the product\'s catalog.' @@ -2592,11 +2608,14 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } btn.disabled = true try { const isRecurring = recurringCb.checked + const durationSeconds = durationSel.value === 'custom' + ? Math.max(1, parseInt(customDaysInput.value, 10) || 0) * 86400 + : parseInt(durationSel.value, 10) || 0 const body = { product_slug: product.slug, slug: slugInput.value.trim(), name: nameInput.value.trim(), - duration_seconds: parseInt(durationSel.value, 10) || 0, + duration_seconds: durationSeconds, grace_seconds: 0, max_machines: parseInt(maxMachinesInput.value, 10), is_trial: false, @@ -2612,8 +2631,8 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } body.grace_period_days = 7 body.trial_days = parseInt(trialDaysInput.value, 10) || 0 } - await api('/v1/admin/policies', { method: 'POST', body }) - onCommit && onCommit() + const saved = await api('/v1/admin/policies', { method: 'POST', body }) + onCommit && onCommit(saved) } catch (e) { if (handleTierCap(e)) { status.textContent = '' @@ -2695,11 +2714,26 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } // operator can keep clicking "+ Add tier" to author multiple // policies side-by-side. Named recursive function so each new // placeholder reuses the same handler. + // + // On commit of any draft, the saved policy is passed back and we + // replace ONLY that draft's slot with a finalized tier card — + // other drafts (if the operator is authoring multiple in + // parallel) keep their in-progress input state untouched. The + // grid does NOT reload via onMutate() on commit, because a full + // reload would wipe sibling drafts. function makePlaceholder() { const placeholder = renderAddTierCard(() => { const draft = renderDraftTierCard( product, - () => onMutate && onMutate(), // commit → reload (rebuilds grid) + (savedPolicy) => { + savedPolicy._license_count = 0 + const newCard = renderTierCard(savedPolicy, product, onMutate) + if (!savedPolicy.archived_at) { + newCard.draggable = true + newCard.dataset.policyId = savedPolicy.id + } + grid.replaceChild(newCard, draft) + }, () => grid.replaceChild(makePlaceholder(), draft), // cancel → swap back ) grid.replaceChild(draft, placeholder) diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 0b64428..b9f566d 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,16 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. const ROUTINE_NOTES = [ + '0.2.0:15 — **Multi-draft tier authoring + custom durations on draft cards.** Small admin-UI release that fixes two papercuts from authoring fresh policies side-by-side.', + '', + '**Multi-draft survival.** Previously, committing one draft tier (clicking Create on a side-by-side draft card) reloaded the whole Policies tab — which wiped any other drafts the operator had open. Now the commit replaces ONLY that draft\'s grid slot with a finalized tier card; sibling drafts keep their in-progress input state untouched. Author Creator, Pro, Patron in parallel and click Create on each as it\'s ready, in any order.', + '', + '**Custom duration on draft cards.** The Duration dropdown on the draft tier card now has a "Custom (days)" option at the bottom. Selecting it reveals a number input; on submit, days × 86400 seconds is what gets sent. Matches the same pattern the Edit-policy modal has had (in raw seconds) — bringing it to the create flow so operators don\'t have to "create then immediately edit" for non-standard durations.', + '', + '**Test count: 87** (UI-only release).', + '', + '**Upgrade path.** v0.2.0:14 → v0.2.0:15 is a drop-in. No schema, no SDK, no behavior change for buyers.', + '', '0.2.0:14 — **Product entitlements catalog bug fix + drag-and-drop tier ordering.** One real bug fix that was silently breaking operator workflows, plus a UX rework of how tier ranks get set.', '', '**Bug fix: product entitlements catalog reads.** Every SELECT against the `products` table in repo.rs was missing the `entitlements_catalog_json` column. The PATCH handler wrote the catalog correctly, but every read returned it as null — so admin UI edits silently appeared to drop on the floor (operator adds entitlements, clicks Save, re-opens the editor, entitlements are gone). The data was always in the DB; only the API was blind to it. Now all four product SELECTs include the column. Net effect: catalog edits persist correctly; the bubble-picker on policy create / edit forms populates from the parent product\'s catalog.', @@ -290,7 +300,7 @@ const ROUTINE_NOTES = [ ].join('\n\n') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:14', + version: '0.2.0:15', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under