From 0dcae66e052b7231fd893b9e8af371239dc2863a Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 13:19:41 -0500 Subject: [PATCH] =?UTF-8?q?SPA=20polish=20=E2=80=94=20compact=20analytics?= =?UTF-8?q?=20opt-in,=20discount-code=20currency=20picker,=20fiat=20tier?= =?UTF-8?q?=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analytics opt-in (Overview page): - Replaces the prominent "Help improve Keysat" card with a compact one-line strip below the public-key card. Single sentence + native checkbox + "what gets sent?" link that toggles an inline disclosure. - Auto-saves on toggle (no separate Save button) so the affordance reads as "click it and it's done", not as a multi-step form. - Default remains OFF — the right call for Keysat specifically given the product positioning around sovereignty / no phone-home. - Inverted-checkbox UX bug fixed (was rendering "☑ Disabled" which reads as a double-negative and confused operators). - Reset install_uuid moves into the expanded view as a small "reset" link rather than a prominent button. Discount-code create form: - New Currency picker dropdown next to Amount (SAT default, USD, EUR). For 'percent' the currency is recorded for audit but amount remains basis points; for 'fixed_sats' / 'set_price' the currency determines the unit (sats for SAT-currency, cents for USD/EUR). - Decimal entry on USD/EUR ($9.99) converts to cents on the way out. - Hint text + step attribute swap live as the operator changes Kind or Currency. - Discount-code list cell now formats fiat amounts as "$10.00 off" / "€25.00 flat" with cents-to-main-unit conversion. Existing SAT codes render unchanged. Buy page tier picker (JS + server render): - Tier cards' static HTML now respects product.price_currency: USD products render as "49.00 USD" instead of "0 sats" (which was happening for fiat-priced products since price_sats=0 for those). - TIERS JSON embedded in the page now carries (price_currency, price_value) alongside the legacy price_sats. JS selectTier() reads the right fields and swaps the unit cell ("sats" ↔ "USD") in addition to the amount when the buyer clicks a different tier. - formatTierPrice() helper centralizes the SAT-vs-fiat rendering; free-tier detection checks the value in the relevant unit. build_tiers_json() also wired to pass currency through. Per-policy currency override stays NULL = "inherit from product" until v0.3 admin UI lands. Test count unchanged at 38 (this is purely SPA + buy-page render work; behaviour is covered by existing API tests). --- licensing-service/src/api/buy_page.rs | 64 ++++++- licensing-service/web/index.html | 243 +++++++++++++++++--------- 2 files changed, 212 insertions(+), 95 deletions(-) diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index 07307a2..a445252 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -548,6 +548,20 @@ footer.kfooter a:hover {{ color:var(--navy-900); }} function fmtSats(n) {{ return Number(n).toLocaleString('en-US'); }} + // Render a tier's price in its native currency. SAT → "50,000" + // (sats unit handled by the surrounding markup); USD/EUR → "49.00" + // with the symbol baked into the unit cell. For fiat the + // price_value is in cents (smallest indivisible unit), so we + // divide by 100 for display. + function formatTierPrice(tier) {{ + const cur = (tier.price_currency || 'SAT').toUpperCase(); + if (cur === 'SAT') {{ + return {{ amount: fmtSats(tier.price_sats), unit: 'sats', isFree: tier.price_sats === 0 }}; + }} + const main = (tier.price_value || 0) / 100; + return {{ amount: main.toFixed(2), unit: cur, isFree: main === 0 }}; + }} + // Wire up tier-card clicks. document.querySelectorAll('.tier').forEach(function(card) {{ card.addEventListener('click', function(e) {{ @@ -579,16 +593,21 @@ footer.kfooter a:hover {{ color:var(--navy-900); }} setStatus(null); setPaidButton(); }} - // Reflect new base price in the cert card. + // Reflect new base price in the cert card. For fiat-priced + // products the unit cell ("sats" → "USD" / "EUR") also swaps. const t = TIERS[slug]; - currentBaseFmt = fmtSats(t.price_sats); + const fmt = formatTierPrice(t); + currentBaseFmt = fmt.amount; priceStrike.style.display = 'none'; priceTag.style.display = 'none'; + const unitEl = document.querySelector('.unit'); + if (unitEl) unitEl.textContent = fmt.unit; if (priceLabel) priceLabel.textContent = 'Price · ' + t.name; // Free tier: render "FREE", swap CTA to "Redeem license" so the - // buyer never sees "Pay with Bitcoin" for a 0-sat product. - if (t.price_sats === 0) {{ + // buyer never sees "Pay with Bitcoin" for a 0-amount product. + if (fmt.isFree) {{ priceCurrent.textContent = 'FREE'; + if (unitEl) unitEl.textContent = ''; setRedeemButton(); }} else {{ priceCurrent.textContent = currentBaseFmt; @@ -873,8 +892,17 @@ fn render_tier_picker( .map(|p| { let name = html_escape(&p.name); let slug_attr = html_escape(&p.slug); - let price = p.price_sats_override.unwrap_or(product.price_sats); - let price_fmt = format_thousands(price); + // For SAT-currency products, the override is in sats; for + // fiat-priced products it's in cents (USD/EUR). The price + // unit cell renders in the right denomination either way. + let (price_fmt, price_unit) = if product.price_currency == "SAT" { + let price = p.price_sats_override.unwrap_or(product.price_sats); + (format_thousands(price), "sats".to_string()) + } else { + let cents = p.price_sats_override.unwrap_or(product.price_value); + let main = format!("{}.{:02}", cents / 100, (cents.abs() % 100)); + (main, product.price_currency.clone()) + }; let description = p .metadata .get("description") @@ -936,12 +964,13 @@ fn render_tier_picker( String::new() }; format!( - r#"
{popular_pill}
{name}
{price_fmt}sats
{dur_html}{trial_meta}{description_html}{entitlements_html}
"#, + r#"
{popular_pill}
{name}
{price_fmt}{price_unit}
{dur_html}{trial_meta}{description_html}{entitlements_html}
"#, classes = classes, slug = slug_attr, popular_pill = popular_pill, name = name, price_fmt = price_fmt, + price_unit = price_unit, dur_html = dur_html, trial_meta = trial_meta, description_html = description_html, @@ -963,14 +992,31 @@ fn build_tiers_json( policies: &[crate::models::Policy], product: &crate::models::Product, ) -> String { + // Each tier carries enough info for the JS to render its price + // in the right unit. For SAT-currency products, `price_sats` + // (legacy field) and `price_value` are equal; for fiat-priced + // products, `price_sats` is a stale snapshot or 0 and the JS + // uses (price_currency, price_value) as the source of truth. + // + // Per-policy currency override (price_currency_override) is + // wired in for v0.3 — for now policies inherit the product's + // currency. The JS handles both cases via fallback to the + // product-level fields embedded in the page. let mut map = serde_json::Map::new(); for p in policies { - let price = p.price_sats_override.unwrap_or(product.price_sats); + let price_sats_value = p.price_sats_override.unwrap_or(product.price_sats); + // For fiat-priced products with a sat override on the + // policy, that override is in the product's currency unit + // (cents for USD/EUR). Most operators leave the override + // unset; the inheritance path covers the common case. + let price_value = p.price_sats_override.unwrap_or(product.price_value); map.insert( p.slug.clone(), serde_json::json!({ "name": p.name, - "price_sats": price, + "price_sats": price_sats_value, + "price_currency": product.price_currency, + "price_value": price_value, }), ); } diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 13f8848..4c0696c 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -892,11 +892,13 @@ The request will be refused if there are licenses or invoices tied to it — use sBtc.querySelector('.value').textContent = '?' } - // Community analytics opt-in card. Off by default; spelled out - // exactly what gets sent so the operator's choice is informed. - const analyticsCard = el('div', { class: 'card' }) - target.appendChild(analyticsCard) - renderAnalyticsCard(analyticsCard) + // Community analytics opt-in. Off by default. Compact strip so it + // doesn't compete with the operator's actual workspace cards. The + // "what's sent" disclosure expands inline; details deliberately + // tucked behind a click so the default view stays calm. + const analyticsStrip = el('div', { style: 'margin-top:24px' }) + target.appendChild(analyticsStrip) + renderAnalyticsCard(analyticsStrip) // Public key fetch — pulls PEM from /v1/issuer/public-key (no auth // required) and displays a short preview. Copy button copies the full @@ -930,95 +932,122 @@ The request will be refused if there are licenses or invoices tied to it — use ]) } - // Renders the "Help improve Keysat" card on Overview. Off by default; - // operators see exactly what gets sent before opting in. Toggling on - // requires confirming a collector URL — without one, the daemon - // doesn't beacon even with the toggle on. - async function renderAnalyticsCard(card) { - card.innerHTML = '' + // Renders the compact community-analytics opt-in strip on Overview. + // Off by default. Auto-saves the toggle on click — no separate Save + // button. Details are tucked into an inline disclosure so the + // default view stays calm and doesn't compete with the operator's + // workspace cards. + async function renderAnalyticsCard(host) { + host.innerHTML = '' let s try { s = await api('/v1/admin/community-analytics') } catch (e) { - card.appendChild(el('p', { class: 'muted' }, 'Could not load analytics state: ' + e.message)) + host.appendChild(el('p', { class: 'muted', style: 'font-size:12px' }, + 'Could not load analytics state: ' + e.message)) return } - const headerLeft = el('div', null, [ - el('h3', { style: 'margin:0 0 4px' }, 'Help improve Keysat'), - el('p', { class: 'muted', style: 'margin:0; font-size:14px' }, - 'Send an anonymous daily heartbeat so we can show real adoption numbers on the public dashboard.'), - ]) - const toggle = el('label', { - style: 'display:inline-flex; align-items:center; gap:10px; font-weight:600; font-size:14px; cursor:pointer' + // The single line that's visible by default. Native checkbox so + // the affordance reads as "click to opt in", not as a fancy + // toggle that needs a Save click after. + const checkbox = el('input', { + type: 'checkbox', + style: 'cursor:pointer', + }) + if (s.enabled) checkbox.checked = true + const detailsLink = el('a', { + href: '#', + class: 'muted', + style: 'font-size:12px; margin-left:6px', + }, 'what gets sent?') + const oneLine = el('label', { + style: 'display:inline-flex; align-items:center; gap:8px; font-size:13px; color:var(--ink-500); cursor:pointer' }, [ - el('input', { type: 'checkbox', checked: s.enabled ? 'checked' : null }), - s.enabled ? 'Enabled' : 'Disabled', + checkbox, + el('span', null, 'Send anonymous usage stats so we can show real adoption numbers on the public dashboard.'), ]) - const toggleInput = toggle.querySelector('input') - card.appendChild(el('div', { - style: 'display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:16px' - }, [headerLeft, toggle])) - // Collector URL field. Required to actually send anything; the - // toggle being on without a URL is "armed but silent". + const inlineRow = el('div', { + style: 'display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px; padding:10px 14px; background:#fbf6e9; border:1px dashed #d6cdb8; border-radius:6px' + }, [ + el('div', null, [oneLine, detailsLink]), + ]) + host.appendChild(inlineRow) + + // Expanded details (collector URL, JSON preview, reset). Hidden + // by default; toggled by the "what gets sent?" link. + const details = el('div', { + style: 'display:none; margin-top:10px; padding:14px; background:#fbf6e9; border:1px dashed #d6cdb8; border-radius:6px' + }) + host.appendChild(details) + detailsLink.addEventListener('click', (e) => { + e.preventDefault() + const showing = details.style.display !== 'none' + details.style.display = showing ? 'none' : 'block' + detailsLink.textContent = showing ? 'what gets sent?' : 'hide details' + }) + + // Collector URL — small input, optional. const urlInput = el('input', { class: 'input', type: 'url', - placeholder: 'https://keysat.xyz/community/v1/heartbeat (or your own collector)', + placeholder: 'https://keysat.xyz/community/v1/heartbeat', value: s.collector_url || '', - style: 'width:100%; box-sizing:border-box; margin-bottom:8px', + style: 'width:100%; box-sizing:border-box; font-size:12px; padding:6px 10px', }) - card.appendChild(el('label', { style: 'display:block; font-weight:600; font-size:13px; margin-bottom:4px' }, 'Collector URL')) - card.appendChild(urlInput) - card.appendChild(el('p', { class: 'muted', style: 'margin:4px 0 16px; font-size:12px' }, - 'Leave blank to opt in but not send (useful while a public collector is being stood up). Once keysat.xyz/community is live, the default URL will populate here on upgrade.')) + details.appendChild(el('label', { style: 'display:block; font-size:11px; font-weight:600; margin-bottom:4px; text-transform:uppercase; letter-spacing:0.05em' }, 'Collector URL')) + details.appendChild(urlInput) + details.appendChild(el('p', { class: 'muted', style: 'margin:4px 0 12px; font-size:11px' }, + 'Leave blank to opt in but not send. Once keysat.xyz/community is live, the default URL will populate on upgrade.')) - // Privacy disclosure — show the exact JSON shape that would be sent. - const disclosure = el('details', { class: 'disclosure' }, [ - el('summary', null, 'Show me exactly what gets sent'), - el('div', { class: 'body' }, [ - el('p', { class: 'muted', style: 'margin:0 0 12px' }, - 'Counts are floored to the nearest 5 to prevent fingerprinting an operator by exact license count. ' + - 'Uptime is bucketed (<1d / 1-7d / 1-4w / >4w). The install_uuid is a random UUIDv4 generated on first opt-in — ' + - 'NOT derived from your operator name, store id, or public URL. You can wipe it any time below.'), - el('pre', { style: 'background:#0e1f33; color:#f6f1e7; padding:12px; border-radius:6px; font-size:12px; overflow-x:auto' }, - JSON.stringify(s.preview_heartbeat, null, 2)), - s.install_uuid - ? el('p', { class: 'muted', style: 'margin:12px 0 8px; font-size:12px' }, - 'Your install_uuid: ' + s.install_uuid) - : null, - ].filter(Boolean)), - ]) - card.appendChild(disclosure) + // The exact JSON the daemon would POST. Live preview, not a + // pretend example — what you see is what would actually be sent. + details.appendChild(el('p', { class: 'muted', style: 'margin:0 0 6px; font-size:11px' }, + 'Counts are floored to the nearest 5 (anti-fingerprinting). Uptime is bucketed. install_uuid is a random UUIDv4 generated on first opt-in — NOT derived from operator name, store id, or public URL.')) + details.appendChild(el('pre', { + style: 'background:#0e1f33; color:#f6f1e7; padding:10px; border-radius:4px; font-size:11px; overflow-x:auto; margin:0 0 8px' + }, JSON.stringify(s.preview_heartbeat, null, 2))) - // Save button. - const saveBtn = el('button', { class: 'btn primary', style: 'margin-top:16px; margin-right:8px' }, 'Save') - saveBtn.addEventListener('click', async () => { - saveBtn.disabled = true + if (s.install_uuid) { + const resetRow = el('div', { style: 'display:flex; justify-content:space-between; align-items:center; gap:8px; font-size:11px' }, [ + el('span', { class: 'muted' }, 'Your install_uuid: ' + s.install_uuid.slice(0, 8) + '…'), + el('a', { href: '#', class: 'muted', style: 'font-size:11px' }, 'reset'), + ]) + const resetLink = resetRow.querySelector('a') + resetLink.addEventListener('click', async (e) => { + e.preventDefault() + if (!confirm('Wipe your anonymous install_uuid? Future heartbeats (if you re-enable) will use a fresh one.')) return + try { + await api('/v1/admin/community-analytics/reset', { method: 'POST' }) + renderAnalyticsCard(host) + } catch (er) { alert(er.message) } + }) + details.appendChild(resetRow) + } + + // Auto-save: toggling the checkbox or editing the URL persists + // immediately. No Save button; the affordance is "click and it's + // done." + let saveTimer = null + async function persist() { try { await api('/v1/admin/community-analytics', { method: 'POST', body: { - enabled: toggleInput.checked, + enabled: checkbox.checked, collector_url: urlInput.value.trim() || null, }}) - renderAnalyticsCard(card) } catch (e) { alert(e.message) - } finally { - saveBtn.disabled = false + // Revert visual state on failure so what the user sees + // matches what's persisted. + checkbox.checked = !checkbox.checked } + } + checkbox.addEventListener('change', persist) + urlInput.addEventListener('input', () => { + clearTimeout(saveTimer) + saveTimer = setTimeout(persist, 600) // debounce the URL field }) - - const resetBtn = el('button', { class: 'btn sm secondary', style: 'margin-top:16px' }, 'Reset install_uuid') - resetBtn.addEventListener('click', async () => { - if (!confirm('This wipes your anonymous install_uuid. Future heartbeats (if you re-enable) will use a fresh UUID. Continue?')) return - try { - await api('/v1/admin/community-analytics/reset', { method: 'POST' }) - renderAnalyticsCard(card) - } catch (e) { alert(e.message) } - }) - card.appendChild(saveBtn) - if (s.install_uuid) card.appendChild(resetBtn) } // Render a product's price for table cells. Picks the right @@ -1671,11 +1700,13 @@ The request will be refused if there are licenses or invoices tied to it — use const target = document.getElementById('route-target') target.innerHTML = '' - function amountHint(kind) { - if (kind === 'percent') return 'percent off, e.g. 50 = 50%. Range: 1–100.' - if (kind === 'fixed_sats') return 'sats subtracted from the base price.' - if (kind === 'set_price') return 'flat price the buyer pays in sats (e.g. 5000 = "buy at 5000 sats regardless of base price"). If higher than base, the code provides no benefit.' + function amountHint(kind, currency) { + if (kind === 'percent') return 'percent off, e.g. 50 = 50%. Range: 1–100. (Currency-agnostic.)' if (kind === 'free_license') return 'amount is ignored — buyer pays nothing.' + const unit = currency === 'SAT' ? 'sats' : currency === 'USD' ? 'USD' : currency === 'EUR' ? 'EUR' : 'units' + const decimals = currency === 'SAT' ? '' : ' (decimals OK, e.g. 9.99)' + if (kind === 'fixed_sats') return `${unit} subtracted from the base price${decimals}.` + if (kind === 'set_price') return `flat price the buyer pays in ${unit}${decimals}. If higher than base, the code provides no benefit.` return '' } @@ -1686,14 +1717,22 @@ The request will be refused if there are licenses or invoices tied to it — use formInput('code', 'Code', { required: true, hint: 'will be uppercased, e.g. FOUNDERS50' }), formSelect('kind', 'Kind', [ { value: 'percent', label: 'Percent off' }, - { value: 'fixed_sats', label: 'Fixed sats off' }, - { value: 'set_price', label: 'Set flat price (in sats)' }, + { value: 'fixed_sats', label: 'Fixed amount off' }, + { value: 'set_price', label: 'Set flat price' }, { value: 'free_license', label: 'Free license (no payment)' }, ], { required: true, value: 'percent' }), ]), el('div', { class: 'row-2' }, [ - formInput('amount', 'Amount', { type: 'number', value: '50', hint: amountHint('percent') }), + formInput('amount', 'Amount', { type: 'number', step: '1', value: '50', hint: amountHint('percent', 'SAT') }), + 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.' }), + ]), + 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' }), @@ -1706,11 +1745,21 @@ The request will be refused if there are licenses or invoices tied to it — use create.querySelector('.body').appendChild(status) try { const kind = create.querySelector('[name=kind]').value - let amount = parseInt(create.querySelector('[name=amount]').value, 10) || 0 - if (kind === 'percent') amount = amount * 100 + 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 + else if (currency === 'SAT') amount = Math.round(rawAmount) + else amount = Math.round(rawAmount * 100) const body = { code: create.querySelector('[name=code]').value.trim(), kind, amount, + discount_currency: currency, description: create.querySelector('[name=description]').value || '', } const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0 @@ -1733,14 +1782,20 @@ The request will be refused if there are licenses or invoices tied to it — use ]), ]) - // Live-update the amount hint as the operator changes the Kind dropdown. + // 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. const kindSelEl = create.querySelector('[name=kind]') - if (kindSelEl) { - kindSelEl.addEventListener('change', function () { - const hintEl = create.querySelector('[name=amount]').parentElement.querySelector('.hint') - if (hintEl) hintEl.textContent = amountHint(kindSelEl.value) - }) + const curSelEl = create.querySelector('[name=discount_currency]') + const amtInputEl = create.querySelector('[name=amount]') + 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' } + if (kindSelEl) kindSelEl.addEventListener('change', updateHint) + if (curSelEl) curSelEl.addEventListener('change', updateHint) target.appendChild(plainCard([ el('p', { class: 'muted', style: 'margin:0 0 16px' }, @@ -1829,10 +1884,26 @@ The request will be refused if there are licenses or invoices tied to it — use const j = await api('/v1/admin/discount-codes?include_inactive=true') const codes = j.codes || [] const rows = codes.map((c) => { + // Currency-aware rendering. SAT-currency codes show "5,000 + // sats off"; fiat codes show "$10.00 off" with cents-to- + // dollars conversion. Backwards-compat for older rows that + // don't carry discount_currency: treat as SAT. + const cur = (c.discount_currency || 'SAT').toUpperCase() + const fmtFiat = (cents, sym) => sym + (cents / 100).toFixed(2) let amountStr = '' if (c.kind === 'percent') amountStr = (c.amount / 100) + '%' - else if (c.kind === 'fixed_sats') amountStr = c.amount.toLocaleString() + ' sats off' - else if (c.kind === 'set_price') amountStr = c.amount.toLocaleString() + ' sats flat' + else if (c.kind === 'fixed_sats') { + if (cur === 'SAT') amountStr = c.amount.toLocaleString() + ' sats off' + else if (cur === 'USD') amountStr = fmtFiat(c.amount, '$') + ' off' + else if (cur === 'EUR') amountStr = fmtFiat(c.amount, '€') + ' off' + else amountStr = c.amount + ' ' + cur + ' off' + } + else if (c.kind === 'set_price') { + if (cur === 'SAT') amountStr = c.amount.toLocaleString() + ' sats flat' + else if (cur === 'USD') amountStr = fmtFiat(c.amount, '$') + ' flat' + else if (cur === 'EUR') amountStr = fmtFiat(c.amount, '€') + ' flat' + else amountStr = c.amount + ' ' + cur + ' flat' + } else amountStr = el('span', { class: 'badge b-gold' }, 'free') const usage = c.used_count + ' / ' + (c.max_uses == null ? '∞' : c.max_uses) return el('tr', null, [