crates/monetize-site-buy/assets/buy.js
// **The buy page's script, ONE copy for every product site** (monetize-site-buy `assets`,
// owner 2026-10-23): Gunnar's `auth/signup.html` script, moved as it is. Each site fills in
// only its settings (its words, by `assets::buy_js`) and draws its own look; every
// call is the site's own buy door (the shared relay, Gunnar's JSON contract).
// ── The site's settings ──────────────────────────────────────────────────────
// `bearer`: where the site keeps this tab's session ('' = the site's cookie);
// `status`: the site's own "who is here"; `statusShape`: how it answers ('' = the fields
// `/auth/status` names; 'links' = a sign-up links answer, read as those fields; A32-24: the
// site's setting, never a product's path in this script); `offer`: the shop's word on what it
// sells here ('' = in `status` already).
const SITE = { bearer: '@@BEARER_KEY@@', status: '@@STATUS_URL@@', statusShape: '@@STATUS_SHAPE@@', offer: '@@OFFER_URL@@' };
function bearer() {
if (!SITE.bearer) return '';
try { return sessionStorage.getItem(SITE.bearer) || ''; } catch (e) { return ''; }
}
// ★ **Hidden is hidden, whatever the site's CSS says** (PAID-3, test2.gunnar.rs 2026-10-26):
// the sites draw the order form as a grid (`form { display: grid }`), and an author `display`
// beats the browser's own `[hidden]`, so a form hidden by its attribute alone stayed on the
// screen after paying. The inline style wins over every stylesheet.
function conceal(e) { if (e) { e.hidden = true; e.style.display = 'none'; } }
function reveal(e, on) { if (e) { e.hidden = !on; e.style.display = on ? '' : 'none'; } }
function authHeaders(extra) {
const h = Object.assign({}, extra || {});
const t = bearer();
if (t) h['Authorization'] = 'Bearer ' + t;
return h;
}
// A site whose "who is here" answers sign-up links (`statusShape` 'links'): the fields
// `/auth/status` names.
function statusOf(l) {
l = l || {};
if (SITE.statusShape !== 'links') return l;
return {
authenticated: !!l.signed_in && !!l.account,
user: l.account || '',
instance: l.instance || '',
private_url: l.private_offered ? (l.private_url || '') : '',
buy_url: l.signed_in ? (l.grow_account_url || '') : (l.account_url || ''),
account_offer: !!l.account_url,
plan: l.plan || '',
};
}
// The shop's own word on what it sells here (`GET /auth/buy/offer`): "Account on <host>" only
// where an account is sold, the private choice only where instances are.
function sold(status, offer) {
if (!offer || typeof offer !== 'object') return status;
const s = Object.assign({}, status);
if (!s.authenticated) s.account_offer = offer.account_offer || '';
if (!offer.private_offered && !/^https:\/\//.test(s.private_url || '')) s.private_url = '';
return s;
}
async function offerHere() {
if (!SITE.offer) return null;
try {
const r = await fetch(SITE.offer, { credentials: 'same-origin', headers: authHeaders() });
if (!r.ok || !(r.headers.get('content-type') || '').includes('json')) return null;
return await r.json();
} catch (e) { return null; }
}
// ── The words of this page, each in ONE place ────────────────────────────────
//
// The owner wrote "REPLIKA"; the UI is English, so it reads REPLICA here, and
// changing it is this one line.
const HOST_LABELS = { front: 'UI', main: 'SERVER', twin: 'REPLICA' };
// The heading follows the situation (owner 2026-10-07). `{company}` is the
// instance's name as the server gave it. A visitor with no account here signs
// up for one first, whichever way they go: "Sign up for an account" (owner
// 2026-10-15) — FIRST_ACCOUNT_HEADING.
const FIRST_ACCOUNT_HEADING = 'Sign up for an account';
const HEADINGS = {
'private': 'New private @@PRODUCT@@',
'account': FIRST_ACCOUNT_HEADING,
'grow-account': 'Grow your account',
'grow-instance': 'Grow {company}’s @@PRODUCT@@',
};
// ★ A FREE account's own growth is buying its first plan (owner 2026-10-17):
// the word on the Account page's button = this heading = its tab. Which one
// is monetize's plan state (`page.planned`, read before the tabs are drawn).
const BUY_A_PLAN = 'Buy a plan';
function growWords() { return page.planned === false ? BUY_A_PLAN : HEADINGS['grow-account']; }
// ★ The private choice's button says what is paid (owner 2026-10-22): the step's amount as
// monetize words it (`pay_words`).
const BUTTONS = { 'private': 'Pay {amount}', 'account': 'Pay {amount}', 'grow-account': 'Upgrade', 'grow-instance': 'Upgrade' };
// ★ **A visitor buying first makes their account here** (one shop, owner
// 2026-10-07; the hosted plan too since ST-10, 2026-10-11 — the owner liked it):
// the account name and an e-mail, the account made at once (the shop's account
// plan, no payment), the passkey registered, and back on this choice signed in,
// where the purchase is made ON that account. No money ever waits for a name.
// The owner's words; `{host}` is this page's host.
const FIRST_ACCOUNT_BUTTON = 'Create your account at {host}';
const FIRST_ACCOUNT_LINES = {
'account': 'Your {host} account shows everything you have bought and lets you add more. You pay only for the storage you choose.',
// ★ BUY-ONE-2 (owner 2026-10-27): the free account's line is its card check, in the
// site's words (RA5-41: the vendor's and the product's, never this script's).
'account-free': '@@CARD_CHECK@@',
};
// ★ **The free account** (owner 2026-10-15): the account at the shop that
// every sign-up makes first (`gunnar/account`, priced at nothing, holding nothing), offered as
// a choice of its own above the plans the ONE box sells. Its words are the
// owner's; every other row's name and price are the ONE box's.
// ★ BUY-ONE-2 (owner 2026-10-27): the free row's note is the owner's, in the site's words
// for its product (RA5-41: Gunnar clones and pushes, Holger installs and publishes).
const FREE_ACCOUNT = { id: '@@FREE_ACCOUNT@@', name: 'Free account', price: 'Free',
note: '@@FREE_NOTE@@' };
// ★ **The one buy page's two tabs** (BUY-ONE-2, owner GO 2026-10-27): the account's tab
// ("Account on <site>"; signed in, the SAME tab is "Grow your plan") and the private one,
// in that order, signed out and signed in. What each offers is the server's (/auth/status).
const TAB_ACCOUNT_SIGNED_IN = 'Grow your plan';
const TAB_PRIVATE = 'New private instance';
// ★ The beta line, on every tab (owner 2026-10-27), in the site's words; empty: none.
const BETA_LINE = '@@BETA_LINE@@';
function betaLine(on) {
on = on && BETA_LINE !== '';
let b = document.getElementById('beta-line');
if (!b && on) {
b = document.createElement('p');
b.id = 'beta-line';
b.className = 'hint';
b.textContent = BETA_LINE;
$('msg').before(b);
}
if (b) reveal(b, on);
}
// ★ **The card check** (owner 2026-10-15): monetize makes a free account only after Stripe
// has checked a card (nothing is charged, one free account per card). Its answer is then
// Stripe's page, not this console's welcome page; the welcome page is where Stripe sends
// the buyer back, and it is told (this tab only) that nothing was paid.
const CARD_CHECK_KEY = '@@STORAGE@@card_check';
// ★ **A visitor pays ONCE for a private @@PRODUCT@@** (owner 2026-10-22): the account name and the
// e-mail typed here go with the order straight to its payment; the account is made with it.
// Signed in, the e-mail is not asked at all: the order mails the account's own address.
const PRIVATE_VISITOR_LINE = 'You pay once. Your account at {host} is made with the payment, and its sign-in link is mailed to you: there you see and grow what you bought.';
const CARD_CHECK_GOING = 'Taking you to Stripe to check your card. Nothing is charged.';
function firstAccountLine() {
const step = page.steps[page.at];
const key = (page.mode === 'account' && step && step.id === FREE_ACCOUNT.id) ? 'account-free' : page.mode;
return (FIRST_ACCOUNT_LINES[key] || '').replace('{host}', location.hostname);
}
function firstAccount() {
const s = page.status || {};
return page.mode === 'account' && !(s.authenticated && s.user);
}
// ★ The owner's standing buy rule (2026-10-23): a PAID plan's button says "Pay <amount>" — the
// amount as monetize words the price (its words up to the currency code: "a month" is not paid) — never "Sign up";
// the free account keeps its own words.
function amountOf(words) {
const w = String(words || '').trim();
const m = w.match(/\d[\d\s.,]*\s?[A-Z]{3}\b/);
return m ? m[0].trim() : w;
}
function buttonWords() {
const visitorsAccount = firstAccount();
if (visitorsAccount) {
const step = page.steps[page.at];
if (step && step.id !== FREE_ACCOUNT.id) return BUTTONS.account.replace('{amount}', amountOf(step.month));
return FIRST_ACCOUNT_BUTTON.replace('{host}', location.hostname);
}
if (page.mode === 'private') {
const step = page.steps[page.at];
return (step && step.pay) ? BUTTONS.private.replace('{amount}', step.pay) : 'Pay';
}
return BUTTONS[page.mode];
}
// `monetize_pay::signup::USERNAME_RULE` and the kit's instance rule, mirrored
// as a courtesy: the server re-checks with its own copy and its sentence wins.
const RULE = /^[a-z0-9][a-z0-9._-]{1,31}$/;
const COMPANY_RULE = /^[a-z][a-z0-9]{0,15}$/;
const MAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const $ = (id) => document.getElementById(id);
const msg = $('msg');
function show(text, cls) { msg.textContent = text || ''; msg.className = 'msg ' + (cls || ''); }
// ★ **Never the browser's own words** (owner order 2026-10-18): a fetch that
// got no answer, an answer that would not parse, and a passkey ceremony's
// DOMException are said in plain words. This page's own Errors already
// carry sentences, and so do @@PRODUCT@@'s refusals.
const PASSKEY_WORDS = {
NotAllowedError: 'The passkey request was cancelled or timed out. Try again.',
AbortError: 'The passkey request was cancelled. Try again.',
InvalidStateError: 'This key already holds a passkey for this site.',
SecurityError: 'Passkeys do not work at this address. Open the page at its own https address.',
NotSupportedError: 'This browser or key cannot make a passkey here. Try another browser or key.',
ConstraintError: 'This browser or key cannot make a passkey here. Try another browser or key.',
};
function said(e) {
if (e instanceof TypeError) return '@@PRODUCT@@ did not answer. Check your connection and try again.';
if (e instanceof SyntaxError) return '@@PRODUCT@@\'s answer could not be read. Reload the page and try again.';
if (typeof DOMException !== 'undefined' && e instanceof DOMException) {
return Object.hasOwn(PASSKEY_WORDS, e.name) ? PASSKEY_WORDS[e.name] : 'Something went wrong with the passkey. Try again.';
}
return (e && e.message) || 'Something went wrong. Try again.';
}
// Only an absolute https:// URL is followed to the payment vendor — the
// signup page's rule since 2026-09-14, kept.
function safeCheckout(url) { return (typeof url === 'string' && url.startsWith('https://')) ? url : ''; }
// An account made here is followed to THIS console's own welcome page.
function safeWelcome(url) {
try { const u = new URL(url, location.href); return (u.origin === location.origin && u.pathname === '/welcome') ? u.href : ''; }
catch (e) { return ''; }
}
// ★ **Where the sign-in link goes, said after paying** (owner 2026-10-11,
// from a live purchase): the buyer's own address, typed on this page before
// the vendor's, is kept MASKED and only in this tab (sessionStorage survives
// the trip to the vendor and back) — so the page can say where the link went
// without the address being kept, or sent, anywhere.
const MAILED_KEY = '@@STORAGE@@signin_mailed';
function maskEmail(address) {
const a = String(address || '').trim();
const at = a.lastIndexOf('@');
if (at < 1 || at === a.length - 1) return '';
const domain = a.slice(at + 1);
const dot = domain.indexOf('.');
const host = dot > 0 ? domain.slice(0, dot) : domain;
return a[0] + '•••@' + host[0] + '•••' + (dot > 0 ? domain.slice(dot) : '');
}
function mailedTo() {
try { return sessionStorage.getItem(MAILED_KEY) || ''; } catch (e) { return ''; }
}
// ── What the page holds: the server's answers, and where the cursor is ──────
const page = {
status: null, // /auth/status
mode: '', // 'private' | 'account' | 'grow-account' | 'grow-instance'
planned: null, // monetize: the account holds a plan (false: free, null: not known)
window: null, // monetize's window, read once at start for the ladder
steps: [], // [{id, name, res, month, total, ok, why, sizes, machines}]
at: 0, // the step shown
floor: 0, // the lowest step the server allows
apex: '', // `<company>.<apex>`, as monetize names it
market: '', // the ONE market the account's plans are priced in (the server's)
busy: false,
asked: 0, // the newest ladder read (an older answer is dropped)
instance: '', // the instance `?instance=` names (Your purchases → Grow)
signin: '', // grow an instance, nobody signed in: where to sign in first
};
// ── The picture ──────────────────────────────────────────────────────────────
// Laid out in the svg's OWN pixels (its viewBox is its size), so the labels
// read at the same size on a phone and on a desktop, and the ring always
// fills the column. The boxes are sized by the step's PLACE on the ladder
// (0 … 1), never by a number of the page's own: the ladder is monetize's, the
// picture only shows "bigger". SERVER (main) is drawn the largest of the
// three, as it is; REPLICA the smallest.
const ROLES = ['front', 'main', 'twin'];
const SHARE = { front: 0.84, main: 1, twin: 0.74 };
function geometry() {
const svg = $('pic');
const W = Math.max(280, Math.round(svg.clientWidth || 640));
const H = Math.max(110, Math.round(svg.clientHeight || 150));
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
const ringH = H - 26;
const set = (id, a) => { const e = $(id); for (const k in a) e.setAttribute(k, String(a[k])); };
set('ring-outer', { x: 3, y: 3, width: W - 6, height: ringH, rx: Math.min(30, ringH / 3) });
set('ring-inner', { x: 11, y: 11, width: W - 22, height: ringH - 16, rx: Math.min(22, ringH / 3 - 6) });
set('pic-caption', { x: W / 2, y: H - 7 });
const cw = (W - 40) / 3;
const top = 20, resLine = 17, bottom = ringH - 6;
return { W, H, cw, maxW: Math.min(cw - 18, 190), maxH: Math.max(30, bottom - top - resLine - 6), top, resLine };
}
function box(g, i, role, w, h) {
const cx = 20 + g.cw * (i + 0.5);
const cy = g.top + (g.maxH) / 2 + 2;
const r = $('box-' + role);
r.style.x = (cx - w / 2) + 'px';
r.style.y = (cy - h / 2) + 'px';
r.style.width = w + 'px';
r.style.height = h + 'px';
$('label-' + role).setAttribute('x', String(cx));
$('label-' + role).setAttribute('y', String(cy + 4));
$('res-' + role).setAttribute('x', String(cx));
$('res-' + role).setAttribute('y', String(g.top + g.maxH + g.resLine));
return { x: cx - w / 2, y: cy - h / 2, w, h };
}
function slice(role, at, t) {
const s = $('slice-' + role);
if (!s) return;
// An svg element has no `hidden` property: the attribute is the switch.
if (!at) { s.setAttribute('hidden', ''); return; }
s.removeAttribute('hidden');
s.style.x = (at.x + 1.5) + 'px';
s.style.y = (at.y + at.h - 13) + 'px';
s.style.width = (Math.max(0.22, t) * (at.w - 3)) + 'px';
s.style.height = '11.5px';
}
function gb(mib) { return Math.round(Number(mib || 0) / 1024) + ' GB'; }
function drawn(step) {
const n = page.steps.length;
const t = (n > 1) ? (page.at / (n - 1)) : 0;
const account = page.mode === 'account' || page.mode === 'grow-account';
$('estate').classList.toggle('coming', !!page.signin);
const g = geometry();
ROLES.forEach((role, i) => {
// An account is a room on THIS instance: the hosts stay as they are and
// the room inside SERVER (and its copy on REPLICA) grows with the step.
const k = account ? 0.8 : (0.5 + 0.5 * t);
// Never narrower than its own label (REPLICA at the first step, on a phone).
const at = box(g, i, role, Math.max(84, g.maxW * SHARE[role] * k), Math.max(30, g.maxH * SHARE[role] * (account ? 0.8 : (0.55 + 0.45 * t))));
slice(role, account && role !== 'front' ? at : null, t);
$('label-' + role).textContent = HOST_LABELS[role];
let words = '';
if (step && Array.isArray(step.machines)) {
const m = step.machines.find(x => x.role === role);
if (m) words = m.cores + ' CPU · ' + gb(m.memory_mib);
} else if (step && account) {
words = role === 'main' ? 'your room' : (role === 'twin' ? 'its copy' : '');
}
$('res-' + role).textContent = words;
});
}
window.addEventListener('resize', () => drawn(page.steps[page.at]));
// ── The step and its price ───────────────────────────────────────────────────
function render() {
const step = page.steps[page.at];
drawn(step);
const coming = !!page.signin;
// ★ The server picture, the − / + and the price are a private @@PRODUCT@@'s
// (owner 2026-10-15): the account picks from a list instead.
const list = page.mode === 'account';
$('estate').hidden = !(page.mode === 'private' || page.mode === 'grow-instance');
drawPlans(list);
if (firstAccount()) $('first-account').textContent = firstAccountLine();
betaLine(!!page.mode && !page.paid);
$('size').hidden = coming || list || page.steps.length === 0;
$('minus').disabled = page.at <= page.floor;
$('plus').disabled = page.at >= page.steps.length - 1;
// ONE size on offer: nothing to step through, so no dead − / + (test2
// 2026-10-12: the ONE box sold one size, and both buttons sat disabled).
const one = page.steps.length - page.floor <= 1;
$('minus').style.visibility = one ? 'hidden' : '';
$('plus').style.visibility = one ? 'hidden' : '';
$('step-name').textContent = step ? step.name : (coming ? '' : '…');
const res = $('step-res');
res.textContent = step ? ((step.ok || step.now) ? step.res : (step.why || step.res)) : '';
res.className = (step && !step.ok && !step.now) ? 'why' : '';
$('price').hidden = coming || list;
$('price-month').textContent = step ? step.month : '';
$('price-total').textContent = step ? step.total : '';
const go = $('go');
go.hidden = coming;
if (!page.busy) go.disabled = !step || !step.ok;
if (!page.busy) go.textContent = buttonWords();
}
// The account's plans, one row each: its name, its price, and a note.
function planRow(step, i) {
const b = document.createElement('button');
b.type = 'button';
b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', String(i === page.at));
b.dataset.plan = step.id;
b.disabled = !step.ok;
const name = document.createElement('span'); name.className = 'plan-name'; name.textContent = step.name;
const price = document.createElement('span'); price.className = 'plan-price'; price.textContent = step.month;
// The free account's line; a last-known offer says buying is off.
const note = document.createElement('span'); note.className = 'plan-note'; note.textContent = step.note || (step.now ? step.total : '');
b.append(name, price, note);
b.addEventListener('click', () => { if (step.ok && page.at !== i) { page.at = i; render(); } });
return b;
}
function drawPlans(on) {
const list = $('plans');
list.hidden = !on || page.steps.length === 0;
list.textContent = '';
if (on) page.steps.forEach((step, i) => list.appendChild(planRow(step, i)));
}
// The ONE box's name for the room and the period it sells it for ("· 1
// month", "· 6 months"), from its own `period_days`; a name that already says
// it is kept.
function planName(p) {
const name = String(p.name || '');
const months = Math.round(Number(p.period_days || 0) / 30);
if (name.includes('·') || months < 1) return name;
return name + ' · ' + months + (months === 1 ? ' month' : ' months');
}
function move(by) {
const next = Math.min(page.steps.length - 1, Math.max(page.floor, page.at + by));
if (next !== page.at) { page.at = next; render(); }
}
// ── The situation: who is here, and what this console sells ─────────────────
function heading() {
const company = (page.status && page.status.instance) || page.instance || '';
const words = (firstAccount() ? FIRST_ACCOUNT_HEADING
: page.mode === 'grow-account' ? growWords() : HEADINGS[page.mode]).replace('{company}', company);
$('heading').textContent = words;
// The name keeps its capital (@@PRODUCT@@ is a name): the heading, as is.
document.title = words + ' — @@PRODUCT@@';
$('go').textContent = buttonWords();
}
// The visitor's private choice (hidden 2026-10-18, SHOWN again: owner 2026-10-21; named TAB_PRIVATE since BUY-ONE-2).
// Signed in, "New instance" is unchanged.
const OFFER_PRIVATE_TO_VISITORS = true;
// ★ "Manage <company>" (owner 2026-10-22): beside Grow, a link to the instance's own page
// that renews, stops renewing and terminates it (renewing() below).
const MANAGE = 'Manage {company}';
const SIGNUP_PRIVATE_RENEW = '@@MANAGE@@';
function manageUrl(company) { return SIGNUP_PRIVATE_RENEW + encodeURIComponent(company); }
function choices() {
const s = page.status || {};
const out = [];
if (s.authenticated) {
// The account's tab first, the private one beside it (BUY-ONE-2), as for a visitor.
if (s.buy_url) out.push({ mode: 'grow-account', words: TAB_ACCOUNT_SIGNED_IN });
if (s.private_url) out.push({ mode: 'private', words: TAB_PRIVATE });
const grows = s.instance || page.instance;
if (grows) {
out.push({ mode: 'grow-instance', words: HEADINGS['grow-instance'].replace('{company}', grows) });
out.push({ mode: 'manage', words: MANAGE.replace('{company}', grows), href: manageUrl(grows) });
}
} else {
// ★ LEFT and chosen first, the account on this site; RIGHT, a private
// @@PRODUCT@@ instance (owner 2026-10-15). The host is the one this page was
// served from; no plan from monetize (/auth/status account_offer) is no
// account choice.
if (s.account_offer && s.buy_url && s.buy_url.startsWith('/signup')) out.push({ mode: 'account', words: 'Account on ' + location.hostname });
// Offered to a visitor again (owner 2026-10-21; hidden 2026-10-18 to 2026-10-21).
if (OFFER_PRIVATE_TO_VISITORS && s.private_url) out.push({ mode: 'private', words: TAB_PRIVATE });
}
return out;
}
function drawChoices(list) {
const row = $('choice');
row.textContent = '';
for (const c of list) {
// A link (Manage) goes to its own page; it is never a choice of this one.
if (c.href) {
const a = document.createElement('a');
a.href = c.href;
a.textContent = c.words;
a.dataset.mode = c.mode;
row.appendChild(a);
continue;
}
const b = document.createElement('button');
b.type = 'button';
b.textContent = c.words;
b.dataset.mode = c.mode;
b.setAttribute('aria-pressed', String(c.mode === page.mode));
b.addEventListener('click', () => choose(c.mode));
row.appendChild(b);
}
row.hidden = list.length < 2;
}
function fields() {
const m = page.mode;
const s = page.status || {};
const signedIn = !!(s.authenticated && s.user);
$('f-account').hidden = !(signedIn || m === 'private' || m === 'account' || m === 'grow-account');
// ★ The e-mail is asked once, for a NEW account only (owner 2026-10-22: it is not changed in
// the buy): signed in, the order mails the account's own address.
reveal($('f-email'), (m === 'private' || m === 'account') && !signedIn);
const first = firstAccount();
$('f-company').hidden = m !== 'private';
$('f-domain').hidden = m !== 'private';
const line = first ? firstAccountLine() : (m === 'private' && !signedIn) ? PRIVATE_VISITOR_LINE.replace('{host}', location.hostname) : '';
$('first-account').hidden = !line;
$('first-account').textContent = line;
$('email-label').textContent = m === 'private' ? 'E-mail for your new account' : 'E-mail for your welcome mail';
// Signed in: the account is the session's, filled in and LOCKED. The lock
// is the server's too — the console sends the session's name, not this field.
// On the private choice it is one short line (owner 2026-10-22): "<name> (you)".
const acct = $('account');
if (signedIn) {
acct.value = s.user;
acct.readOnly = true;
// ★ "<name> (you)" on every choice (owner 2026-10-23).
acct.hidden = true;
$('account-hint').textContent = s.user + ' (you)';
} else {
acct.readOnly = false;
acct.hidden = false;
$('account-hint').textContent = '';
}
reveal($('form'), !(m === 'grow-instance' && !signedIn));
}
// **A private @@PRODUCT@@ is sold where /auth/status says** (2026-10-07): on a
// sold instance that is its provider's sign-up page, an https address on
// another site — the browser goes there; this console sells none itself.
function elsewhere(url) {
try { return typeof url === 'string' && url.startsWith('https://') && new URL(url).origin !== location.origin; }
catch (e) { return false; }
}
function choose(mode) {
// A PROVIDED private @@PRODUCT@@ sells no new instance itself: its provider does
// (/auth/status private_url, an absolute https address elsewhere). Go there.
const s = page.status || {};
if (mode === 'private' && elsewhere((page.status || {}).private_url)) {
location.assign(s.private_url);
return;
}
page.mode = mode;
// What the "choose one" page concealed is the choice's to draw again (ALICA-2).
for (const id of UNCHOSEN_HIDES) { const e = $(id); if (e) e.style.display = ''; }
for (const b of $('choice').querySelectorAll('button')) b.setAttribute('aria-pressed', String(b.dataset.mode === mode));
heading();
fields();
show('');
page.steps = []; page.at = 0; page.floor = 0;
render();
ladder();
}
// ── The ladder, as the server answers it ─────────────────────────────────────
// The words when the shop does not answer (`auth::signup::SHOP_UNAVAILABLE`).
const SHOP_DOWN = 'The shop is unavailable right now — try again in a few minutes.';
async function getJson(url) {
const r = await fetch(url, { credentials: 'same-origin', headers: authHeaders() });
const j = await r.json().catch(() => null);
if (!r.ok) throw new Error((j && (j.error || j.message || j.reason)) || SHOP_DOWN);
return j;
}
async function ladder() {
const mine = ++page.asked;
const mode = page.mode;
$('coming').hidden = true;
try {
let steps = [], start = 0, floor = 0;
if (mode === 'grow-instance') {
// ★ **Grow an instance** (one shop 4, 2026-10-07): its own ladder — the
// step it is laid at and the floor its data allows — for the signed-in
// account that owns it (the server rides its ticket along).
const company = (page.status && page.status.instance) || page.instance || '';
const j = await getJson('/auth/private/ladder?company=' + encodeURIComponent(company));
page.apex = j.apex || '';
steps = (j.steps || []).map(s => ({ id: s.name, name: s.name, res: s.resources_words, month: s.monthly_words, total: s.total_words,
ok: s.available !== false, why: s.reason || '', sizes: s.sizes, machines: s.machines }));
if (j.laid && steps[j.current]) Object.assign(steps[j.current], { ok: false, now: true, total: company + '’s size now' });
start = j.start || 0; floor = j.floor || 0;
} else if (mode === 'private') {
const j = await getJson('/auth/private/ladder');
page.apex = j.apex || '';
steps = (j.steps || []).map(s => ({ id: s.name, name: s.name, res: s.resources_words, month: s.monthly_words, total: s.total_words,
pay: s.pay_words || '', ok: s.available !== false, why: s.reason || '', sizes: s.sizes, machines: s.machines }));
start = j.start || 0; floor = j.floor || 0;
} else if (mode === 'account') {
// ONE market's plans (owner 2026-10-08: the − / + steps through sizes,
// never through currencies): the server reads the browser's languages
// and names the market it answered in; the order sends it back.
// ★ The shop not answering (owner 2026-10-12): one plain sentence, and
// the last offer this console was given, READ-ONLY — nothing is bought
// from it.
const r = await fetch('/auth/signup/ladder', { credentials: 'same-origin' });
const j = await r.json().catch(() => null) || {};
if (!r.ok && j.unavailable) {
if (mine !== page.asked) return;
const last = (j.last_known && j.last_known.plans) || [];
page.steps = last.map(p => ({ id: p.id, name: planName(p), res: p.resources_words, month: p.price_words,
total: 'last known price — buying is off', ok: false, now: true, why: '' }));
page.floor = 0; page.at = 0;
render();
show(j.error, 'err');
return;
}
if (!r.ok) throw new Error(j.error || j.reason || SHOP_DOWN);
page.market = j.market || '';
// The free account first, then the ONE box's plans in its order; the
// first plan it sells is the one chosen when the page opens.
// A ONE box that lists the free account itself does not add a second row to the one above.
const sold = (j.plans || []).filter(p => p.id !== FREE_ACCOUNT.id).map(p => ({ id: p.id, name: planName(p), res: p.resources_words, month: p.price_words, total: '',
ok: p.available !== false, why: '' }));
steps = [{ id: FREE_ACCOUNT.id, name: FREE_ACCOUNT.name, res: '', month: FREE_ACCOUNT.price, total: '', note: FREE_ACCOUNT.note, ok: true, why: '' }].concat(sold);
start = sold.length ? 1 : 0;
} else if (mode === 'grow-account') {
const j = page.window || await getJson('/api/account/subscription/bundles');
page.window = null;
if (j.pending && !(j.offers || []).length) { show(j.pending); setTimeout(() => { if (mine === page.asked) ladder(); }, 3000); return; }
if (j.unavailable && !(j.offers || []).length) throw new Error(j.unavailable);
// The rows that make the account BIGGER, in monetize's order. Prolonging
// and renewing are the Account page's (they keep the size).
steps = (j.offers || []).filter(o => o.action !== 'prolong' && o.action !== 'renew').map(o => ({ id: o.id, name: o.label, res: '',
month: o.price_words, total: o.period ? ('for one ' + o.period) : '', ok: !!o.can_serve, why: o.reason || '' }));
// The picture starts at the room this account HAS (monetize's plan),
// and the − stops there: nothing below it is offered, the room never
// shrinks below what is stored.
const plan = j.plan || {};
// monetize's word on the plan, now known: the tab and heading follow it.
if (j.plan || !j.pending) { page.planned = !!(plan.state && plan.state !== 'none'); heading(); drawChoices(choices()); }
if (plan.label && plan.state && plan.state !== 'none') {
steps.unshift({ id: '', name: plan.label, res: plan.ends_words || '', month: plan.price_words || '', total: 'your plan now', ok: false, now: true, why: '' });
}
start = 0; floor = 0;
}
if (mine !== page.asked) return;
page.steps = steps;
page.floor = Math.min(floor, Math.max(0, steps.length - 1));
page.at = Math.max(page.floor, Math.min(start, steps.length - 1));
if (!steps.length) show('Nothing is on sale here right now.', 'err');
render();
} catch (e) {
if (mine !== page.asked) return;
page.steps = [];
render();
// A network failure is the shop not answering, said plainly.
show((e instanceof TypeError) ? SHOP_DOWN : (e.message || SHOP_DOWN), 'err');
}
}
// ── The company's name, as it will be served ─────────────────────────────────
function companyHint() {
const c = $('company').value.trim().toLowerCase();
$('company-hint').textContent = (c && page.apex) ? (c + '.' + page.apex) : '';
}
// ── Sign up / Upgrade ────────────────────────────────────────────────────────
async function post(url, body) {
const r = await fetch(url, { method: 'POST', credentials: 'same-origin', headers: authHeaders({ 'content-type': 'application/json' }), body: JSON.stringify(body) });
const text = await r.text();
let j = null; try { j = JSON.parse(text); } catch (e) {}
if (!r.ok) throw new Error((j && (j.error || j.words || j.reason)) || SHOP_DOWN);
return j || {};
}
function working(on) {
const go = $('go');
page.busy = on;
go.disabled = on;
go.classList.toggle('working', on);
go.textContent = on ? 'Processing …' : buttonWords();
if (!on) render();
}
async function submit() {
if (page.busy) return;
const step = page.steps[page.at];
if (!step || !step.ok) return;
const s = page.status || {};
const signedIn = !!(s.authenticated && s.user);
const account = $('account').value.trim().toLowerCase();
const email = $('email').value.trim();
if ((page.mode === 'private' || page.mode === 'account') && !signedIn) {
if (!RULE.test(account)) { show('An account name is 2 to 32 characters of a-z, 0-9, dot, underscore or dash, starting with a letter or a digit.', 'err'); $('account').focus(); return; }
if (!MAIL.test(email)) { show('Type the e-mail address the mail should go to.', 'err'); $('email').focus(); return; }
}
working(true);
try {
let url = '';
if (firstAccount()) {
// Step one of two: the account here, then back on this choice signed in.
show('Checking …');
const free = await getJson('/auth/signup/name?u=' + encodeURIComponent(account));
if (!free.free) throw new Error(free.reason || 'Choose another account name.');
show('Creating your account …');
// Where the sign-in link goes, said on the welcome page: MASKED, this tab only.
try { sessionStorage.setItem(MAILED_KEY, maskEmail(email)); } catch (e) {}
// ★ B (owner 2026-10-15): a PLAN row goes to Stripe once — its own payment, the account
// made once the payment is confirmed (monetize holds the name meanwhile), then on to the
// console. The free row makes the free account (Stripe's card check, nothing charged).
// A private @@PRODUCT@@ no longer comes here: it pays once (owner 2026-10-22).
const freeOnly = page.mode === 'account' && step.id === FREE_ACCOUNT.id;
const planRow = page.mode === 'account' && !freeOnly;
const j = await post('/auth/signup/start', planRow
? { username: account, email, plan: step.id, next: '/' }
: { username: account, email, plan: '@@FREE_ACCOUNT@@', next: '/' });
url = safeWelcome(j.url);
const check = url ? '' : safeCheckout(j.url);
if (!url && !check) throw new Error('Your account could not be made. Please try again.');
try { sessionStorage.setItem(CARD_CHECK_KEY, (check && !planRow) ? '1' : ''); } catch (e) {}
if (check) {
// monetize's answer: Stripe's page (the card check, or the plan's payment), then back
// to the welcome page.
show(planRow ? 'Taking you to pay …' : CARD_CHECK_GOING, 'ok');
location.assign(check);
return;
}
show('Taking you to register your passkey …', 'ok');
location.assign(url);
return;
} else if (page.mode === 'private') {
const company = $('company').value.trim().toLowerCase();
if (!COMPANY_RULE.test(company)) { throw new Error('A company name is 1 to 16 letters and digits, starting with a letter.'); }
show('Opening the payment page …');
// Where the links go, said after paying: a visitor's typed address, MASKED, this tab only;
// signed in, the account's own (never typed here).
try { if (signedIn) sessionStorage.removeItem(MAILED_KEY); else sessionStorage.setItem(MAILED_KEY, maskEmail(email)); } catch (e) {}
const domain = $('domain').value.trim();
// ★ Pay once (owner 2026-10-22): signed in, the account's order (no e-mail, no name: the
// session's); a visitor's names the new account and its e-mail.
const j = await post('/auth/private/start', signedIn
? { company, domain, front_plan: step.sizes.front_plan, main_plan: step.sizes.main_plan, main_data_gib: step.sizes.main_data_gib }
: { company, email, account, domain, front_plan: step.sizes.front_plan, main_plan: step.sizes.main_plan, main_data_gib: step.sizes.main_data_gib });
url = safeCheckout(j.url);
} else if (page.mode === 'grow-instance') {
const company = (s.instance || page.instance || '').toLowerCase();
show('Opening the payment page …');
const j = await post('/auth/private/change', { company, front_plan: step.sizes.front_plan, main_plan: step.sizes.main_plan, main_data_gib: step.sizes.main_data_gib });
url = safeCheckout(j.url);
// Nothing to pay now (a smaller plan, the period's end): queued, said.
if (!url) { show(j.words || 'Done.', 'ok'); working(false); return; }
} else if (page.mode === 'grow-account') {
show('Opening the payment page …');
const j = await post('/api/account/subscription/buy-bundle', { bundle: step.id });
url = safeCheckout(j.url);
if (!url) { show(j.words || j.reason || 'Done.', j.state === 'refused' ? 'err' : 'ok'); working(false); return; }
}
if (!url) throw new Error('The payment page did not open. Nothing has been charged — please try again.');
show('Taking you to pay …', 'ok');
location.assign(url);
} catch (e) {
show(said(e), 'err');
working(false);
}
}
// ── After paying for a private @@PRODUCT@@: where the order has got to ────────────
async function watch(company) {
const box = $('state');
box.hidden = false;
let j = null, ok = false;
try {
const r = await fetch('/auth/private/status?company=' + encodeURIComponent(company), { headers: authHeaders() });
j = await r.json().catch(() => null);
ok = r.ok;
} catch (e) {}
box.textContent = '';
// The mail first: paid is paid, and the link is how the buyer gets in.
const mailed = mailedTo() || 'your account’s e-mail address';
const paid = ok && j && ['queued', 'waiting_for_dns', 'laying', 'installing', 'ready'].includes(j.state);
if (paid) {
const mail = document.createElement('p');
mail.id = 'mailed';
mail.style.margin = '0 0 .5rem';
mail.textContent = j.state === 'ready'
? 'Payment received. We’ve sent a sign-in link to ' + mailed + '. Open it to register your security key.'
: 'Payment received. When ' + company + ' is ready we’ll send a sign-in link to ' + mailed + '.';
box.appendChild(mail);
if (j.state === 'ready') box.append(sendAgain(company), ' ');
}
const head = document.createElement('p');
head.style.margin = '0';
head.textContent = (ok && j) ? (j.words || j.state) : ((j && j.error) || 'no answer yet');
box.appendChild(head);
if (ok && j && Array.isArray(j.records) && j.records.length) {
const t = document.createElement('table');
for (const rec of j.records) { const tr = t.insertRow(); for (const v of [rec.name, rec.kind, rec.value]) tr.insertCell().textContent = v; }
box.appendChild(t);
}
// ★ Back from paying (BUYFLOW-4): "paid, setting up" — the steps the order goes through and
// where it stands now, then the instance's link once it is ready.
if (paid && page.paid === company) box.appendChild(progress(j.state));
const link = (ok && j && typeof j.url === 'string' && j.url.startsWith('https://')) ? j.url
: (paid && page.paid === company && j.state === 'ready') ? addressIn(j.words) : '';
if (link) {
const a = document.createElement('a'); a.href = link; a.textContent = link; box.appendChild(a);
}
if (!ok || !j || (j.state !== 'ready' && j.state !== 'failed')) setTimeout(() => watch(company), 3000);
}
// The steps a paid order goes through, in the buyer's words, and the order's state at each.
const PROGRESS = ['Payment received', 'Being prepared', 'Your machines are being set up', '@@PRODUCT@@ is being installed', 'Ready'];
const PROGRESS_AT = { queued: 1, waiting_for_dns: 1, laying: 2, installing: 3, ready: PROGRESS.length };
function progress(state) {
const now = PROGRESS_AT[state] || 1;
const ol = document.createElement('ol');
ol.id = 'progress';
PROGRESS.forEach((words, i) => {
const li = document.createElement('li');
li.className = i < now ? 'done' : (i === now ? 'now' : 'todo');
li.textContent = words;
ol.appendChild(li);
});
return ol;
}
// The instance's own address as the order's words say it (`… ready at https://<host>; …`): a
// visitor's answer carries no address of its own.
function addressIn(words) {
const m = /https:\/\/[A-Za-z0-9.-]+/.exec(String(words || ''));
return m ? m[0].replace(/[.-]+$/, '') : '';
}
// The page once paid for `company`: its heading and the order's status, every step, field and
// button of the order hidden (never drawn again on this visit: nothing calls render()).
const PAID_HIDES = ['choice', 'plans', 'form', 'estate', 'size', 'price', 'go', 'coming'];
function paid(company, change) {
page.paid = company;
$('heading').textContent = company + '’s @@PRODUCT@@';
document.title = company + '’s @@PRODUCT@@';
for (const id of PAID_HIDES) conceal($(id));
betaLine(false);
// Back from paying for a grow: the machines restart while it is made — said first.
if (change) {
const said = document.createElement('p');
said.id = 'change-restarts';
said.textContent = 'Payment received. Your change is being made: the machines restart for a few minutes while it is done, and come back by themselves.';
$('state').before(said);
}
watch(company);
}
// **A member buys nothing** (owner 2026-10-07: a customer is an ACCOUNT that
// pays; its MEMBERS use its space and ask its admin for more). @@PRODUCT@@ says who
// is a member (/auth/status account_role, from Access.WhoAmI), and the server
// refuses a member's order besides: this page says so instead of a shop.
function member() {
const s = page.status || {};
const account = s.account || '';
const admin = s.account_admin || account;
$('heading').textContent = 'You are a member of ' + account;
document.title = 'You are a member of ' + account + ' — @@PRODUCT@@';
for (const id of ['choice', 'plans', 'form', 'estate', 'size', 'price', 'go', 'coming']) conceal($(id));
show((s.account_line || ('Your space comes from ' + account + '. Ask ' + admin + ' for more room.')) +
' Members don’t buy: ' + admin + ' buys room for ' + account + '.', 'member');
}
// ★ **Nothing chosen yet** (ALICA-2): the choices, and no plan, price, field or button until
// the buyer presses one of them.
const UNCHOSEN_HIDES = ['plans', 'form', 'estate', 'size', 'price', 'go'];
const CHOOSE_HEADING = 'What would you like?';
function unchosen() {
$('heading').textContent = CHOOSE_HEADING;
document.title = CHOOSE_HEADING + ' — @@PRODUCT@@';
for (const id of UNCHOSEN_HIDES) conceal($(id));
betaLine(false);
$('choice').hidden = false;
show('Choose one above to see its plans and price.');
}
// ── Start ────────────────────────────────────────────────────────────────────
$('minus').addEventListener('click', () => move(-1));
$('plus').addEventListener('click', () => move(1));
$('go').addEventListener('click', submit);
$('company').addEventListener('input', companyHint);
$('form').addEventListener('submit', (e) => { e.preventDefault(); submit(); });
drawn(null);
(async function start() {
const q = new URLSearchParams(location.search);
// The site's own word for the account choice opens it as Gunnar's `10gb` does.
if ((q.get('plan') || '').toLowerCase() === '@@ACCOUNT_PLAN@@') q.set('plan', '10gb');
try {
const r = await fetch(SITE.status, { credentials: 'same-origin', headers: authHeaders() });
page.status = statusOf(r.ok ? await r.json() : {});
} catch (e) { page.status = {}; }
page.status = sold(page.status, await offerHere());
// The server's word that this is an account's member (an employee, or one appointed its
// admin): its members line. A member buys nothing.
if (page.status.authenticated && page.status.account_line) {
member();
return;
}
// ★ **Back from paying: the order's status, and nothing to pay** (owner 2026-10-23: Stripe
// ONCE per buy; "double still", test2.gunnar.rs 2026-10-25). The vendor's way back
// (`/paid/estate/<company>`, or the welcome page's `next`) lands here with `company=`: no
// step, field or button is drawn, so no second payment is offered — on a reload too. A visit
// without `company=` is a fresh one and shows the order form.
const paidFor = q.get('company');
if (paidFor && COMPANY_RULE.test(paidFor)) {
paid(paidFor, q.get('change') === '1');
return;
}
// ★ "Grow" from Your purchases names the instance it grows (one shop,
// 2026-10-07): the instance rule, read once; signed in, on this shop.
const asked_instance = (q.get('instance') || '').toLowerCase();
page.instance = (page.status.authenticated && COMPANY_RULE.test(asked_instance)) ? asked_instance : '';
// From an instance's own admin page ("Grow this instance"), nobody signed
// in here: the instance is grown by its account HERE — sign in first, and
// come back to this very page.
if (!page.status.authenticated && (q.get('plan') || '').toLowerCase() === 'grow' && COMPANY_RULE.test(asked_instance)) {
page.mode = 'grow-instance';
page.instance = asked_instance;
page.signin = '/login?next=' + encodeURIComponent(location.pathname + location.search);
heading();
for (const id of ['choice', 'form', 'size', 'price', 'go']) conceal($(id));
drawn(null);
const box = $('coming');
box.hidden = false;
box.textContent = asked_instance + '’s @@PRODUCT@@ is grown by its account at ' + location.hostname + '. ';
const a = document.createElement('a');
a.href = page.signin;
a.textContent = 'Sign in at ' + location.hostname;
box.appendChild(a);
return;
}
// The account's plan, monetize's word, before the tabs are drawn: a free
// account's tab and heading say "Buy a plan". The ladder reuses this read.
if (page.status.authenticated && page.status.buy_url) {
try {
const w = await getJson('/api/account/subscription/bundles');
if (!(w.pending && !(w.offers || []).length)) page.window = w;
if (w.plan || !w.pending) page.planned = !!(w.plan && w.plan.state && w.plan.state !== 'none');
} catch (e) {}
}
const list = choices();
// `?plan=` preselects (the front page's two links); a signed-in visitor's
// `10gb` is their own account growing. "Sign up for a new" is ALWAYS a new
// instance, never an increase.
const asked = (q.get('plan') || '').toLowerCase();
// ★ With no `?plan=`, NOTHING is preselected (ALICA-2, owner 2026-10-26): a buyer who
// wanted a private instance paid for an account without noticing it was the chosen tab.
// The buyer picks one; only a page with a single thing on offer opens it at once.
const want = asked === '10gb' ? (page.status.authenticated ? 'grow-account' : 'account')
: asked === 'grow' ? ((page.status.instance || page.instance) ? 'grow-instance' : 'grow-account')
: asked === 'private' ? 'private'
: '';
// A PROVIDED console's private choice is its provider's page, reached only
// by pressing it (the server sends ?plan=private there itself): never the
// first thing this page does, unless it is the only thing on offer.
const here = list.filter(c => !c.href && !(c.mode === 'private' && /^https:\/\//.test(page.status.private_url || '')));
const pick = here.length ? here : list;
const chosen = list.filter(c => !c.href);
if (!want && chosen.length >= 2) {
page.mode = '';
drawChoices(list);
unchosen();
const renew = (q.get('renew') || '').toLowerCase();
if (COMPANY_RULE.test(renew)) renewing(renew);
return;
}
const mode = pick.some(c => c.mode === want) ? want : (pick[0] ? pick[0].mode : 'private');
page.mode = mode;
drawChoices(list);
if (!list.length) {
heading(); fields(); render();
show('Nothing is sold by this @@HERE@@.', 'err');
return;
}
choose(mode);
const renew = (q.get('renew') || '').toLowerCase();
if (COMPANY_RULE.test(renew)) renewing(renew);
})();
// ── Renew an instance, or stop renewing it (A52/A51) ─────────────────────────
// The renewal mail's link and "Manage <company>" (the choice row, Your purchases) lead here. The account's
// own: signed in, monetize answers the payment page or what stopping means.
function renewing(company) {
const box = $('coming');
box.hidden = false;
box.textContent = '';
if (!page.status.authenticated) {
const a = document.createElement('a');
a.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
a.textContent = 'Sign in at ' + location.hostname + ' to renew ' + company;
box.appendChild(a);
return;
}
const go = document.createElement('button');
go.type = 'button'; go.className = 'btn primary'; go.id = 'renew-go'; go.textContent = 'Renew ' + company;
go.onclick = async () => {
try {
show('Opening the payment page …');
const j = await post('/auth/private/renew', { company });
const url = safeCheckout(j.url);
if (!url) throw new Error('The payment page did not open. Nothing has been charged — please try again.');
location.assign(url);
} catch (e) { show(said(e), 'err'); }
};
const stop = document.createElement('button');
stop.type = 'button'; stop.className = 'btn'; stop.id = 'renew-stop'; stop.textContent = 'Stop renewing';
stop.onclick = async () => {
try { const j = await post('/auth/private/cancel', { company }); show(j.words || 'Done.', 'ok'); } catch (e) { show(said(e), 'err'); }
};
box.append(go, ' ', stop, ' ', terminating(company));
watch(company);
}
// TERMINATE (owner 2026-10-12; no backup at all, "ta ingen backup alls" 2026-10-22): the
// account's admin ends the instance — its machines, disks and records go, for good. The name is
// typed back; the request carries no backup word (A32-27); the console asks for a fresh
// login if the last one is not recent. monetize answers.
function terminating(company) {
const wrap = document.createElement('span');
const open = document.createElement('button');
open.type = 'button'; open.className = 'btn'; open.id = 'terminate-open'; open.textContent = 'Terminate ' + company;
const pane = document.createElement('div');
pane.hidden = true; pane.className = 'terminate-pane';
const offer = document.createElement('p');
offer.textContent = 'Terminating ' + company + ' ends it for good: its machines, disks and records go.';
const ask = document.createElement('label');
ask.textContent = 'Type ' + company + ' to confirm: ';
const typed = document.createElement('input'); typed.type = 'text'; typed.id = 'terminate-name'; typed.autocomplete = 'off'; typed.spellcheck = false;
ask.appendChild(typed);
const go = document.createElement('button');
go.type = 'button'; go.className = 'btn'; go.id = 'terminate-go'; go.disabled = true;
go.textContent = 'Terminate ' + company;
typed.oninput = () => { go.disabled = typed.value.trim() !== company; };
go.onclick = async () => {
if (typed.value.trim() !== company) return;
go.disabled = true;
try {
const j = await post('/auth/private/terminate', { company, confirm: typed.value.trim() });
show(j.words || 'Done.', j.done ? 'ok' : '');
} catch (e) { show(said(e), 'err'); go.disabled = false; }
};
open.onclick = () => { pane.hidden = false; open.hidden = true; typed.focus(); };
pane.append(offer, ask, ' ', go);
wrap.append(open, pane);
return wrap;
}
// S10 (OM-23): the ready mail's one sign-in link lost or run out — a new one,
// minted by the instance and mailed to the order's address (monetize
// `EstateOrder.SendSignInAgain`). The account's own: signed in here.
function sendAgain(company) {
const again = document.createElement('button');
again.type = 'button'; again.className = 'btn'; again.textContent = 'Send again';
again.onclick = async () => {
again.disabled = true;
try { const j = await post('/auth/private/sign-in-again', { company }); show(j.words || 'Sent.', 'ok'); } catch (e) { show(said(e), 'err'); }
finally { again.disabled = false; }
};
return again;
}