feat(sirius-x): tier-based label pricing; badges on search cards + TLD list

Single source of truth (js/pricing.js) mirrored on landing, tld.html
and the mint flow — same label always shows the same price everywhere.

Name tiers (label part):
  1 char           $5.00  premium-1
  2 chars          $3.00  short-2
  3 chars          $2.00  short-3
  4–5 chars        $1.00  standard
  6–7 chars        $0.50  long
  8–16 chars       $0.25  extended
  17+ chars        $0.10  very-long
  all-digits       ×0.5   (less brandable)
  contains hyphen  ×0.7

TLD tiers (label part):
  1 char           $10.00  premium-1
  2 chars          $8.00   short-2
  3 chars          $6.00   short-3
  4–8 chars        $5.00   standard
  9+ chars         $4.00   long

Rendering:
  - Landing #search-results: acid-tinted price badge on each available
    row; grid grew a fourth column so name + badge + price + button
    all sit on one line
  - tld.html search result-card: same price badge next to the Register
    button on available TLDs
  - tld.html registered-TLDs table: new 'Mint price' column showing the
    tier price for every registered TLD, so buyers see the pricing
    ladder next to concrete examples

Mint pipe:
  - startFlow(name)/startTldFlow(label) resolve the label price via
    window.siriusPricing (default) but honour opts.serviceFeeSats when
    the caller passes one — leaves the door open for coupons /
    operator discounts / oracle overrides in a future revision without
    reshuffling call sites
  - The overridden service fee is threaded through quoteRegistration
    AND registerWithBuiltInWallet/registerWithExternalWallet so the
    confirm screen and the on-chain output agree

Chipnet placeholder rate (250,000 sat/USD) stays in pricing.js;
mainnet will swap in a live BCH/USD oracle. Operator-side discounts
and coupon codes are the next iteration — deliberately not disclosed
in this shipping copy.
This commit is contained in:
Local Dev 2026-09-09 01:09:05 +02:00
parent 4f776844a0
commit ce9248a327
4 changed files with 133 additions and 13 deletions

View file

@ -126,11 +126,15 @@
<style>
/* Inline search card styling: mirrors register.html's .card look. */
#search-results .row-card{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;
#search-results .row-card{display:grid;grid-template-columns:1fr auto auto auto;gap:12px;align-items:center;
background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:11px 16px;min-height:48px}
#search-results .row-card.checking{opacity:.55}
#search-results .row-card .n{font-family:ui-monospace,monospace;font-size:15px;overflow-wrap:anywhere}
#search-results .row-card .btn{padding:6px 12px;font-size:12.5px}
#search-results .row-card .price{
color:var(--acid);font-family:ui-monospace,monospace;font-size:14px;font-weight:600;
padding:2px 8px;border-radius:6px;background:rgba(214,255,61,.10);white-space:nowrap
}
#search-results .badge{font-size:11px;padding:2px 10px;border-radius:999px;font-weight:600;white-space:nowrap}
#search-results .b-ok{background:rgba(79,209,165,.15);color:var(--ok)}
#search-results .b-taken{background:rgba(246,118,138,.15);color:var(--taken)}
@ -162,12 +166,20 @@
const badge = state === "ok" ? '<span class="badge b-ok">available</span>'
: state === "taken" ? '<span class="badge b-taken">taken</span>'
: '<span class="badge b-check">checking…</span>';
// Label price (rendered only for available names). Tier logic lives in
// pricing.js so landing / tld.html / the mint modal all agree.
let price = "";
if (state === "ok" && window.siriusPricing) {
const label = full.split(".")[0];
const p = window.siriusPricing.priceForName(label);
price = `<span class="price" title="${esc(p.tier)}">${esc(window.siriusPricing.formatUsd(p.usd))}</span>`;
}
const btn = state === "ok"
? `<button class="btn acid" data-register="${esc(full)}">Register →</button>`
: state === "taken"
? `<a class="btn ghost" href="https://navigate.st/bns/${encodeURIComponent(full)}" target="_blank" rel="noopener">view →</a>`
: "<span></span>";
return `<div class="row-card ${state === "checking" ? "checking" : ""}"><span class="n">${esc(full)}</span>${badge}${btn}</div>`;
return `<div class="row-card ${state === "checking" ? "checking" : ""}"><span class="n">${esc(full)}</span>${badge}${price}${btn}</div>`;
}
async function search(label) {
@ -436,6 +448,7 @@ process.exit(0);"</pre>
</script>
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
<script src="./js/pricing.js?v=20260909tiers"></script>
<script defer src="./js/site-footer.js?v=20260907rel"></script>
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
</body>

71
js/pricing.js Normal file
View file

@ -0,0 +1,71 @@
// Sirius.X pricing model — deterministic per-label cost so users can predict
// what a name or TLD will cost before they click Register. Public tiers only;
// operator-level discounts and coupon overrides land in a future revision
// (the plan is oracle-fed rates + coupon-code redemption at mint time).
//
// Exposed as window.siriusPricing so both the register-flow modal and the
// inline search widgets on landing/tld.html use one source of truth.
(() => {
// Chipnet placeholder rate: mainnet swaps this for a real BCH/USD oracle.
// The numbers keep the demo transactions in the "few chipnet coins" range
// so a single faucet visit covers a name or a TLD.
const CHIPNET_SATS_PER_USD = 250_000;
const usdToSats = (usd) => BigInt(Math.max(0, Math.round(Number(usd) * CHIPNET_SATS_PER_USD)));
const cleanLabel = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9-]/g, "");
// Tier picker — LENGTH-first, then downshift for "less brandable" shapes
// (all-digits, hyphenated). Kept as multiplicative modifiers so tiers
// remain the primary story and mods just soften an edge case.
function priceForName(label) {
const s = cleanLabel(label);
if (!s) return { usd: 0, tier: "invalid" };
let usd, tier;
if (s.length === 1) { usd = 5.00; tier = "premium-1"; }
else if (s.length === 2) { usd = 3.00; tier = "short-2"; }
else if (s.length === 3) { usd = 2.00; tier = "short-3"; }
else if (s.length <= 5) { usd = 1.00; tier = "standard"; }
else if (s.length <= 7) { usd = 0.50; tier = "long"; }
else if (s.length <= 16) { usd = 0.25; tier = "extended"; }
else { usd = 0.10; tier = "very-long"; }
// Modifiers.
if (/^\d+$/.test(s)) { usd = round2(usd * 0.5); tier += "+digits"; }
if (s.includes("-")) { usd = round2(usd * 0.7); tier += "+hyphen"; }
return { usd, tier, sats: usdToSats(usd) };
}
function priceForTld(label) {
const s = cleanLabel(label);
if (!s) return { usd: 0, tier: "invalid" };
let usd, tier;
if (s.length === 1) { usd = 10.00; tier = "premium-1"; }
else if (s.length === 2) { usd = 8.00; tier = "short-2"; }
else if (s.length === 3) { usd = 6.00; tier = "short-3"; }
else if (s.length <= 8) { usd = 5.00; tier = "standard"; }
else { usd = 4.00; tier = "long"; }
return { usd, tier, sats: usdToSats(usd) };
}
// Rounded USD → display string. Under $1 shows cents ($0.25); $1 and up
// shows whole dollars ($5) unless there's a fractional part.
function formatUsd(usd) {
const n = Number(usd || 0);
if (n >= 1 && n === Math.floor(n)) return `$${n}`;
return `$${n.toFixed(2)}`;
}
function formatSats(sats) {
return `${BigInt(sats || 0n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")} sat`;
}
function round2(n) { return Math.round(Number(n) * 100) / 100; }
window.siriusPricing = {
priceForName,
priceForTld,
formatUsd,
formatSats,
usdToSats,
CHIPNET_SATS_PER_USD,
};
})();

