diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index d4a000a..9ab5e6d 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -324,6 +324,43 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } .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, the dragging card visibly lifts, and the drop-target receives a 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)' }, ], { required: true, value: 'percent' }), ]), - el('div', { class: 'row-2' }, [ - formInput('amount', 'Amount', { type: 'number', step: '1', value: '50', hint: amountHint('percent', 'SAT') }), - formSelect('discount_currency', 'Currency', [ + // Amount + currency. Currency is hidden when the kind doesn't + // use it (percent / free_license) — they're currency-agnostic. + // 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: 'USD', label: 'USD ($)' }, { 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. // 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' }), - formInput('expires_at', 'Expires at', { type: 'datetime-local', hint: 'Leave blank for no expiry.' }), - ]), + // Limits: "Limit total uses" checkbox + number input, paired + // with the expires-at picker. The number input is hidden when + // the checkbox is off (= unlimited). Clearer than the old + // "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('description', 'Description (internal note)', { textarea: true }), - el('div', { style: 'display:flex; align-items:flex-start; gap:8px; margin-top:8px' }, [ - el('input', { type: 'checkbox', name: 'featured', id: 'create_featured_cb', style: 'margin-top:3px' }), - el('label', { for: 'create_featured_cb', style: 'font-size:12.5px; line-height:1.45; cursor:pointer' }, [ - el('strong', null, 'Featured (launch special) '), - 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.'), - ]), - ]), + // Featured pill toggle — more prominent than a checkbox so it + // doesn't get missed. Click anywhere on the pill toggles state. + // 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('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 () { const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…') 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, description: create.querySelector('[name=description]').value || '', } - const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0 - if (mu > 0) body.max_uses = mu + // 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 + if (mu > 0) body.max_uses = mu + } // 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 @@ -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 // 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 curSelEl = create.querySelector('[name=discount_currency]') const amtInputEl = create.querySelector('[name=amount]') + const currencyFieldEl = create.querySelector('[data-currency-field]') function updateHint() { const hintEl = amtInputEl.parentElement.querySelector('.hint') if (hintEl) hintEl.textContent = amountHint(kindSelEl.value, curSelEl.value) // Toggle decimal entry — sats are integer, fiat goes to cents. 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 (curSelEl) curSelEl.addEventListener('change', updateHint) + // Initial render: kind defaults to 'percent', so hide currency. + updateHint() target.appendChild(plainCard([ 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.' : amountHint(c.kind) + ' (kind: ' + c.kind + ', not editable)', }) - const muField = formInput('e_max_uses', 'Max uses (0 = unlimited)', { - type: 'number', - 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, - }) + // Max uses — "Limit total uses" checkbox + dependent number + // input. Same UX as the create form. Pre-populated based on + // the code's current state. + 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 // the native picker can render. On submit we reverse: append // ":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, value: c.description || '', }) - // Featured toggle — same shape as in Create. Pre-populated with - // the existing value. - const featuredCb = el('input', { - type: 'checkbox', name: 'e_featured', id: 'e_featured_cb', - style: 'margin-top:3px', - }) - if (c.featured) featuredCb.checked = true - const featuredField = el('div', { - style: 'display:flex; align-items:flex-start; gap:8px; margin-top:8px', - }, [ - featuredCb, - 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' }, - '— display on the buy page with a diagonal ribbon + slashed price. Auto-applies for buyers who don\'t type a code.'), - ]), - ]) + // Featured pill toggle — same shape as in Create. Pre-populated. + const featuredField = (() => { + const hiddenCb = el('input', { type: 'checkbox', name: 'e_featured', style: 'display:none' }) + if (c.featured) hiddenCb.checked = true + const pill = el('button', { + type: 'button', + class: 'featured-pill-toggle' + (c.featured ? ' on' : ''), + 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' }, 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 status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…') 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 } } - const muRaw = parseInt(editPanel.querySelector('[name=e_max_uses]').value, 10) || 0 - body.max_uses = muRaw > 0 ? muRaw : null + // 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 + body.max_uses = muRaw > 0 ? muRaw : null + } else { + body.max_uses = null + } // datetime-local → RFC3339 by appending ":00Z" for UTC. // Empty → null (clear the field). const expRaw = editPanel.querySelector('[name=e_expires_at]').value.trim() diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 5e5c728..1a3ccb7 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: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.', '', '**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') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:17', + version: '0.2.0:18', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under