SPA polish — compact analytics opt-in, discount-code currency picker, fiat tier rendering
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).
This commit is contained in:
@@ -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);
|
||||
// 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);
|
||||
let price_fmt = format_thousands(price);
|
||||
(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#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}<div class="tier-name">{name}</div><div class="tier-price">{price_fmt}<span class="tier-price-unit">sats</span></div>{dur_html}{trial_meta}{description_html}{entitlements_html}<button type="button" class="tier-select-btn">Select</button></div>"#,
|
||||
r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}<div class="tier-name">{name}</div><div class="tier-price">{price_fmt}<span class="tier-price-unit">{price_unit}</span></div>{dur_html}{trial_meta}{description_html}{entitlements_html}<button type="button" class="tier-select-btn">Select</button></div>"#,
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
// 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)))
|
||||
|
||||
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'),
|
||||
])
|
||||
card.appendChild(disclosure)
|
||||
|
||||
// Save button.
|
||||
const saveBtn = el('button', { class: 'btn primary', style: 'margin-top:16px; margin-right:8px' }, 'Save')
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
saveBtn.disabled = true
|
||||
try {
|
||||
await api('/v1/admin/community-analytics', { method: 'POST', body: {
|
||||
enabled: toggleInput.checked,
|
||||
collector_url: urlInput.value.trim() || null,
|
||||
}})
|
||||
renderAnalyticsCard(card)
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
} finally {
|
||||
saveBtn.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
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(card)
|
||||
} catch (e) { alert(e.message) }
|
||||
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: checkbox.checked,
|
||||
collector_url: urlInput.value.trim() || null,
|
||||
}})
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
// 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
|
||||
})
|
||||
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, [
|
||||
|
||||
Reference in New Issue
Block a user