View file

@ -337,10 +337,18 @@ function adoptSignedInWallet() {
return false;
}
function startFlow(name) {
function startFlow(name, opts = {}) {
state.name = name;
state.tld = null;
state.signInOnly = false;
// Tier-based service fee (siriusPricing owns the tiers so landing search
// badges, tld cards and this mint agree). Caller can override with
// opts.serviceFeeSats when a coupon or manual price is in play.
const label = String(name).split(".")[0];
const p = window.siriusPricing?.priceForName(label);
state.nameFeeSats = opts.serviceFeeSats != null
? BigInt(opts.serviceFeeSats)
: (p ? p.sats : null);
open();
if (adoptSignedInWallet()) stepFund();
else stepWallet();
@ -528,7 +536,10 @@ async function stepExternalConfirm(session, label) {
// steps which know to call registerTldWithExternalWallet.
if (state.tld) { state.session = session; return stepConfirmTld(); }
const quote = await BNS.quoteRegistration(c, { name: state.name, ownerAddress: owner, records: {} });
const feeOverride = state.nameFeeSats != null
? { serviceFee: { ...(BNS.REGISTRAR?.serviceFee ?? {}), sats: state.nameFeeSats } }
: {};
const quote = await BNS.quoteRegistration(c, { name: state.name, ownerAddress: owner, records: {}, ...feeOverride });
const rows = BNS.priceSummary(quote.costs).map((l) => `
<tr class="${l.total ? "total" : ""}"><td>${esc(l.label)}${l.note ? `<small>${esc(l.note)}</small>` : ""}</td>
<td>${sats(l.sats)} sat</td></tr>`).join("");
@ -552,7 +563,7 @@ async function stepExternalConfirm(session, label) {
const say = (s) => { const d = document.createElement("div"); d.textContent = s; log.appendChild(d); };
try {
const res = await BNS.registerWithExternalWallet(await client(), {
session, name: state.name, ownerAddress: owner, records: {}, onProgress: say,
session, name: state.name, ownerAddress: owner, records: {}, onProgress: say, ...feeOverride,
});
state.result = res;
state.session = session;
@ -732,10 +743,14 @@ async function stepConfirm() {
if (state.tld) return stepConfirmTld();
const avail = await BNS.checkAvailability(c, state.name);
if (!avail.available) throw new Error(`"${avail.name}" was just registered by someone else`);
const feeOverride = state.nameFeeSats != null
? { serviceFee: { ...(BNS.REGISTRAR?.serviceFee ?? {}), sats: state.nameFeeSats } }
: {};
const quote = await BNS.quoteRegistration(c, {
name: state.name, ownerAddress: state.wallet.address, records: {},
name: state.name, ownerAddress: state.wallet.address, records: {}, ...feeOverride,
});
state.quote = quote;
state._feeOverride = feeOverride;
const rows = BNS.priceSummary(quote.costs).map((l) => `
<tr class="${l.total ? "total" : ""}"><td>${esc(l.label)}${l.note ? `<small>${esc(l.note)}</small>` : ""}</td>
@ -848,6 +863,7 @@ async function stepRegister() {
const res = await BNS.registerWithBuiltInWallet(await client(), {
wallet: state.wallet, name: state.name, records: {},
onProgress: (s) => say(words[s] ?? s),
...(state._feeOverride ?? {}),
});
state.result = res;
stepDone();

View file

@ -74,6 +74,11 @@
table.tlds th{color:var(--mut);font-weight:500;font-size:11.5px;text-transform:uppercase;letter-spacing:.4px}
table.tlds .tld{font-family:ui-monospace,monospace;color:var(--acid);font-size:13.5px;font-weight:600}
table.tlds .cat{font-family:ui-monospace,monospace;color:var(--mut);font-size:11.5px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.price, table.tlds .price{
color:var(--acid);font-family:ui-monospace,monospace;font-size:13px;font-weight:600;
padding:2px 8px;border-radius:6px;background:rgba(214,255,61,.10);white-space:nowrap;display:inline-block
}
.result-card{grid-template-columns:1fr auto auto auto !important}
footer{border-top:1px solid var(--line);padding:2rem 0 3rem;color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem}
</style>
<script type="importmap">
@ -132,7 +137,7 @@
<p class="muted">Fetched from the on-chain TLD registry via the public gateway. Refresh the page for the latest.</p>
<div id="tlds-status" class="status">Loading…</div>
<table class="tlds" id="tlds-table" style="display:none">
<thead><tr><th>TLD</th><th>Category</th></tr></thead>
<thead><tr><th>TLD</th><th>Mint price</th><th>Category</th></tr></thead>
<tbody></tbody>
</table>
</section>
@ -162,9 +167,15 @@
const j = await r.json();
const tlds = (j.tlds || []).map((t) => (typeof t === "string" ? { tld: t } : t));
tlds.sort((a, b) => a.tld.localeCompare(b.tld));
tbody.innerHTML = tlds.map((t) =>
`<tr><td class="tld">.${esc(t.tld)}</td><td class="cat">${esc(t.certificate || t.category || "")}</td></tr>`
).join("");
tbody.innerHTML = tlds.map((t) => {
const p = window.siriusPricing?.priceForTld(t.tld);
const priceCell = p ? `<span class="price" title="${esc(p.tier)}">${esc(window.siriusPricing.formatUsd(p.usd))}</span>` : "—";
return `<tr>
<td class="tld">.${esc(t.tld)}</td>
<td class="price-cell">${priceCell}</td>
<td class="cat">${esc(t.certificate || t.category || "")}</td>
</tr>`;
}).join("");
$("tlds-table").style.display = "";
status.className = "status ok";
status.textContent = `${tlds.length} TLDs registered on chipnet.`;
@ -195,9 +206,14 @@
different label — TLDs are first-come, first-served.</p>`;
return;
}
const p = window.siriusPricing?.priceForTld(label);
const priceBadge = p
? `<span class="price" title="${esc(p.tier)}">${esc(window.siriusPricing.formatUsd(p.usd))}</span>`
: "";
res.innerHTML = `<div class="result-card">
<div class="lbl"><span class="dot">.</span>${esc(label)}</div>
<span class="badge b-ok">available</span>
${priceBadge}
<button class="btn acid" id="go-register">Register .${esc(label)} →</button>
</div>`;
$("go-register").onclick = () => beginRegister(label);
@ -231,9 +247,12 @@
// — window.siriusRegisterTld). That modal owns the wallet-choice, fund,
// confirm, and mint steps — same UX as name registration.
function beginRegister(label) {
const call = () => window.siriusRegisterTld(label, {
serviceFeeSats: BigInt(DEFAULT_USD * CHIPNET_SATS_PER_USD),
});
// Label-based tier pricing lives in siriusPricing (js/pricing.js) so
// landing / tld / mint agree. Fall back to the flat DEFAULT_USD if the
// pricing module hasn't loaded for some reason.
const p = window.siriusPricing?.priceForTld(label);
const fee = p ? p.sats : BigInt(DEFAULT_USD * CHIPNET_SATS_PER_USD);
const call = () => window.siriusRegisterTld(label, { serviceFeeSats: fee });
if (typeof window.siriusRegisterTld === "function") { call(); return; }
// Module still loading — retry until it lands.
const btn = $("go-register");
@ -263,6 +282,7 @@
</script>
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
<script src="./js/pricing.js?v=20260909tiers"></script>
<script defer src="./js/site-footer.js?v=20260907rel"></script>
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
</body>