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:
@@ -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
|
||||||
el('strong', null, 'Featured (launch special) '),
|
// logic (querySelector by name) keeps working.
|
||||||
el('span', { class: 'muted' },
|
(() => {
|
||||||
'— 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 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('span', { 'data-state': '1', class: 'state' }, 'Off'),
|
||||||
|
])
|
||||||
|
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 || '',
|
||||||
}
|
}
|
||||||
const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0
|
// Max uses: only honor the number field if the "Limit total
|
||||||
if (mu > 0) body.max_uses = mu
|
// 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
|
||||||
|
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)
|
||||||
featuredCb,
|
pill.querySelector('[data-state]').textContent = hiddenCb.checked ? 'On' : 'Off'
|
||||||
el('label', { for: 'e_featured_cb', style: 'font-size:12.5px; line-height:1.45; cursor:pointer' }, [
|
},
|
||||||
el('strong', null, 'Featured (launch special) '),
|
}, [
|
||||||
el('span', { class: 'muted' },
|
el('span', null, '★'),
|
||||||
'— display on the buy page with a diagonal ribbon + slashed price. Auto-applies for buyers who don\'t type a code.'),
|
el('strong', null, 'Featured (launch special)'),
|
||||||
]),
|
el('span', { 'data-state': '1', class: 'state' }, c.featured ? 'On' : 'Off'),
|
||||||
])
|
])
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const muRaw = parseInt(editPanel.querySelector('[name=e_max_uses]').value, 10) || 0
|
// Max uses: only honor the number field if the "Limit total
|
||||||
body.max_uses = muRaw > 0 ? muRaw : null
|
// 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
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user