diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html
index 356c039..d4a000a 100644
--- a/licensing-service/web/index.html
+++ b/licensing-service/web/index.html
@@ -3656,6 +3656,45 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
return ''
}
+ // Pre-fetch products so the create form's scope-picker dropdowns
+ // can populate. The codes table render below reuses the same fetch
+ // (`productsForCreate`) — see the Promise.all in the table block.
+ const productsForCreate = (await api('/v1/products').catch(() => ({ products: [] }))).products || []
+
+ // Product picker: "Any product" + one option per product.
+ const productOptions = [{ value: '', label: '— Any product —' }]
+ productsForCreate.forEach((p) => productOptions.push({ value: p.slug, label: p.name }))
+ const productScopeField = formSelect('product_slug', 'Restrict to product (optional)',
+ productOptions, { value: '' })
+
+ // Policy picker: populated on-demand when a product is selected.
+ // Disabled when "Any product" is selected (a policy-scoped code
+ // requires a product scope per the server contract).
+ const policyScopeField = formSelect('policy_slug', 'Restrict to policy (optional)',
+ [{ value: '', label: '— Any policy —' }], { value: '' })
+ const policySel = policyScopeField.querySelector('select')
+ policySel.disabled = true
+
+ productScopeField.querySelector('select').addEventListener('change', async (e) => {
+ const slug = e.target.value
+ policySel.innerHTML = ''
+ policySel.appendChild(el('option', { value: '' }, '— Any policy —'))
+ if (!slug) {
+ policySel.disabled = true
+ return
+ }
+ policySel.disabled = false
+ try {
+ const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(slug) +
+ '&include_inactive=true')
+ const pols = r.policies || []
+ pols.forEach((p) => policySel.appendChild(el('option', { value: p.slug }, p.name)))
+ } catch (_) {
+ // Silent: operator can still create a product-scoped code
+ // without a policy if the policy fetch fails.
+ }
+ })
+
const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new code'),
el('div', { class: 'body' }, [
@@ -3676,13 +3715,14 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
{ value: 'EUR', label: 'EUR (€)' },
], { value: 'SAT', hint: 'matters for "fixed amount off" and "set flat price" — ignored for percent / free.' }),
]),
+ // Scope: product picker + dependent policy picker, side-by-side.
+ // Both default to "Any" — a code with no scope applies to every
+ // product on this instance.
+ el('div', { class: 'row-2' }, [productScopeField, policyScopeField]),
+ // Limits: max-uses + expires-at on the same row.
el('div', { class: 'row-2' }, [
formInput('max_uses', 'Max uses (0 = unlimited)', { type: 'number', value: '0' }),
- el('div'), // spacer to keep the row balanced
- ]),
- el('div', { class: 'row-2' }, [
- formInput('expires_at', 'Expires at (RFC3339, optional)', { hint: 'e.g. 2026-12-31T23:59:59Z' }),
- formInput('product_slug', 'Restrict to product slug (optional)'),
+ formInput('expires_at', 'Expires at', { type: 'datetime-local', hint: 'Leave blank for no expiry.' }),
]),
formInput('referrer_label', 'Referrer / campaign label (optional)'),
formInput('description', 'Description (internal note)', { textarea: true }),
@@ -3701,10 +3741,6 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const kind = create.querySelector('[name=kind]').value
const currency = create.querySelector('[name=discount_currency]').value
const rawAmount = parseFloat(create.querySelector('[name=amount]').value) || 0
- // For percent: stored as basis points (50% → 5000).
- // For SAT-currency fixed/set: stored as sats (whole number).
- // For USD/EUR fixed/set: stored as cents (1.00 main unit → 100).
- // Free license: amount ignored (we send 0).
let amount
if (kind === 'percent') amount = Math.round(rawAmount * 100)
else if (kind === 'free_license') amount = 0
@@ -3718,10 +3754,19 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0
if (mu > 0) body.max_uses = mu
- const exp = create.querySelector('[name=expires_at]').value.trim()
- if (exp) body.expires_at = exp
+ // datetime-local returns "2026-12-31T23:59" (local time, no
+ // seconds, no timezone). Convert to RFC3339 by appending
+ // ":00Z" — that pins it to UTC. Operators expecting local
+ // time should use a future picker upgrade; for now this
+ // matches the server-side parser (chrono RFC3339).
+ const expRaw = create.querySelector('[name=expires_at]').value.trim()
+ if (expRaw) {
+ body.expires_at = expRaw.length === 16 ? (expRaw + ':00Z') : expRaw
+ }
const ps = create.querySelector('[name=product_slug]').value.trim()
if (ps) body.product_slug = ps
+ const pol = create.querySelector('[name=policy_slug]').value.trim()
+ if (pol) body.policy_slug = pol
const rl = create.querySelector('[name=referrer_label]').value.trim()
if (rl) body.referrer_label = rl
const featured = create.querySelector('[name=featured]').checked
@@ -3730,7 +3775,6 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.codes, 600)
} catch (e) {
- // Tier-cap 402 → upgrade modal; everything else → inline status pill.
if (handleTierCap(e)) status.remove()
else status.replaceWith(err(e.message))
}
@@ -3765,9 +3809,32 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const editPanel = el('div', { id: 'edit-code-panel', style: 'display:none; margin:16px 0;' })
target.appendChild(editPanel)
- function openEdit(c) {
+ async function openEdit(c) {
editPanel.innerHTML = ''
editPanel.style.display = 'block'
+
+ // Resolve scope names (read-only display — scope can't be edited).
+ // Product name comes from the products list we already fetched.
+ // Policy name requires fetching the product's policies, done
+ // lazily here when an actual policy_id is bound.
+ let scopeLabel = 'Applies to: all products on this instance'
+ if (c.applies_to_product_id) {
+ const prod = productsForCreate.find((p) => p.id === c.applies_to_product_id)
+ const productName = prod ? prod.name : c.applies_to_product_id
+ if (c.applies_to_policy_id && prod) {
+ let policyName = c.applies_to_policy_id
+ try {
+ const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(prod.slug) +
+ '&include_inactive=true')
+ const pol = (r.policies || []).find((p) => p.id === c.applies_to_policy_id)
+ if (pol) policyName = pol.name + ' (' + pol.slug + ')'
+ } catch (_) {}
+ scopeLabel = 'Applies to: ' + productName + ' → ' + policyName
+ } else {
+ scopeLabel = 'Applies to: ' + productName + ' (any policy)'
+ }
+ }
+
const amtField = formInput('e_amount', 'Amount', {
type: 'number',
value: c.kind === 'percent' ? String(c.amount / 100) : String(c.amount),
@@ -3780,8 +3847,21 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
value: c.max_uses == null ? '0' : String(c.max_uses),
hint: c.used_count > 0 ? 'cannot go below current used_count (' + c.used_count + ').' : null,
})
- const expField = formInput('e_expires_at', 'Expires at (RFC3339, blank to clear)', {
- value: c.expires_at || '',
+ // Convert RFC3339 → datetime-local format (YYYY-MM-DDTHH:MM) so
+ // the native picker can render. On submit we reverse: append
+ // ":00Z" for UTC. Empty stays empty (= clear the field).
+ const expValueInit = (() => {
+ if (!c.expires_at) return ''
+ try {
+ const d = new Date(c.expires_at)
+ if (Number.isNaN(d.getTime())) return ''
+ return d.toISOString().slice(0, 16) // strip seconds + Z
+ } catch (_) { return '' }
+ })()
+ const expField = formInput('e_expires_at', 'Expires at', {
+ type: 'datetime-local',
+ value: expValueInit,
+ hint: 'Blank to clear.',
})
const refField = formInput('e_referrer_label', 'Referrer / campaign label (blank to clear)', {
value: c.referrer_label || '',
@@ -3820,8 +3900,11 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
const muRaw = parseInt(editPanel.querySelector('[name=e_max_uses]').value, 10) || 0
body.max_uses = muRaw > 0 ? muRaw : null
+ // datetime-local → RFC3339 by appending ":00Z" for UTC.
+ // Empty → null (clear the field).
const expRaw = editPanel.querySelector('[name=e_expires_at]').value.trim()
- body.expires_at = expRaw === '' ? null : expRaw
+ body.expires_at = expRaw === '' ? null
+ : (expRaw.length === 16 ? (expRaw + ':00Z') : expRaw)
const refRaw = editPanel.querySelector('[name=e_referrer_label]').value.trim()
body.referrer_label = refRaw === '' ? null : refRaw
body.description = editPanel.querySelector('[name=e_description]').value || ''
@@ -3840,12 +3923,21 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}, 'Cancel')
editPanel.appendChild(plainCard([
- el('div', { style: 'display:flex; align-items:baseline; justify-content:space-between; margin-bottom:12px' }, [
+ el('div', { style: 'display:flex; align-items:baseline; justify-content:space-between; margin-bottom:8px' }, [
el('strong', null, 'Editing code '),
el('code', { style: 'font-size:14px' }, c.code),
]),
+ // Read-only scope display — the data the operator most often
+ // needs to see when reviewing a code. Distinct from the
+ // editable fields below.
+ el('div', {
+ style:
+ 'padding:8px 12px; margin-bottom:12px; ' +
+ 'background:var(--cream-100); border-radius:6px; ' +
+ 'font-size:12.5px; color:var(--ink-700);',
+ }, scopeLabel),
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
- 'Editable: amount, max uses, expiry, referrer label, description. The code string, kind, and product/policy scope cannot be changed — disable + create a new code instead.'),
+ 'Editable: amount, max uses, expiry, referrer label, description, featured flag. The code string, kind, and product/policy scope cannot be changed — disable + create a new code instead.'),
el('div', { class: 'row-2' }, [amtField, muField]),
el('div', { class: 'row-2' }, [expField, refField]),
descField,
@@ -3856,12 +3948,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
try {
- // Fetch products + codes in parallel so we can group codes by product.
- const [productsResp, codesResp] = await Promise.all([
- api('/v1/products').catch(() => ({ products: [] })),
- api('/v1/admin/discount-codes?include_inactive=true'),
- ])
- const products = productsResp.products || []
+ // Reuse the products list we fetched above for the create form's
+ // scope pickers — no need to re-fetch.
+ const products = productsForCreate
+ const codesResp = await api('/v1/admin/discount-codes?include_inactive=true')
const codes = codesResp.codes || []
const productById = {}
products.forEach((p) => { productById[p.id] = p })
diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts
index 4367878..5e5c728 100644
--- a/startos/versions/v0.2.0.ts
+++ b/startos/versions/v0.2.0.ts
@@ -58,6 +58,18 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
+ '0.2.0:17 — **Discount Codes form usability.** Three concrete improvements to make the Discount Codes tab less type-heavy and more discoverable.',
+ '',
+ '**Product + policy scope as dropdowns, not free-text.** The create form previously had a "Restrict to product slug (optional)" text input that required operators to remember slugs exactly. Now it\'s a dropdown populated from the product list — pick "Any product" or any specific product. A second dependent dropdown appears for "Restrict to policy" once a product is chosen; it loads that product\'s policies on the fly. Both default to "Any" so the previous "no scope = global" behavior is preserved.',
+ '',
+ '**Datetime picker on expires_at.** The "Expires at (RFC3339)" text input is now a native `datetime-local` picker on both create and edit forms. Operators get a calendar + time spinner UI instead of having to hand-type `2026-12-31T23:59:59Z`. Submit converts back to RFC3339 (UTC) automatically. Empty stays empty (no expiry).',
+ '',
+ '**Edit form shows current scope read-only.** Previously the Edit modal didn\'t surface which product or policy a code was scoped to — operators reviewing a code couldn\'t tell at a glance. Now the top of the edit form shows "Applies to: [product] → [policy]" (or "Applies to: all products on this instance" for global codes) as a muted info block. Scope is still immutable (disable + create new to change), but at least it\'s visible.',
+ '',
+ '**Test count: 87** (unchanged — UI-only release).',
+ '',
+ '**Upgrade path.** v0.2.0:16 → v0.2.0:17 is a drop-in. No schema, no SDK, no behavior change for buyers. The admin form fields persist the same way they always did (`product_slug`, `policy_slug` strings sent to the existing endpoints).',
+ '',
'0.2.0:16 — **Launch-special discount codes + marketing bullets + discount codes per-product UI.** Operators can now run public promotional discounts that auto-apply on the buy page, plus author marketing-copy bullets on tiers that don\'t map to real entitlements.',
'',
'**Launch-special (featured) discount codes (migration 0017).** Flag a discount code as `featured` and three things happen automatically: (1) the buy page renders a diagonal "LAUNCH SPECIAL" gold ribbon on every tier the code applies to; (2) the original price is struck through and replaced with the discounted price; (3) the purchase endpoint auto-applies the discount for buyers who don\'t type any code. Operator-typed codes still win — a buyer who pastes a different code in the form gets that code instead. When a featured code exhausts its `max_uses` cap (e.g. "first 100 buyers"), the ribbon disappears automatically and pricing reverts to standard. Expiry dates work the same way. New repo helper `find_applicable_featured_discount` picks the most specific match (policy > product > global) with operator priority by created_at.',
@@ -314,7 +326,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
- version: '0.2.0:16',
+ version: '0.2.0:17',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under