v0.2.0:18 — Discount Codes form polish

Three small admin-UI changes that make the create + edit forms less
footgun-prone:

- Max-uses: "Limit total uses" checkbox + dependent number input
  (default 100), replacing the "0 = unlimited" pattern that read like
  "0 uses allowed." Unchecked sends no cap.
- Currency dropdown hides for percent + free_license kinds (neither
  has a currency). Stays for fixed_amount.
- Featured flag promoted from buried checkbox to a prominent gold
  pill toggle. Edit form starts in correct state.

UI-only; no schema, no SDK, no behavior change for buyers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 13:26:50 -05:00
parent 11cf1808c6
commit bb53d708a1
2 changed files with 220 additions and 46 deletions
+197 -35
View File
@@ -324,6 +324,43 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
.topbar { padding:14px 20px; } .topbar { padding:14px 20px; }
} }
/* Featured (launch special) pill toggle — used on the Discount Codes
create + edit forms. Click anywhere on the pill to flip the
underlying hidden checkbox. Off = muted; on = gold accent. Reads as
"this is a deliberate decision," not a passive checkbox. */
.featured-pill-toggle {
display:inline-flex; align-items:center; gap:10px;
padding:8px 14px;
background:var(--cream-100); color:var(--ink-700);
border:1px solid var(--border-2); border-radius:999px;
font-family:var(--font-body); font-size:13px;
cursor:pointer; transition:all 100ms;
}
.featured-pill-toggle:hover {
background:var(--cream-200); color:var(--navy-900);
}
.featured-pill-toggle > strong {
font-weight:600;
}
.featured-pill-toggle .state {
font-size:11px; font-weight:700; letter-spacing:0.1em;
text-transform:uppercase; padding:2px 8px; border-radius:999px;
background:var(--cream-50); color:var(--ink-500);
border:1px solid var(--border-1);
}
.featured-pill-toggle.on {
background:var(--gold-500); color:var(--navy-950);
border-color:var(--gold-500);
box-shadow:0 2px 6px rgba(191,160,104,0.25);
}
.featured-pill-toggle.on .state {
background:var(--navy-950); color:var(--gold-500);
border-color:var(--navy-950);
}
.featured-pill-toggle.on:hover {
background:var(--gold-400);
}
/* Tier-card drag affordance — cursor signals draggability on hover, /* Tier-card drag affordance — cursor signals draggability on hover,
the dragging card visibly lifts, and the drop-target receives a the dragging card visibly lifts, and the drop-target receives a
subtle outline so the operator sees where the card will land. */ subtle outline so the operator sees where the card will land. */
@@ -3707,33 +3744,96 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
{ value: 'free_license', label: 'Free license (no payment)' }, { value: 'free_license', label: 'Free license (no payment)' },
], { required: true, value: 'percent' }), ], { required: true, value: 'percent' }),
]), ]),
el('div', { class: 'row-2' }, [ // Amount + currency. Currency is hidden when the kind doesn't
formInput('amount', 'Amount', { type: 'number', step: '1', value: '50', hint: amountHint('percent', 'SAT') }), // use it (percent / free_license) — they're currency-agnostic.
formSelect('discount_currency', 'Currency', [ // The kind-change listener below toggles `display` on the
// currency field's wrapper.
(() => {
const amountField = formInput('amount', 'Amount',
{ type: 'number', step: '1', value: '50', hint: amountHint('percent', 'SAT') })
const currencyField = formSelect('discount_currency', 'Currency', [
{ value: 'SAT', label: 'sats' }, { value: 'SAT', label: 'sats' },
{ value: 'USD', label: 'USD ($)' }, { value: 'USD', label: 'USD ($)' },
{ value: 'EUR', label: 'EUR (€)' }, { value: 'EUR', label: 'EUR (€)' },
], { value: 'SAT', hint: 'matters for "fixed amount off" and "set flat price" — ignored for percent / free.' }), ], { value: 'SAT' })
]), // Stash a marker so the kind-change listener can find this row
// and toggle the currency column visibility.
currencyField.setAttribute('data-currency-field', '1')
return el('div', {
class: 'row-2',
'data-amount-row': '1',
}, [amountField, currencyField])
})(),
// Scope: product picker + dependent policy picker, side-by-side. // Scope: product picker + dependent policy picker, side-by-side.
// Both default to "Any" — a code with no scope applies to every // Both default to "Any" — a code with no scope applies to every
// product on this instance. // product on this instance.
el('div', { class: 'row-2' }, [productScopeField, policyScopeField]), el('div', { class: 'row-2' }, [productScopeField, policyScopeField]),
// Limits: max-uses + expires-at on the same row. // Limits: "Limit total uses" checkbox + number input, paired
el('div', { class: 'row-2' }, [ // with the expires-at picker. The number input is hidden when
formInput('max_uses', 'Max uses (0 = unlimited)', { type: 'number', value: '0' }), // the checkbox is off (= unlimited). Clearer than the old
formInput('expires_at', 'Expires at', { type: 'datetime-local', hint: 'Leave blank for no expiry.' }), // "0 = unlimited" sentinel value.
(() => {
const limitCb = el('input', {
type: 'checkbox',
name: 'max_uses_enabled',
id: 'create_max_uses_cb',
})
const limitNum = el('input', {
class: 'input',
type: 'number',
min: '1',
value: '100',
name: 'max_uses',
style: 'display:none; max-width:120px',
})
limitCb.addEventListener('change', () => {
limitNum.style.display = limitCb.checked ? 'inline-block' : 'none'
if (limitCb.checked) limitNum.focus()
})
const limitWrap = el('div', { class: 'field' }, [
el('label', { class: 'lbl', for: 'create_max_uses_cb' }, 'Use cap'),
el('div', {
style: 'display:flex; align-items:center; gap:10px; flex-wrap:wrap',
}, [
el('label', {
for: 'create_max_uses_cb',
style: 'display:flex; align-items:center; gap:6px; cursor:pointer; font-size:13px',
}, [limitCb, 'Limit total uses']),
limitNum,
]), ]),
])
const expiresField = formInput('expires_at', 'Expires at',
{ type: 'datetime-local', hint: 'Leave blank for no expiry.' })
return el('div', { class: 'row-2' }, [limitWrap, expiresField])
})(),
formInput('referrer_label', 'Referrer / campaign label (optional)'), formInput('referrer_label', 'Referrer / campaign label (optional)'),
formInput('description', 'Description (internal note)', { textarea: true }), formInput('description', 'Description (internal note)', { textarea: true }),
el('div', { style: 'display:flex; align-items:flex-start; gap:8px; margin-top:8px' }, [ // Featured pill toggle — more prominent than a checkbox so it
el('input', { type: 'checkbox', name: 'featured', id: 'create_featured_cb', style: 'margin-top:3px' }), // doesn't get missed. Click anywhere on the pill toggles state.
el('label', { for: 'create_featured_cb', style: 'font-size:12.5px; line-height:1.45; cursor:pointer' }, [ // Hidden state still tracked via a checkbox so existing form
// logic (querySelector by name) keeps working.
(() => {
const hiddenCb = el('input', { type: 'checkbox', name: 'featured', style: 'display:none' })
const pill = el('button', {
type: 'button',
class: 'featured-pill-toggle',
onclick: () => {
hiddenCb.checked = !hiddenCb.checked
pill.classList.toggle('on', hiddenCb.checked)
pill.querySelector('[data-state]').textContent = hiddenCb.checked ? 'On' : 'Off'
},
}, [
el('span', null, '★'),
el('strong', null, 'Featured (launch special)'), el('strong', null, 'Featured (launch special)'),
el('span', { class: 'muted' }, el('span', { 'data-state': '1', class: 'state' }, 'Off'),
'— 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.'), ])
]), return el('div', { style: 'margin-top:12px' }, [
]), pill,
hiddenCb,
el('p', { class: 'muted', style: 'margin:6px 0 0; font-size:12.5px; line-height:1.5' },
'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.'),
])
})(),
el('button', { class: 'btn primary', onclick: async function () { el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…') const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
create.querySelector('.body').appendChild(status) create.querySelector('.body').appendChild(status)
@@ -3752,8 +3852,14 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
discount_currency: currency, discount_currency: currency,
description: create.querySelector('[name=description]').value || '', description: create.querySelector('[name=description]').value || '',
} }
// Max uses: only honor the number field if the "Limit total
// uses" checkbox is checked. Otherwise leave body.max_uses
// absent (server treats absent = unlimited).
const limitCb = create.querySelector('[name=max_uses_enabled]')
if (limitCb && limitCb.checked) {
const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0 const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0
if (mu > 0) body.max_uses = mu if (mu > 0) body.max_uses = mu
}
// datetime-local returns "2026-12-31T23:59" (local time, no // datetime-local returns "2026-12-31T23:59" (local time, no
// seconds, no timezone). Convert to RFC3339 by appending // seconds, no timezone). Convert to RFC3339 by appending
// ":00Z" — that pins it to UTC. Operators expecting local // ":00Z" — that pins it to UTC. Operators expecting local
@@ -3784,18 +3890,26 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// Live-update the amount hint as the operator changes Kind or // Live-update the amount hint as the operator changes Kind or
// Currency. Also swap the input's `step` so SAT-currency codes // Currency. Also swap the input's `step` so SAT-currency codes
// are integer-only and USD/EUR can take decimals. // are integer-only and USD/EUR can take decimals. AND hide the
// currency dropdown when the kind doesn't use it (percent is
// currency-agnostic basis points; free_license has no amount).
const kindSelEl = create.querySelector('[name=kind]') const kindSelEl = create.querySelector('[name=kind]')
const curSelEl = create.querySelector('[name=discount_currency]') const curSelEl = create.querySelector('[name=discount_currency]')
const amtInputEl = create.querySelector('[name=amount]') const amtInputEl = create.querySelector('[name=amount]')
const currencyFieldEl = create.querySelector('[data-currency-field]')
function updateHint() { function updateHint() {
const hintEl = amtInputEl.parentElement.querySelector('.hint') const hintEl = amtInputEl.parentElement.querySelector('.hint')
if (hintEl) hintEl.textContent = amountHint(kindSelEl.value, curSelEl.value) if (hintEl) hintEl.textContent = amountHint(kindSelEl.value, curSelEl.value)
// Toggle decimal entry — sats are integer, fiat goes to cents. // Toggle decimal entry — sats are integer, fiat goes to cents.
amtInputEl.step = curSelEl.value === 'SAT' ? '1' : '0.01' amtInputEl.step = curSelEl.value === 'SAT' ? '1' : '0.01'
// Show currency only when the kind actually consumes it.
const usesCurrency = kindSelEl.value === 'fixed_sats' || kindSelEl.value === 'set_price'
if (currencyFieldEl) currencyFieldEl.style.display = usesCurrency ? '' : 'none'
} }
if (kindSelEl) kindSelEl.addEventListener('change', updateHint) if (kindSelEl) kindSelEl.addEventListener('change', updateHint)
if (curSelEl) curSelEl.addEventListener('change', updateHint) if (curSelEl) curSelEl.addEventListener('change', updateHint)
// Initial render: kind defaults to 'percent', so hide currency.
updateHint()
target.appendChild(plainCard([ target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' }, el('p', { class: 'muted', style: 'margin:0 0 16px' },
@@ -3842,11 +3956,45 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
? 'free_license codes have no amount.' ? 'free_license codes have no amount.'
: amountHint(c.kind) + ' (kind: ' + c.kind + ', not editable)', : amountHint(c.kind) + ' (kind: ' + c.kind + ', not editable)',
}) })
const muField = formInput('e_max_uses', 'Max uses (0 = unlimited)', { // Max uses — "Limit total uses" checkbox + dependent number
type: 'number', // input. Same UX as the create form. Pre-populated based on
value: c.max_uses == null ? '0' : String(c.max_uses), // the code's current state.
hint: c.used_count > 0 ? 'cannot go below current used_count (' + c.used_count + ').' : null, const muField = (() => {
const hasLimit = c.max_uses != null
const cb = el('input', {
type: 'checkbox',
name: 'e_max_uses_enabled',
id: 'e_max_uses_cb',
}) })
if (hasLimit) cb.checked = true
const num = el('input', {
class: 'input',
type: 'number',
min: '1',
value: hasLimit ? String(c.max_uses) : '100',
name: 'e_max_uses',
style: (hasLimit ? '' : 'display:none; ') + 'max-width:120px',
})
cb.addEventListener('change', () => {
num.style.display = cb.checked ? 'inline-block' : 'none'
if (cb.checked) num.focus()
})
const hint = c.used_count > 0
? el('div', { class: 'hint', style: 'margin-top:4px' },
'Cannot go below current used_count (' + c.used_count + ').')
: null
return el('div', { class: 'field' }, [
el('label', { class: 'lbl', for: 'e_max_uses_cb' }, 'Use cap'),
el('div', { style: 'display:flex; align-items:center; gap:10px; flex-wrap:wrap' }, [
el('label', {
for: 'e_max_uses_cb',
style: 'display:flex; align-items:center; gap:6px; cursor:pointer; font-size:13px',
}, [cb, 'Limit total uses']),
num,
]),
hint,
])
})()
// Convert RFC3339 → datetime-local format (YYYY-MM-DDTHH:MM) so // Convert RFC3339 → datetime-local format (YYYY-MM-DDTHH:MM) so
// the native picker can render. On submit we reverse: append // the native picker can render. On submit we reverse: append
// ":00Z" for UTC. Empty stays empty (= clear the field). // ":00Z" for UTC. Empty stays empty (= clear the field).
@@ -3870,23 +4018,30 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
textarea: true, textarea: true,
value: c.description || '', value: c.description || '',
}) })
// Featured toggle — same shape as in Create. Pre-populated with // Featured pill toggle — same shape as in Create. Pre-populated.
// the existing value. const featuredField = (() => {
const featuredCb = el('input', { const hiddenCb = el('input', { type: 'checkbox', name: 'e_featured', style: 'display:none' })
type: 'checkbox', name: 'e_featured', id: 'e_featured_cb', if (c.featured) hiddenCb.checked = true
style: 'margin-top:3px', const pill = el('button', {
}) type: 'button',
if (c.featured) featuredCb.checked = true class: 'featured-pill-toggle' + (c.featured ? ' on' : ''),
const featuredField = el('div', { onclick: () => {
style: 'display:flex; align-items:flex-start; gap:8px; margin-top:8px', hiddenCb.checked = !hiddenCb.checked
pill.classList.toggle('on', hiddenCb.checked)
pill.querySelector('[data-state]').textContent = hiddenCb.checked ? 'On' : 'Off'
},
}, [ }, [
featuredCb, el('span', null, '★'),
el('label', { for: 'e_featured_cb', style: 'font-size:12.5px; line-height:1.45; cursor:pointer' }, [
el('strong', null, 'Featured (launch special)'), el('strong', null, 'Featured (launch special)'),
el('span', { class: 'muted' }, el('span', { 'data-state': '1', class: 'state' }, c.featured ? 'On' : 'Off'),
'— display on the buy page with a diagonal ribbon + slashed price. Auto-applies for buyers who don\'t type a code.'),
]),
]) ])
return el('div', { style: 'margin-top:12px' }, [
pill,
hiddenCb,
el('p', { class: 'muted', style: 'margin:6px 0 0; font-size:12.5px; line-height:1.5' },
'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.'),
])
})()
const saveBtn = el('button', { class: 'btn primary', onclick: async function () { const saveBtn = el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…') const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…')
editPanel.appendChild(status) editPanel.appendChild(status)
@@ -3898,8 +4053,15 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
body.amount = c.kind === 'percent' ? rawAmt * 100 : rawAmt body.amount = c.kind === 'percent' ? rawAmt * 100 : rawAmt
} }
} }
// Max uses: only honor the number field if the "Limit total
// uses" checkbox is checked. Otherwise send null (unlimited).
const limitCb = editPanel.querySelector('[name=e_max_uses_enabled]')
if (limitCb && limitCb.checked) {
const muRaw = parseInt(editPanel.querySelector('[name=e_max_uses]').value, 10) || 0 const muRaw = parseInt(editPanel.querySelector('[name=e_max_uses]').value, 10) || 0
body.max_uses = muRaw > 0 ? muRaw : null body.max_uses = muRaw > 0 ? muRaw : null
} else {
body.max_uses = null
}
// datetime-local → RFC3339 by appending ":00Z" for UTC. // datetime-local → RFC3339 by appending ":00Z" for UTC.
// Empty → null (clear the field). // Empty → null (clear the field).
const expRaw = editPanel.querySelector('[name=e_expires_at]').value.trim() const expRaw = editPanel.querySelector('[name=e_expires_at]').value.trim()
+13 -1
View File
@@ -58,6 +58,18 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions // in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here. // append here.
const ROUTINE_NOTES = [ const ROUTINE_NOTES = [
'0.2.0:18 — **Discount Codes form polish: less typing, clearer intent.** Three small admin-UI changes that make the create + edit forms less footgun-prone.',
'',
'**Max-uses: checkbox + dependent number, not "0 = unlimited".** Previously the form had a single number input with a hint that read `"0 = unlimited"`. That meant the default value was `0`, which displayed as "no cap" but read like "0 uses allowed." Now it\'s a "Limit total uses" checkbox + a number input that only appears when the checkbox is checked (default 100). Unchecked = no cap is sent. Edit form matches.',
'',
'**Currency dropdown hides for percent + free_license codes.** A "50% off" code has no currency — neither does a free-license code. Previously the form still showed the SAT/USD selector for those kinds, which made buyers wonder what `50% off · SAT` meant. The kind-change listener now hides the currency field for `percent` and `free_license`, shows it for `fixed_amount`. Submit still defaults sensibly so existing forms keep working.',
'',
'**Featured: pill toggle, not buried checkbox.** The launch-special feature flag was the third checkbox on the form and getting missed. Replaced with a prominent gold-bordered pill toggle that flips to filled gold/navy when on. Click anywhere on the pill to toggle. Edit form matches; the toggle starts in the correct state for codes that were already featured.',
'',
'**Test count: 87** (unchanged — UI-only release).',
'',
'**Upgrade path.** v0.2.0:17 → v0.2.0:18 is a drop-in. No schema, no SDK, no behavior change for buyers. Form fields persist the same way they always did.',
'',
'0.2.0:17 — **Discount Codes form usability.** Three concrete improvements to make the Discount Codes tab less type-heavy and more discoverable.', '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.', '**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.',
@@ -326,7 +338,7 @@ const ROUTINE_NOTES = [
].join('\n\n') ].join('\n\n')
export const v0_2_0 = VersionInfo.of({ export const v0_2_0 = VersionInfo.of({
version: '0.2.0:17', version: '0.2.0:18',
releaseNotes: { en_US: ROUTINE_NOTES }, releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change. // No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under // SQLite-level migrations live separately under