diff --git a/apps/simplex-badge-service/web/dist/catalog.js b/apps/simplex-badge-service/web/dist/catalog.js
new file mode 100644
index 0000000000..5bd4563a4b
--- /dev/null
+++ b/apps/simplex-badge-service/web/dist/catalog.js
@@ -0,0 +1,222 @@
+// The catalog: the payload `GET /api/catalog` serves, the site constants it
+// does not carry, and the one money formatter.
+//
+// THE BROWSER COMPUTES NO CHARGEABLE AMOUNT. `offerTotal` in
+// BadgeService/Catalog.hs is the only implementation of a total in the system;
+// D4 serves its result in every offer's `total`; this module copies that number
+// into a Selection and view.ts renders it. The one multiplication here is
+// `savingOn`, which is a DISPLAY-ONLY comparison and is never sent anywhere —
+// see its comment. `POST /api/checkout` (D6) carries `{priceId, offerId?,
+// method}` and no amount at all, so the price shown and the price charged
+// cannot drift.
+//
+// Nothing here touches the DOM, so all of it is unit-tested with `node --test`
+// (../test/catalog.test.mjs).
+const ACTIVE = "active";
+const DEPRECATED = "deprecated";
+export const TIERS = [
+ { badgeType: "supporter", label: "Supporter", perk: "2 GB files" },
+ { badgeType: "legend", label: "Legend", perk: "5 GB files" },
+];
+/** The durations offered, in months. A4 seeds an offer for 3 and 12 only. */
+export const DURATIONS = [1, 3, 12];
+/** The one duration with no offer: it is charged at the price's `monthPrice`,
+ * and its checkout request carries no `offerId` (D6 reads exactly one month
+ * from that absence). */
+export const MONTHS_WITHOUT_OFFER = 1;
+/** `CurrencyAmount` is a `Word32` (PaymentService/Types.hs). */
+export const MAX_MINOR_UNITS = 4294967295;
+const CURRENCY_SYMBOLS = new Map([["usd", "$"]]);
+const MINOR_UNITS_IN_MAJOR = 100;
+/**
+ * A minor-unit amount as text: `700` and `"usd"` render as `$7.00`.
+ *
+ * This is integer formatting, not arithmetic on a price — the amount is
+ * displayed exactly as it arrived. A currency with no symbol renders as its
+ * ISO code before the digits (`EUR 12.34`), so an unknown currency is never
+ * shown as a bare number that could be read as dollars. The one formatter in
+ * the site: no other module formats money.
+ */
+export function formatAmount(minorUnits, currency) {
+ if (!Number.isInteger(minorUnits) || minorUnits < 0 || minorUnits > MAX_MINOR_UNITS) {
+ throw new RangeError(`not a minor-unit amount: ${minorUnits}`);
+ }
+ const major = Math.trunc(minorUnits / MINOR_UNITS_IN_MAJOR);
+ const minor = minorUnits % MINOR_UNITS_IN_MAJOR;
+ const digits = minor < 10 ? `0${minor}` : `${minor}`;
+ const symbol = CURRENCY_SYMBOLS.get(currency.toLowerCase());
+ return symbol === undefined ? `${currency.toUpperCase()} ${major}.${digits}` : `${symbol}${major}.${digits}`;
+}
+/**
+ * The price to sell `badgeType` at, or null if there is none to sell.
+ *
+ * Prefers an `active` row over a `deprecated` one, then the most recently
+ * created; a tie keeps the payload's order. Repricing appends a new price and
+ * deprecates the old one (UX §3), so a badge type can legitimately have more
+ * than one row here and the newest active one is the current price.
+ */
+export function priceForTier(catalog, badgeType, use) {
+ return best(catalog.prices.filter((p) => p.badgeType === badgeType && usable(p.status, use)));
+}
+/** The offer to sell `months` of `priceId` at, or null if there is none. An
+ * offer the service could not price (`total: null`) is not one. */
+export function offerForMonths(catalog, priceId, months, use) {
+ return best(catalog.offers.filter((o) => o.priceId === priceId && o.months === months && o.total !== null && usable(o.status, use)));
+}
+/**
+ * The selection for a tier and a duration, or null when that combination
+ * cannot be sold.
+ *
+ * `use` applies to the OFFER. The price is always looked up as `chosen`,
+ * because `badgeType` is an answer the visitor has already given — the tier
+ * screen is where a deprecated price is kept out of the fresh choices, and by
+ * the time a duration is being priced the tier is settled.
+ */
+export function selectionFor(catalog, badgeType, months, use) {
+ const price = priceForTier(catalog, badgeType, "chosen");
+ if (!price)
+ return null;
+ if (months === MONTHS_WITHOUT_OFFER)
+ return { price, offer: null, months, total: price.monthPrice };
+ const offer = offerForMonths(catalog, price.priceId, months, use);
+ // `offer.total` is non-null by offerForMonths' filter; this reads it rather
+ // than asserting it, because a total is not a thing to assume.
+ return offer && offer.total !== null ? { price, offer, months, total: offer.total } : null;
+}
+/**
+ * DISPLAY ONLY. What the same duration would cost at the monthly price, less
+ * what it actually costs — the "you save" line, and the only multiplication in
+ * the site.
+ *
+ * It is a comparison figure, not a price: it is never sent to
+ * `/api/checkout`, never stored in a Selection, and never rendered as the
+ * amount to pay. `Selection.total` is the amount, and it comes from the
+ * service. Null when there is nothing to claim, so a mispriced offer says
+ * nothing rather than boasting of a negative saving.
+ */
+export function savingOn(selection) {
+ const undiscounted = selection.months * selection.price.monthPrice;
+ return undiscounted > selection.total ? undiscounted - selection.total : null;
+}
+function usable(status, use) {
+ return status === ACTIVE || (use === "chosen" && status === DEPRECATED);
+}
+// The best of a set of interchangeable rows: active beats deprecated, then
+// newest wins, then the payload's order. Total over an empty list.
+function best(rows) {
+ let chosen = null;
+ for (const row of rows)
+ if (chosen === null || better(row, chosen))
+ chosen = row;
+ return chosen;
+}
+// Ordered by hand rather than by a numeric score: a score combining status and
+// a millisecond timestamp needs more than the 53 bits a double holds exactly.
+function better(row, than) {
+ const active = row.status === ACTIVE;
+ if (active !== (than.status === ACTIVE))
+ return active;
+ // createdAt parses: parseCatalog rejects a row whose timestamp does not.
+ return Date.parse(row.createdAt) > Date.parse(than.createdAt);
+}
+/**
+ * The `/api/catalog` payload, validated.
+ *
+ * A malformed payload is refused whole rather than repaired row by row: a
+ * price that cannot be read is not a price to show at a guess. Unknown fields
+ * and unknown badge types are ignored, so a newer service can add either
+ * without breaking an older site.
+ *
+ * @throws TypeError naming the field that is wrong.
+ */
+export function parseCatalog(payload) {
+ const root = asObject(payload, "catalog");
+ return {
+ prices: asArray(root.prices, "prices").map(parsePrice),
+ offers: asArray(root.offers, "offers").map(parseOffer),
+ };
+}
+function parsePrice(value, i) {
+ const at = `prices[${i}]`;
+ const row = asObject(value, at);
+ return {
+ priceId: asText(row.priceId, `${at}.priceId`),
+ badgeType: asText(row.badgeType, `${at}.badgeType`),
+ monthPrice: asMinorUnits(row.monthPrice, `${at}.monthPrice`),
+ currency: asText(row.currency, `${at}.currency`),
+ status: asText(row.status, `${at}.status`),
+ createdAt: asTimestamp(row.createdAt, `${at}.createdAt`),
+ };
+}
+function parseOffer(value, i) {
+ const at = `offers[${i}]`;
+ const row = asObject(value, at);
+ return {
+ offerId: asText(row.offerId, `${at}.offerId`),
+ // Absent means "applies to any price" (A2). B1's getActiveCatalog joins on
+ // the price, so the site never sees one; it is read as unpinned, and an
+ // unpinned offer matches no priceId and is therefore never selected.
+ priceId: row.priceId === undefined || row.priceId === null ? null : asText(row.priceId, `${at}.priceId`),
+ months: asMonths(row.months, `${at}.months`),
+ status: asText(row.status, `${at}.status`),
+ createdAt: asTimestamp(row.createdAt, `${at}.createdAt`),
+ total: row.total === undefined || row.total === null ? null : asMinorUnits(row.total, `${at}.total`),
+ };
+}
+function asObject(value, at) {
+ if (typeof value !== "object" || value === null || Array.isArray(value))
+ throw new TypeError(`${at} is not an object`);
+ return value;
+}
+function asArray(value, at) {
+ if (!Array.isArray(value))
+ throw new TypeError(`${at} is not an array`);
+ return value;
+}
+function asText(value, at) {
+ if (typeof value !== "string" || value === "")
+ throw new TypeError(`${at} is not a non-empty string`);
+ return value;
+}
+function asMinorUnits(value, at) {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_MINOR_UNITS) {
+ throw new TypeError(`${at} is not a minor-unit amount`);
+ }
+ return value;
+}
+// months is a Word8 on the wire, and zero months is not a duration.
+const MAX_MONTHS = 255;
+function asMonths(value, at) {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_MONTHS) {
+ throw new TypeError(`${at} is not a month count`);
+ }
+ return value;
+}
+function asTimestamp(value, at) {
+ const text = asText(value, at);
+ if (Number.isNaN(Date.parse(text)))
+ throw new TypeError(`${at} is not a timestamp`);
+ return text;
+}
+export const CATALOG_PATH = "/api/catalog";
+const PRICES_UNAVAILABLE = "Prices could not be loaded. Please reload the page, or contact support using the link below.";
+/**
+ * Fetch the catalog and hand it to the sink, or say so in the banner.
+ *
+ * One attempt, no retry: a silent retry loop turns a broken service into a
+ * page that simply never prices anything, with nothing on screen to say why.
+ * Nothing here fails to a blank screen (D2).
+ */
+export async function loadCatalog(fetchLike, sink) {
+ try {
+ const response = await fetchLike(CATALOG_PATH);
+ if (!response.ok)
+ throw new Error(`GET ${CATALOG_PATH} answered ${response.status}`);
+ sink.setCatalog(parseCatalog(await response.json()));
+ }
+ catch (err) {
+ // The reason is for whoever opens the console; the banner is for the visitor.
+ console.error(err);
+ sink.showError(PRICES_UNAVAILABLE);
+ }
+}
diff --git a/apps/simplex-badge-service/web/dist/main.js b/apps/simplex-badge-service/web/dist/main.js
index c2fc01718c..93b060d840 100644
--- a/apps/simplex-badge-service/web/dist/main.js
+++ b/apps/simplex-badge-service/web/dist/main.js
@@ -2,12 +2,17 @@
// relative specifiers below itself: tsc does not rewrite them, which is why
// they keep their ".js" extension and why the whole graph is served under one
// prefix (decision 7, D4).
+import { loadCatalog } from "./catalog.js";
import { startShell } from "./ui.js";
const FAILED = "This page failed to load. Please reload, or contact support using the link below.";
const app = document.getElementById("app");
if (app) {
try {
- startShell(app);
+ const shell = startShell(app);
+ // Started, not awaited: the wizard is on screen while the prices are on
+ // their way, and every option is disabled until they arrive. `fetch` is
+ // wrapped rather than passed, because passing it unbound loses `this`.
+ void loadCatalog((path) => fetch(path), shell);
}
catch (err) {
// A shell that fails to start must still say so: the alternative is an
diff --git a/apps/simplex-badge-service/web/dist/ui.js b/apps/simplex-badge-service/web/dist/ui.js
index 5530fb629e..a9189d69a1 100644
--- a/apps/simplex-badge-service/web/dist/ui.js
+++ b/apps/simplex-badge-service/web/dist/ui.js
@@ -8,8 +8,7 @@
//
// Everything decidable without a browser lives in router.ts and view.ts.
import { hashForScreen, nextScreen, screenIdForHash } from "./router.js";
-import { firstUnansweredScreen, questionOfScreen, screenView } from "./view.js";
-const CHOOSE_AN_OPTION = "Choose an option to continue.";
+import { firstUnansweredScreen, nothingChosenMessage, questionOfScreen, screenView } from "./view.js";
// D7 replaces this branch with the POST to /api/checkout.
const PAYMENT_UNAVAILABLE = "Payment is not available yet.";
/** Build elements from a view tree. No innerHTML: text is always a text node. */
@@ -36,6 +35,9 @@ export function startShell(root, initial = {}) {
const banner = errorBanner();
const answers = { ...initial };
let current = firstUnansweredScreen(answers);
+ // No catalog yet: every screen renders, priced or not (view.ts), and this is
+ // filled in by the fetch main.ts starts.
+ let catalog = null;
const shell = {
showError(message) {
banner.textContent = message;
@@ -48,6 +50,10 @@ export function startShell(root, initial = {}) {
refresh() {
render(current, false);
},
+ setCatalog(loaded) {
+ catalog = loaded;
+ shell.refresh();
+ },
answers() {
return { ...answers };
},
@@ -59,7 +65,7 @@ export function startShell(root, initial = {}) {
function render(id, moveFocus) {
current = id;
clearError();
- const screen = toDom(screenView(id, answers));
+ const screen = toDom(screenView(id, answers, catalog));
root.replaceChildren(banner, screen);
// The heading is the start of the new screen for a keyboard or screen
// reader user, who would otherwise stay on a button that no longer exists.
@@ -96,7 +102,7 @@ export function startShell(root, initial = {}) {
return;
const chosen = form.querySelector("input[type=radio]:checked");
if (!chosen) {
- shell.showError(CHOOSE_AN_OPTION);
+ shell.showError(nothingChosenMessage(catalog));
return;
}
answers[question] = chosen.value;
diff --git a/apps/simplex-badge-service/web/dist/view.js b/apps/simplex-badge-service/web/dist/view.js
index 71db4ae191..0813660ba2 100644
--- a/apps/simplex-badge-service/web/dist/view.js
+++ b/apps/simplex-badge-service/web/dist/view.js
@@ -3,54 +3,113 @@
// A screen is described as an element tree and never touches the DOM, so the
// structure a browser will render can be asserted with `node --test` (see
// ../test/static.test.mjs, which counts the
of every screen and checks
-// that every radio group is a real fieldset/legend/label). ui.ts turns a tree
-// into elements with document.createElement and textContent — there is no
-// innerHTML anywhere, so a label that later comes from the catalog (D3) or from
-// a query parameter (D5) cannot become markup.
+// that every radio group is a real fieldset/legend/label, and
+// ../test/prices.test.mjs, which drives a fixture catalog through this module
+// and reads the rendered prices back). ui.ts turns a tree into elements with
+// document.createElement and textContent — there is no innerHTML anywhere, so
+// a label that comes from the catalog or from a query parameter (D5) cannot
+// become markup.
//
-// The options here are placeholders. D3 builds the first three screens from the
-// catalog payload and replaces them.
+// Every price and every total on these screens is a number the service
+// computed and served; see catalog.ts's header for why that matters.
import { FIRST_SCREEN, nextScreen } from "./router.js";
+import { DURATIONS, MONTHS_WITHOUT_OFFER, TIERS, formatAmount, priceForTier, savingOn, selectionFor, } from "./catalog.js";
/** An element node. An attribute present with an empty value is a boolean attribute. */
export function h(tag, attrs = {}, children = []) {
return { tag, attrs, children };
}
const QUESTIONS = {
- tier: {
- heading: "Choose your level",
- legend: "Badge level",
- options: [
- { value: "supporter", label: "Supporter", detail: "2 GB files" },
- { value: "legend", label: "Legend", detail: "5 GB files" },
- ],
- },
- months: {
- heading: "How long?",
- legend: "Subscription length",
- options: [
- { value: "1", label: "1 month", detail: "Billed once" },
- { value: "3", label: "3 months", detail: "Billed once" },
- { value: "12", label: "12 months", detail: "Billed once" },
- ],
- },
- pay: {
- heading: "How would you like to pay?",
- legend: "Payment method",
- options: [
- { value: "card", label: "Card", detail: "Visa, Mastercard and others" },
- { value: "btc", label: "Bitcoin", detail: "On-chain or Lightning" },
- { value: "xmr", label: "Monero", detail: "On-chain" },
- ],
- },
+ tier: { heading: "Choose your level", legend: "Badge level" },
+ months: { heading: "How long?", legend: "Subscription length" },
+ pay: { heading: "How would you like to pay?", legend: "Payment method" },
};
+// The only options that are not built from the catalog: a payment method is a
+// property of the service's providers, not of a price. D6 takes these three
+// spellings as its `method`.
+const PAY_OPTIONS = [
+ { value: "card", label: "Card", detail: "Visa, Mastercard and others" },
+ { value: "btc", label: "Bitcoin", detail: "On-chain or Lightning" },
+ { value: "xmr", label: "Monero", detail: "On-chain" },
+];
const QUESTION_IDS = new Set(Object.keys(QUESTIONS));
+const LOADING_PRICES = "Loading prices…";
+const UNAVAILABLE = "Unavailable";
+const CHOOSE_TIER_FIRST = "Choose your level first";
+const NOT_CHOSEN = "Not chosen yet";
+const SEPARATOR = " · ";
/** The question a screen asks, or null for a screen that asks none. */
export function questionOfScreen(id) {
return QUESTION_IDS.has(id) ? id : null;
}
-/** The placeholder options of a question screen. D3 replaces this with the catalog. */
-export function optionsOfQuestion(q) {
- return QUESTIONS[q].options;
+/**
+ * The options of a question screen, priced from the catalog.
+ *
+ * A tier or duration that cannot be sold is rendered DISABLED, not hidden (UX
+ * §2.1) — including while the catalog is still on its way, so that nothing can
+ * be chosen before its price is known. The same applies to a deprecated row,
+ * which is not offered as a fresh choice but is still rendered when it is the
+ * answer already held: that is how a `?tier=`/`?months=` parameter for a
+ * withdrawn price survives a walk back through the wizard.
+ */
+export function optionsOfQuestion(q, answers, catalog) {
+ switch (q) {
+ case "tier":
+ return tierOptions(answers, catalog);
+ case "months":
+ return monthOptions(answers, catalog);
+ case "pay":
+ return PAY_OPTIONS;
+ }
+}
+function tierOptions(answers, catalog) {
+ return TIERS.map(({ badgeType, label, perk }) => {
+ const price = catalog && priceForTier(catalog, badgeType, use(badgeType === answers.tier));
+ if (!price)
+ return unavailable(badgeType, label, catalog === null ? LOADING_PRICES : UNAVAILABLE + SEPARATOR + perk);
+ return { value: badgeType, label, detail: `${formatAmount(price.monthPrice, price.currency)} per month${SEPARATOR}${perk}` };
+ });
+}
+function monthOptions(answers, catalog) {
+ const { tier } = answers;
+ return DURATIONS.map((months) => {
+ const value = String(months);
+ const label = monthsLabel(months);
+ if (catalog === null)
+ return unavailable(value, label, LOADING_PRICES);
+ // Reachable by hand-editing the hash to #/months on a first visit.
+ if (tier === undefined)
+ return unavailable(value, label, CHOOSE_TIER_FIRST);
+ const selection = selectionFor(catalog, tier, months, use(value === answers.months));
+ if (!selection)
+ return unavailable(value, label, UNAVAILABLE);
+ const { currency } = selection.price;
+ // The price shown is the service's own total, verbatim. The saving beside
+ // it is a display-only comparison (catalog.ts) and one month has none, so
+ // that row shows its monthly price and nothing else.
+ const total = formatAmount(selection.total, currency);
+ const saving = savingOn(selection);
+ return { value, label, detail: saving === null ? total : `${total}${SEPARATOR}you save ${formatAmount(saving, currency)}` };
+ });
+}
+function unavailable(value, label, detail) {
+ return { value, label, detail, disabled: true };
+}
+/**
+ * What to say when a question is submitted with nothing chosen.
+ *
+ * Every option is disabled until the catalog lands, so before then the visitor
+ * cannot choose one and "choose an option" would be a lie about whose turn it
+ * is. Here rather than in ui.ts because it is copy, and because the shell's own
+ * branches are the part of the site no test can reach.
+ */
+export function nothingChosenMessage(catalog) {
+ return catalog === null ? "Prices are still loading. Please try again in a moment." : "Choose an option to continue.";
+}
+function use(chosen) {
+ return chosen ? "chosen" : "fresh";
+}
+function monthsLabel(months) {
+ return months === MONTHS_WITHOUT_OFFER ? `${months} month` : `${months} months`;
}
/**
* The earliest screen whose question has no answer, or `checkout` when every
@@ -78,27 +137,30 @@ export function firstUnansweredScreen(answers) {
*
* The switch is exhaustive over ScreenId with no default, so adding a screen
* to router.ts fails to compile until this renders it — there is no route the
- * shell can reach that renders nothing.
+ * shell can reach that renders nothing. `catalog` is null until the fetch
+ * lands, which is a state every screen renders rather than a state it waits
+ * for.
*/
-export function screenView(id, answers) {
+export function screenView(id, answers, catalog) {
switch (id) {
case "tier":
case "months":
case "pay":
- return questionScreen(id, answers[id]);
+ return questionScreen(id, answers, catalog);
case "checkout":
- return checkoutScreen(answers);
+ return checkoutScreen(answers, catalog);
case "order":
return orderScreen();
case "code":
return codeScreen();
}
}
-function questionScreen(q, answer) {
- const { heading, legend, options } = QUESTIONS[q];
+function questionScreen(q, answers, catalog) {
+ const { heading, legend } = QUESTIONS[q];
+ const options = optionsOfQuestion(q, answers, catalog);
return section(heading, [
h("form", { class: "form" }, [
- h("fieldset", { class: "options" }, [h("legend", { class: "options__legend" }, [legend]), ...options.map((o) => optionCard(q, o, answer))]),
+ h("fieldset", { class: "options" }, [h("legend", { class: "options__legend" }, [legend]), ...options.map((o) => optionCard(q, o, answers[q]))]),
submitButton("Continue"),
]),
]);
@@ -117,15 +179,16 @@ function optionCard(group, option, answer) {
]),
]);
}
-const NOT_CHOSEN = "Not chosen yet";
-function checkoutScreen(answers) {
+function checkoutScreen(answers, catalog) {
return section("Review your order", [
h("form", { class: "form" }, [
h("dl", { class: "summary" }, [
- ...summaryRow("Level", labelOf("tier", answers.tier)),
- ...summaryRow("Length", labelOf("months", answers.months)),
- ...summaryRow("Payment", labelOf("pay", answers.pay)),
+ ...summaryRow("Level", tierLabel(answers.tier)),
+ ...summaryRow("Length", lengthLabel(answers.months)),
+ ...summaryRow("Total", totalLabel(answers, catalog)),
+ ...summaryRow("Payment", payLabel(answers.pay)),
]),
+ // Inert until D7 wires it: ui.ts answers this submit with a banner.
submitButton("Pay"),
]),
]);
@@ -133,10 +196,39 @@ function checkoutScreen(answers) {
function summaryRow(term, value) {
return [h("dt", { class: "summary__term" }, [term]), h("dd", { class: "summary__value" }, [value])];
}
-function labelOf(q, value) {
+function tierLabel(value) {
if (value === undefined)
return NOT_CHOSEN;
- return QUESTIONS[q].options.find((o) => o.value === value)?.label ?? value;
+ return TIERS.find((t) => t.badgeType === value)?.label ?? value;
+}
+function lengthLabel(value) {
+ if (value === undefined)
+ return NOT_CHOSEN;
+ const months = monthsOf(value);
+ return months === null ? value : monthsLabel(months);
+}
+function payLabel(value) {
+ if (value === undefined)
+ return NOT_CHOSEN;
+ return PAY_OPTIONS.find((o) => o.value === value)?.label ?? value;
+}
+// The one place the summary states an amount, and it states the service's.
+function totalLabel(answers, catalog) {
+ const { tier, months } = answers;
+ if (tier === undefined || months === undefined)
+ return NOT_CHOSEN;
+ if (catalog === null)
+ return LOADING_PRICES;
+ const chosen = monthsOf(months);
+ const selection = chosen === null ? null : selectionFor(catalog, tier, chosen, "chosen");
+ return selection === null ? UNAVAILABLE : formatAmount(selection.total, selection.price.currency);
+}
+// An answer is a string, and every path into it is hand-editable: a query
+// parameter (D5), a resumed order (E5), the hash. Anything that is not a month
+// count is not one.
+function monthsOf(value) {
+ const months = Number(value);
+ return Number.isInteger(months) && months > 0 ? months : null;
}
// E5 replaces this with the crypto payment screen, E6 with the result screen.
// They exist now so that no path can route to a hash that renders nothing.
diff --git a/apps/simplex-badge-service/web/src/catalog.ts b/apps/simplex-badge-service/web/src/catalog.ts
new file mode 100644
index 0000000000..9535f65504
--- /dev/null
+++ b/apps/simplex-badge-service/web/src/catalog.ts
@@ -0,0 +1,322 @@
+// The catalog: the payload `GET /api/catalog` serves, the site constants it
+// does not carry, and the one money formatter.
+//
+// THE BROWSER COMPUTES NO CHARGEABLE AMOUNT. `offerTotal` in
+// BadgeService/Catalog.hs is the only implementation of a total in the system;
+// D4 serves its result in every offer's `total`; this module copies that number
+// into a Selection and view.ts renders it. The one multiplication here is
+// `savingOn`, which is a DISPLAY-ONLY comparison and is never sent anywhere —
+// see its comment. `POST /api/checkout` (D6) carries `{priceId, offerId?,
+// method}` and no amount at all, so the price shown and the price charged
+// cannot drift.
+//
+// Nothing here touches the DOM, so all of it is unit-tested with `node --test`
+// (../test/catalog.test.mjs).
+
+/** A price row of the RPC `BadgeCatalog` encoding (A2). Extra fields are ignored. */
+export interface Price {
+ readonly priceId: string
+ readonly badgeType: string
+ /** Minor units, as everywhere in this system: 700 is $7.00. */
+ readonly monthPrice: number
+ readonly currency: string
+ readonly status: string
+ readonly createdAt: string
+}
+
+/** An offer row. `total` is the service's computed charge for `months`. */
+export interface Offer {
+ readonly offerId: string
+ readonly priceId: string | null
+ readonly months: number
+ readonly status: string
+ readonly createdAt: string
+ /** Minor units. `null` when the service could not price the offer, which
+ * makes that duration unavailable rather than guessable. */
+ readonly total: number | null
+}
+
+export interface Catalog {
+ readonly prices: readonly Price[]
+ readonly offers: readonly Offer[]
+}
+
+/**
+ * Whether a lookup may return a `deprecated` row.
+ *
+ * A deprecated price or offer is never offered as a fresh choice (UX §3), but
+ * is honoured once the visitor has named it — a `?tier=`/`?months=` parameter
+ * (D5) or a resumed order (E5). D6 draws the same line server-side: it accepts
+ * `deprecated` and rejects `disabled`.
+ */
+export type Use = "fresh" | "chosen"
+
+const ACTIVE = "active"
+const DEPRECATED = "deprecated"
+
+/**
+ * A badge type the site sells, with the copy that is NOT in the catalog
+ * payload.
+ *
+ * The perk line is a site constant, so changing "2 GB files" needs a site
+ * rebuild rather than a database edit (H6). The label sits in the same table
+ * because it is keyed by the same thing; the payload carries neither.
+ */
+export interface Tier {
+ readonly badgeType: string
+ readonly label: string
+ readonly perk: string
+}
+
+export const TIERS: readonly Tier[] = [
+ {badgeType: "supporter", label: "Supporter", perk: "2 GB files"},
+ {badgeType: "legend", label: "Legend", perk: "5 GB files"},
+]
+
+/** The durations offered, in months. A4 seeds an offer for 3 and 12 only. */
+export const DURATIONS: readonly number[] = [1, 3, 12]
+
+/** The one duration with no offer: it is charged at the price's `monthPrice`,
+ * and its checkout request carries no `offerId` (D6 reads exactly one month
+ * from that absence). */
+export const MONTHS_WITHOUT_OFFER = 1
+
+/** `CurrencyAmount` is a `Word32` (PaymentService/Types.hs). */
+export const MAX_MINOR_UNITS = 4294967295
+
+const CURRENCY_SYMBOLS: ReadonlyMap = new Map([["usd", "$"]])
+
+const MINOR_UNITS_IN_MAJOR = 100
+
+/**
+ * A minor-unit amount as text: `700` and `"usd"` render as `$7.00`.
+ *
+ * This is integer formatting, not arithmetic on a price — the amount is
+ * displayed exactly as it arrived. A currency with no symbol renders as its
+ * ISO code before the digits (`EUR 12.34`), so an unknown currency is never
+ * shown as a bare number that could be read as dollars. The one formatter in
+ * the site: no other module formats money.
+ */
+export function formatAmount(minorUnits: number, currency: string): string {
+ if (!Number.isInteger(minorUnits) || minorUnits < 0 || minorUnits > MAX_MINOR_UNITS) {
+ throw new RangeError(`not a minor-unit amount: ${minorUnits}`)
+ }
+ const major = Math.trunc(minorUnits / MINOR_UNITS_IN_MAJOR)
+ const minor = minorUnits % MINOR_UNITS_IN_MAJOR
+ const digits = minor < 10 ? `0${minor}` : `${minor}`
+ const symbol = CURRENCY_SYMBOLS.get(currency.toLowerCase())
+ return symbol === undefined ? `${currency.toUpperCase()} ${major}.${digits}` : `${symbol}${major}.${digits}`
+}
+
+/** What the visitor has chosen, resolved against the catalog. */
+export interface Selection {
+ readonly price: Price
+ /** `null` for one month, which has no offer. D7 posts `offer.offerId` when
+ * there is one and omits `offerId` when there is not. */
+ readonly offer: Offer | null
+ readonly months: number
+ /**
+ * What the service will charge, in minor units, COPIED from the catalog: the
+ * offer's `total`, or the price's `monthPrice` for the one month that has no
+ * offer. Never computed here, and never sent back to the service.
+ */
+ readonly total: number
+}
+
+/**
+ * The price to sell `badgeType` at, or null if there is none to sell.
+ *
+ * Prefers an `active` row over a `deprecated` one, then the most recently
+ * created; a tie keeps the payload's order. Repricing appends a new price and
+ * deprecates the old one (UX §3), so a badge type can legitimately have more
+ * than one row here and the newest active one is the current price.
+ */
+export function priceForTier(catalog: Catalog, badgeType: string, use: Use): Price | null {
+ return best(catalog.prices.filter((p) => p.badgeType === badgeType && usable(p.status, use)))
+}
+
+/** The offer to sell `months` of `priceId` at, or null if there is none. An
+ * offer the service could not price (`total: null`) is not one. */
+export function offerForMonths(catalog: Catalog, priceId: string, months: number, use: Use): Offer | null {
+ return best(catalog.offers.filter((o) => o.priceId === priceId && o.months === months && o.total !== null && usable(o.status, use)))
+}
+
+/**
+ * The selection for a tier and a duration, or null when that combination
+ * cannot be sold.
+ *
+ * `use` applies to the OFFER. The price is always looked up as `chosen`,
+ * because `badgeType` is an answer the visitor has already given — the tier
+ * screen is where a deprecated price is kept out of the fresh choices, and by
+ * the time a duration is being priced the tier is settled.
+ */
+export function selectionFor(catalog: Catalog, badgeType: string, months: number, use: Use): Selection | null {
+ const price = priceForTier(catalog, badgeType, "chosen")
+ if (!price) return null
+ if (months === MONTHS_WITHOUT_OFFER) return {price, offer: null, months, total: price.monthPrice}
+ const offer = offerForMonths(catalog, price.priceId, months, use)
+ // `offer.total` is non-null by offerForMonths' filter; this reads it rather
+ // than asserting it, because a total is not a thing to assume.
+ return offer && offer.total !== null ? {price, offer, months, total: offer.total} : null
+}
+
+/**
+ * DISPLAY ONLY. What the same duration would cost at the monthly price, less
+ * what it actually costs — the "you save" line, and the only multiplication in
+ * the site.
+ *
+ * It is a comparison figure, not a price: it is never sent to
+ * `/api/checkout`, never stored in a Selection, and never rendered as the
+ * amount to pay. `Selection.total` is the amount, and it comes from the
+ * service. Null when there is nothing to claim, so a mispriced offer says
+ * nothing rather than boasting of a negative saving.
+ */
+export function savingOn(selection: Selection): number | null {
+ const undiscounted = selection.months * selection.price.monthPrice
+ return undiscounted > selection.total ? undiscounted - selection.total : null
+}
+
+function usable(status: string, use: Use): boolean {
+ return status === ACTIVE || (use === "chosen" && status === DEPRECATED)
+}
+
+interface Row {
+ readonly status: string
+ readonly createdAt: string
+}
+
+// The best of a set of interchangeable rows: active beats deprecated, then
+// newest wins, then the payload's order. Total over an empty list.
+function best(rows: readonly T[]): T | null {
+ let chosen: T | null = null
+ for (const row of rows) if (chosen === null || better(row, chosen)) chosen = row
+ return chosen
+}
+
+// Ordered by hand rather than by a numeric score: a score combining status and
+// a millisecond timestamp needs more than the 53 bits a double holds exactly.
+function better(row: Row, than: Row): boolean {
+ const active = row.status === ACTIVE
+ if (active !== (than.status === ACTIVE)) return active
+ // createdAt parses: parseCatalog rejects a row whose timestamp does not.
+ return Date.parse(row.createdAt) > Date.parse(than.createdAt)
+}
+
+/**
+ * The `/api/catalog` payload, validated.
+ *
+ * A malformed payload is refused whole rather than repaired row by row: a
+ * price that cannot be read is not a price to show at a guess. Unknown fields
+ * and unknown badge types are ignored, so a newer service can add either
+ * without breaking an older site.
+ *
+ * @throws TypeError naming the field that is wrong.
+ */
+export function parseCatalog(payload: unknown): Catalog {
+ const root = asObject(payload, "catalog")
+ return {
+ prices: asArray(root.prices, "prices").map(parsePrice),
+ offers: asArray(root.offers, "offers").map(parseOffer),
+ }
+}
+
+function parsePrice(value: unknown, i: number): Price {
+ const at = `prices[${i}]`
+ const row = asObject(value, at)
+ return {
+ priceId: asText(row.priceId, `${at}.priceId`),
+ badgeType: asText(row.badgeType, `${at}.badgeType`),
+ monthPrice: asMinorUnits(row.monthPrice, `${at}.monthPrice`),
+ currency: asText(row.currency, `${at}.currency`),
+ status: asText(row.status, `${at}.status`),
+ createdAt: asTimestamp(row.createdAt, `${at}.createdAt`),
+ }
+}
+
+function parseOffer(value: unknown, i: number): Offer {
+ const at = `offers[${i}]`
+ const row = asObject(value, at)
+ return {
+ offerId: asText(row.offerId, `${at}.offerId`),
+ // Absent means "applies to any price" (A2). B1's getActiveCatalog joins on
+ // the price, so the site never sees one; it is read as unpinned, and an
+ // unpinned offer matches no priceId and is therefore never selected.
+ priceId: row.priceId === undefined || row.priceId === null ? null : asText(row.priceId, `${at}.priceId`),
+ months: asMonths(row.months, `${at}.months`),
+ status: asText(row.status, `${at}.status`),
+ createdAt: asTimestamp(row.createdAt, `${at}.createdAt`),
+ total: row.total === undefined || row.total === null ? null : asMinorUnits(row.total, `${at}.total`),
+ }
+}
+
+function asObject(value: unknown, at: string): Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${at} is not an object`)
+ return value as Record
+}
+
+function asArray(value: unknown, at: string): readonly unknown[] {
+ if (!Array.isArray(value)) throw new TypeError(`${at} is not an array`)
+ return value
+}
+
+function asText(value: unknown, at: string): string {
+ if (typeof value !== "string" || value === "") throw new TypeError(`${at} is not a non-empty string`)
+ return value
+}
+
+function asMinorUnits(value: unknown, at: string): number {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_MINOR_UNITS) {
+ throw new TypeError(`${at} is not a minor-unit amount`)
+ }
+ return value
+}
+
+// months is a Word8 on the wire, and zero months is not a duration.
+const MAX_MONTHS = 255
+
+function asMonths(value: unknown, at: string): number {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_MONTHS) {
+ throw new TypeError(`${at} is not a month count`)
+ }
+ return value
+}
+
+function asTimestamp(value: unknown, at: string): string {
+ const text = asText(value, at)
+ if (Number.isNaN(Date.parse(text))) throw new TypeError(`${at} is not a timestamp`)
+ return text
+}
+
+export const CATALOG_PATH = "/api/catalog"
+
+const PRICES_UNAVAILABLE = "Prices could not be loaded. Please reload the page, or contact support using the link below."
+
+/** What `loadCatalog` reports to. The shell (ui.ts) satisfies it. */
+export interface CatalogSink {
+ setCatalog(catalog: Catalog): void
+ showError(message: string): void
+}
+
+/** The narrow part of `fetch` this needs, so the load path is testable without
+ * a browser and without a network. */
+export type FetchResponse = {readonly ok: boolean; readonly status: number; json(): Promise}
+export type FetchLike = (path: string) => Promise
+
+/**
+ * Fetch the catalog and hand it to the sink, or say so in the banner.
+ *
+ * One attempt, no retry: a silent retry loop turns a broken service into a
+ * page that simply never prices anything, with nothing on screen to say why.
+ * Nothing here fails to a blank screen (D2).
+ */
+export async function loadCatalog(fetchLike: FetchLike, sink: CatalogSink): Promise {
+ try {
+ const response = await fetchLike(CATALOG_PATH)
+ if (!response.ok) throw new Error(`GET ${CATALOG_PATH} answered ${response.status}`)
+ sink.setCatalog(parseCatalog(await response.json()))
+ } catch (err) {
+ // The reason is for whoever opens the console; the banner is for the visitor.
+ console.error(err)
+ sink.showError(PRICES_UNAVAILABLE)
+ }
+}
diff --git a/apps/simplex-badge-service/web/src/main.ts b/apps/simplex-badge-service/web/src/main.ts
index 0cfa6b6015..4bf5d7bba7 100644
--- a/apps/simplex-badge-service/web/src/main.ts
+++ b/apps/simplex-badge-service/web/src/main.ts
@@ -3,6 +3,7 @@
// they keep their ".js" extension and why the whole graph is served under one
// prefix (decision 7, D4).
+import {loadCatalog} from "./catalog.js"
import {startShell} from "./ui.js"
const FAILED = "This page failed to load. Please reload, or contact support using the link below."
@@ -10,7 +11,11 @@ const FAILED = "This page failed to load. Please reload, or contact support usin
const app = document.getElementById("app")
if (app) {
try {
- startShell(app)
+ const shell = startShell(app)
+ // Started, not awaited: the wizard is on screen while the prices are on
+ // their way, and every option is disabled until they arrive. `fetch` is
+ // wrapped rather than passed, because passing it unbound loses `this`.
+ void loadCatalog((path) => fetch(path), shell)
} catch (err) {
// A shell that fails to start must still say so: the alternative is an
// empty page with the reason only in the console (D2).
diff --git a/apps/simplex-badge-service/web/src/ui.ts b/apps/simplex-badge-service/web/src/ui.ts
index 5cadde9b0d..a9f1f6a94a 100644
--- a/apps/simplex-badge-service/web/src/ui.ts
+++ b/apps/simplex-badge-service/web/src/ui.ts
@@ -8,8 +8,9 @@
//
// Everything decidable without a browser lives in router.ts and view.ts.
+import {type Catalog} from "./catalog.js"
import {hashForScreen, nextScreen, screenIdForHash, type ScreenId} from "./router.js"
-import {firstUnansweredScreen, questionOfScreen, screenView, type Answers, type Child, type El} from "./view.js"
+import {firstUnansweredScreen, nothingChosenMessage, questionOfScreen, screenView, type Answers, type Child, type El} from "./view.js"
/** The running shell. D3 fetches the catalog through it; D7 the checkout. */
export interface Shell {
@@ -19,16 +20,17 @@ export interface Shell {
go(id: ScreenId): void
/**
* Redraw the current screen in place, with no history entry and no focus
- * move. D3's catalog arrives after the first render; `go` would push a
+ * move. The catalog arrives after the first render; `go` would push a
* duplicate entry and break back, and stealing focus mid-read is worse
* still.
*/
refresh(): void
+ /** Price every screen from this catalog, and redraw the one on show. */
+ setCatalog(catalog: Catalog): void
/** The answers gathered so far. */
answers(): Answers
}
-const CHOOSE_AN_OPTION = "Choose an option to continue."
// D7 replaces this branch with the POST to /api/checkout.
const PAYMENT_UNAVAILABLE = "Payment is not available yet."
@@ -56,6 +58,9 @@ export function startShell(root: HTMLElement, initial: Answers = {}): Shell {
const banner = errorBanner()
const answers: {tier?: string; months?: string; pay?: string} = {...initial}
let current = firstUnansweredScreen(answers)
+ // No catalog yet: every screen renders, priced or not (view.ts), and this is
+ // filled in by the fetch main.ts starts.
+ let catalog: Catalog | null = null
const shell: Shell = {
showError(message: string): void {
@@ -69,6 +74,10 @@ export function startShell(root: HTMLElement, initial: Answers = {}): Shell {
refresh(): void {
render(current, false)
},
+ setCatalog(loaded: Catalog): void {
+ catalog = loaded
+ shell.refresh()
+ },
answers(): Answers {
return {...answers}
},
@@ -82,7 +91,7 @@ export function startShell(root: HTMLElement, initial: Answers = {}): Shell {
function render(id: ScreenId, moveFocus: boolean): void {
current = id
clearError()
- const screen = toDom(screenView(id, answers))
+ const screen = toDom(screenView(id, answers, catalog))
root.replaceChildren(banner, screen)
// The heading is the start of the new screen for a keyboard or screen
// reader user, who would otherwise stay on a button that no longer exists.
@@ -118,7 +127,7 @@ export function startShell(root: HTMLElement, initial: Answers = {}): Shell {
if (!(form instanceof HTMLFormElement)) return
const chosen = form.querySelector("input[type=radio]:checked")
if (!chosen) {
- shell.showError(CHOOSE_AN_OPTION)
+ shell.showError(nothingChosenMessage(catalog))
return
}
answers[question] = chosen.value
diff --git a/apps/simplex-badge-service/web/src/view.ts b/apps/simplex-badge-service/web/src/view.ts
index 9ef410d9ed..c64e9d4730 100644
--- a/apps/simplex-badge-service/web/src/view.ts
+++ b/apps/simplex-badge-service/web/src/view.ts
@@ -3,15 +3,28 @@
// A screen is described as an element tree and never touches the DOM, so the
// structure a browser will render can be asserted with `node --test` (see
// ../test/static.test.mjs, which counts the
of every screen and checks
-// that every radio group is a real fieldset/legend/label). ui.ts turns a tree
-// into elements with document.createElement and textContent — there is no
-// innerHTML anywhere, so a label that later comes from the catalog (D3) or from
-// a query parameter (D5) cannot become markup.
+// that every radio group is a real fieldset/legend/label, and
+// ../test/prices.test.mjs, which drives a fixture catalog through this module
+// and reads the rendered prices back). ui.ts turns a tree into elements with
+// document.createElement and textContent — there is no innerHTML anywhere, so
+// a label that comes from the catalog or from a query parameter (D5) cannot
+// become markup.
//
-// The options here are placeholders. D3 builds the first three screens from the
-// catalog payload and replaces them.
+// Every price and every total on these screens is a number the service
+// computed and served; see catalog.ts's header for why that matters.
import {FIRST_SCREEN, nextScreen, type ScreenId} from "./router.js"
+import {
+ DURATIONS,
+ MONTHS_WITHOUT_OFFER,
+ TIERS,
+ formatAmount,
+ priceForTier,
+ savingOn,
+ selectionFor,
+ type Catalog,
+ type Use,
+} from "./catalog.js"
export type Child = El | string
@@ -34,7 +47,15 @@ export interface Option {
readonly disabled?: boolean
}
-/** The answers gathered so far. D3 replaces the values with catalog identifiers. */
+/**
+ * The answers gathered so far.
+ *
+ * `tier` is a badge type and `months` a month count, which is what D5's
+ * `?tier=`/`?months=` parameters carry and what the app's hand-off sends. The
+ * catalog identifiers D6 wants are resolved from them at the last moment, so a
+ * catalog that changes under a visitor reprices their answers instead of
+ * carrying a stale id to checkout.
+ */
export interface Answers {
readonly tier?: string
readonly months?: string
@@ -47,48 +68,107 @@ export type Question = "tier" | "months" | "pay"
interface QuestionScreen {
readonly heading: string
readonly legend: string
- readonly options: readonly Option[]
}
const QUESTIONS: Readonly> = {
- tier: {
- heading: "Choose your level",
- legend: "Badge level",
- options: [
- {value: "supporter", label: "Supporter", detail: "2 GB files"},
- {value: "legend", label: "Legend", detail: "5 GB files"},
- ],
- },
- months: {
- heading: "How long?",
- legend: "Subscription length",
- options: [
- {value: "1", label: "1 month", detail: "Billed once"},
- {value: "3", label: "3 months", detail: "Billed once"},
- {value: "12", label: "12 months", detail: "Billed once"},
- ],
- },
- pay: {
- heading: "How would you like to pay?",
- legend: "Payment method",
- options: [
- {value: "card", label: "Card", detail: "Visa, Mastercard and others"},
- {value: "btc", label: "Bitcoin", detail: "On-chain or Lightning"},
- {value: "xmr", label: "Monero", detail: "On-chain"},
- ],
- },
+ tier: {heading: "Choose your level", legend: "Badge level"},
+ months: {heading: "How long?", legend: "Subscription length"},
+ pay: {heading: "How would you like to pay?", legend: "Payment method"},
}
+// The only options that are not built from the catalog: a payment method is a
+// property of the service's providers, not of a price. D6 takes these three
+// spellings as its `method`.
+const PAY_OPTIONS: readonly Option[] = [
+ {value: "card", label: "Card", detail: "Visa, Mastercard and others"},
+ {value: "btc", label: "Bitcoin", detail: "On-chain or Lightning"},
+ {value: "xmr", label: "Monero", detail: "On-chain"},
+]
+
const QUESTION_IDS: ReadonlySet = new Set(Object.keys(QUESTIONS))
+const LOADING_PRICES = "Loading prices…"
+const UNAVAILABLE = "Unavailable"
+const CHOOSE_TIER_FIRST = "Choose your level first"
+const NOT_CHOSEN = "Not chosen yet"
+const SEPARATOR = " · "
+
/** The question a screen asks, or null for a screen that asks none. */
export function questionOfScreen(id: ScreenId): Question | null {
return QUESTION_IDS.has(id) ? (id as Question) : null
}
-/** The placeholder options of a question screen. D3 replaces this with the catalog. */
-export function optionsOfQuestion(q: Question): readonly Option[] {
- return QUESTIONS[q].options
+/**
+ * The options of a question screen, priced from the catalog.
+ *
+ * A tier or duration that cannot be sold is rendered DISABLED, not hidden (UX
+ * §2.1) — including while the catalog is still on its way, so that nothing can
+ * be chosen before its price is known. The same applies to a deprecated row,
+ * which is not offered as a fresh choice but is still rendered when it is the
+ * answer already held: that is how a `?tier=`/`?months=` parameter for a
+ * withdrawn price survives a walk back through the wizard.
+ */
+export function optionsOfQuestion(q: Question, answers: Answers, catalog: Catalog | null): readonly Option[] {
+ switch (q) {
+ case "tier":
+ return tierOptions(answers, catalog)
+ case "months":
+ return monthOptions(answers, catalog)
+ case "pay":
+ return PAY_OPTIONS
+ }
+}
+
+function tierOptions(answers: Answers, catalog: Catalog | null): readonly Option[] {
+ return TIERS.map(({badgeType, label, perk}) => {
+ const price = catalog && priceForTier(catalog, badgeType, use(badgeType === answers.tier))
+ if (!price) return unavailable(badgeType, label, catalog === null ? LOADING_PRICES : UNAVAILABLE + SEPARATOR + perk)
+ return {value: badgeType, label, detail: `${formatAmount(price.monthPrice, price.currency)} per month${SEPARATOR}${perk}`}
+ })
+}
+
+function monthOptions(answers: Answers, catalog: Catalog | null): readonly Option[] {
+ const {tier} = answers
+ return DURATIONS.map((months) => {
+ const value = String(months)
+ const label = monthsLabel(months)
+ if (catalog === null) return unavailable(value, label, LOADING_PRICES)
+ // Reachable by hand-editing the hash to #/months on a first visit.
+ if (tier === undefined) return unavailable(value, label, CHOOSE_TIER_FIRST)
+ const selection = selectionFor(catalog, tier, months, use(value === answers.months))
+ if (!selection) return unavailable(value, label, UNAVAILABLE)
+ const {currency} = selection.price
+ // The price shown is the service's own total, verbatim. The saving beside
+ // it is a display-only comparison (catalog.ts) and one month has none, so
+ // that row shows its monthly price and nothing else.
+ const total = formatAmount(selection.total, currency)
+ const saving = savingOn(selection)
+ return {value, label, detail: saving === null ? total : `${total}${SEPARATOR}you save ${formatAmount(saving, currency)}`}
+ })
+}
+
+function unavailable(value: string, label: string, detail: string): Option {
+ return {value, label, detail, disabled: true}
+}
+
+/**
+ * What to say when a question is submitted with nothing chosen.
+ *
+ * Every option is disabled until the catalog lands, so before then the visitor
+ * cannot choose one and "choose an option" would be a lie about whose turn it
+ * is. Here rather than in ui.ts because it is copy, and because the shell's own
+ * branches are the part of the site no test can reach.
+ */
+export function nothingChosenMessage(catalog: Catalog | null): string {
+ return catalog === null ? "Prices are still loading. Please try again in a moment." : "Choose an option to continue."
+}
+
+function use(chosen: boolean): Use {
+ return chosen ? "chosen" : "fresh"
+}
+
+function monthsLabel(months: number): string {
+ return months === MONTHS_WITHOUT_OFFER ? `${months} month` : `${months} months`
}
/**
@@ -116,16 +196,18 @@ export function firstUnansweredScreen(answers: Answers): ScreenId {
*
* The switch is exhaustive over ScreenId with no default, so adding a screen
* to router.ts fails to compile until this renders it — there is no route the
- * shell can reach that renders nothing.
+ * shell can reach that renders nothing. `catalog` is null until the fetch
+ * lands, which is a state every screen renders rather than a state it waits
+ * for.
*/
-export function screenView(id: ScreenId, answers: Answers): El {
+export function screenView(id: ScreenId, answers: Answers, catalog: Catalog | null): El {
switch (id) {
case "tier":
case "months":
case "pay":
- return questionScreen(id, answers[id])
+ return questionScreen(id, answers, catalog)
case "checkout":
- return checkoutScreen(answers)
+ return checkoutScreen(answers, catalog)
case "order":
return orderScreen()
case "code":
@@ -133,11 +215,12 @@ export function screenView(id: ScreenId, answers: Answers): El {
}
}
-function questionScreen(q: Question, answer: string | undefined): El {
- const {heading, legend, options} = QUESTIONS[q]
+function questionScreen(q: Question, answers: Answers, catalog: Catalog | null): El {
+ const {heading, legend} = QUESTIONS[q]
+ const options = optionsOfQuestion(q, answers, catalog)
return section(heading, [
h("form", {class: "form"}, [
- h("fieldset", {class: "options"}, [h("legend", {class: "options__legend"}, [legend]), ...options.map((o) => optionCard(q, o, answer))]),
+ h("fieldset", {class: "options"}, [h("legend", {class: "options__legend"}, [legend]), ...options.map((o) => optionCard(q, o, answers[q]))]),
submitButton("Continue"),
]),
])
@@ -156,16 +239,16 @@ function optionCard(group: string, option: Option, answer: string | undefined):
])
}
-const NOT_CHOSEN = "Not chosen yet"
-
-function checkoutScreen(answers: Answers): El {
+function checkoutScreen(answers: Answers, catalog: Catalog | null): El {
return section("Review your order", [
h("form", {class: "form"}, [
h("dl", {class: "summary"}, [
- ...summaryRow("Level", labelOf("tier", answers.tier)),
- ...summaryRow("Length", labelOf("months", answers.months)),
- ...summaryRow("Payment", labelOf("pay", answers.pay)),
+ ...summaryRow("Level", tierLabel(answers.tier)),
+ ...summaryRow("Length", lengthLabel(answers.months)),
+ ...summaryRow("Total", totalLabel(answers, catalog)),
+ ...summaryRow("Payment", payLabel(answers.pay)),
]),
+ // Inert until D7 wires it: ui.ts answers this submit with a banner.
submitButton("Pay"),
]),
])
@@ -175,9 +258,38 @@ function summaryRow(term: string, value: string): readonly El[] {
return [h("dt", {class: "summary__term"}, [term]), h("dd", {class: "summary__value"}, [value])]
}
-function labelOf(q: Question, value: string | undefined): string {
+function tierLabel(value: string | undefined): string {
if (value === undefined) return NOT_CHOSEN
- return QUESTIONS[q].options.find((o) => o.value === value)?.label ?? value
+ return TIERS.find((t) => t.badgeType === value)?.label ?? value
+}
+
+function lengthLabel(value: string | undefined): string {
+ if (value === undefined) return NOT_CHOSEN
+ const months = monthsOf(value)
+ return months === null ? value : monthsLabel(months)
+}
+
+function payLabel(value: string | undefined): string {
+ if (value === undefined) return NOT_CHOSEN
+ return PAY_OPTIONS.find((o) => o.value === value)?.label ?? value
+}
+
+// The one place the summary states an amount, and it states the service's.
+function totalLabel(answers: Answers, catalog: Catalog | null): string {
+ const {tier, months} = answers
+ if (tier === undefined || months === undefined) return NOT_CHOSEN
+ if (catalog === null) return LOADING_PRICES
+ const chosen = monthsOf(months)
+ const selection = chosen === null ? null : selectionFor(catalog, tier, chosen, "chosen")
+ return selection === null ? UNAVAILABLE : formatAmount(selection.total, selection.price.currency)
+}
+
+// An answer is a string, and every path into it is hand-editable: a query
+// parameter (D5), a resumed order (E5), the hash. Anything that is not a month
+// count is not one.
+function monthsOf(value: string): number | null {
+ const months = Number(value)
+ return Number.isInteger(months) && months > 0 ? months : null
}
// E5 replaces this with the crypto payment screen, E6 with the result screen.
diff --git a/apps/simplex-badge-service/web/test/catalog.test.mjs b/apps/simplex-badge-service/web/test/catalog.test.mjs
new file mode 100644
index 0000000000..fcd8dad803
--- /dev/null
+++ b/apps/simplex-badge-service/web/test/catalog.test.mjs
@@ -0,0 +1,341 @@
+// The catalog module, unit-tested directly against the built output.
+//
+// The money formatter is tested here rather than through a rendered screen: a
+// formatter checked only by reading HTML back is checked through three layers
+// of coincidence, and the interesting inputs (a value under a major unit, one
+// that needs the pad, the top of the range, an unknown currency) never appear
+// in a fixture catalog at all.
+
+import test from "node:test"
+import assert from "node:assert/strict"
+
+import {
+ CATALOG_PATH,
+ DURATIONS,
+ MAX_MINOR_UNITS,
+ MONTHS_WITHOUT_OFFER,
+ TIERS,
+ formatAmount,
+ loadCatalog,
+ offerForMonths,
+ parseCatalog,
+ priceForTier,
+ savingOn,
+ selectionFor,
+} from "../dist/catalog.js"
+import {LEGEND_PRICE_ID, SUPPORTER_PRICE_ID, catalogPayload, offer, offerOf, priceOf} from "./fixture.mjs"
+
+const fixture = () => parseCatalog(catalogPayload())
+
+// -- the money formatter ----------------------------------------------------
+
+test("a minor-unit amount renders as major.minor with the currency's symbol", () => {
+ for (const [minorUnits, expected] of [
+ [0, "$0.00"],
+ [1, "$0.01"],
+ // Under one major unit: the whole part is 0 and must still be written.
+ [5, "$0.05"],
+ [99, "$0.99"],
+ [100, "$1.00"],
+ // The pad: a remainder under ten is two digits, not one.
+ [705, "$7.05"],
+ [710, "$7.10"],
+ [700, "$7.00"],
+ // A4's seeded totals, and the fixture's deliberately unround ones.
+ [1400, "$14.00"],
+ [1301, "$13.01"],
+ [42007, "$420.07"],
+ // The top of CurrencyAmount's Word32 range, which is not a round number
+ // in either part.
+ [MAX_MINOR_UNITS, "$42949672.95"],
+ ]) {
+ assert.equal(formatAmount(minorUnits, "usd"), expected, `${minorUnits} minor units`)
+ }
+})
+
+test("a currency with no symbol renders as its ISO code before the digits", () => {
+ // Never a bare number: an amount with no marker at all would be read as
+ // dollars by most of this site's readers.
+ assert.equal(formatAmount(1234, "eur"), "EUR 12.34")
+ assert.equal(formatAmount(5, "chf"), "CHF 0.05")
+ assert.equal(formatAmount(0, "jpy"), "JPY 0.00")
+})
+
+test("the currency is matched whatever its case, and only usd is the dollar", () => {
+ assert.equal(formatAmount(700, "USD"), "$7.00")
+ assert.equal(formatAmount(700, "Usd"), "$7.00")
+ // A near miss must not inherit the symbol.
+ assert.equal(formatAmount(700, "usdc"), "USDC 7.00")
+ assert.equal(formatAmount(700, "aud"), "AUD 7.00")
+})
+
+test("the formatter refuses anything that is not a minor-unit amount", () => {
+ // Reached only if parseCatalog is bypassed, which is exactly when a silent
+ // "$NaN" or a rounded fraction of a cent would be worst.
+ for (const bad of [-1, 0.5, 1.5, NaN, Infinity, MAX_MINOR_UNITS + 1, "700"]) {
+ assert.throws(() => formatAmount(bad, "usd"), RangeError, `${String(bad)} must be refused`)
+ }
+})
+
+test("no key of Object.prototype is a currency symbol", () => {
+ // A symbol table indexed as a plain object answers "constructor" with a
+ // function, and the amount would render with it prefixed.
+ assert.equal(formatAmount(100, "constructor"), "CONSTRUCTOR 1.00")
+ assert.equal(formatAmount(100, "toString"), "TOSTRING 1.00")
+})
+
+// -- parsing ----------------------------------------------------------------
+
+test("the served payload parses into prices and offers", () => {
+ const catalog = fixture()
+ assert.deepEqual(
+ catalog.prices.map((p) => p.priceId),
+ [SUPPORTER_PRICE_ID, LEGEND_PRICE_ID]
+ )
+ assert.equal(catalog.prices[0].monthPrice, 700)
+ assert.equal(catalog.offers.length, 4)
+ assert.equal(offerForMonths(catalog, SUPPORTER_PRICE_ID, 3, "fresh").total, 1301)
+})
+
+test("an unknown field is ignored and an unknown badge type is simply not sold", () => {
+ const payload = catalogPayload()
+ payload.somethingNewer = true
+ payload.prices[0].plan = "monthly"
+ payload.prices.push({...priceOf(payload, LEGEND_PRICE_ID), priceId: "price-investor", badgeType: "investor"})
+ const catalog = parseCatalog(payload)
+ assert.equal(catalog.prices.length, 3)
+ assert.deepEqual(
+ TIERS.map((t) => t.badgeType),
+ ["supporter", "legend"],
+ "the site sells what TIERS lists, whatever else the payload carries"
+ )
+})
+
+test("an offer with no priceId and one with no total parse as absent, not as zero", () => {
+ const payload = catalogPayload()
+ payload.offers = [
+ {offerId: "unpinned", months: 3, status: "active", createdAt: "2026-08-01T09:00:00Z"},
+ offer("unpriced", SUPPORTER_PRICE_ID, 3, null),
+ ]
+ const catalog = parseCatalog(payload)
+ assert.equal(catalog.offers[0].priceId, null)
+ assert.equal(catalog.offers[1].total, null)
+ // Neither can be sold: an unpinned offer is pinned to no price, and an
+ // unpriced one has no total to charge. A zero would be free.
+ assert.equal(offerForMonths(catalog, SUPPORTER_PRICE_ID, 3, "chosen"), null)
+})
+
+test("a malformed payload is refused, naming the field", () => {
+ const cases = [
+ [(p) => (p.prices = {}), /prices is not an array/],
+ [(p) => delete p.offers, /offers is not an array/],
+ [(p) => (priceOf(p, SUPPORTER_PRICE_ID).monthPrice = 7.5), /prices\[0\]\.monthPrice/],
+ [(p) => (priceOf(p, SUPPORTER_PRICE_ID).monthPrice = "700"), /prices\[0\]\.monthPrice/],
+ [(p) => (priceOf(p, SUPPORTER_PRICE_ID).monthPrice = -1), /prices\[0\]\.monthPrice/],
+ [(p) => (priceOf(p, SUPPORTER_PRICE_ID).currency = ""), /prices\[0\]\.currency/],
+ [(p) => delete priceOf(p, SUPPORTER_PRICE_ID).status, /prices\[0\]\.status/],
+ [(p) => (priceOf(p, SUPPORTER_PRICE_ID).createdAt = "whenever"), /prices\[0\]\.createdAt is not a timestamp/],
+ [(p) => (offerOf(p, "offer-supporter-3").months = 0), /offers\[0\]\.months/],
+ [(p) => (offerOf(p, "offer-supporter-3").total = 0.5), /offers\[0\]\.total/],
+ ]
+ for (const [break_, message] of cases) {
+ const payload = catalogPayload()
+ break_(payload)
+ assert.throws(() => parseCatalog(payload), {name: "TypeError", message}, `${message} was accepted`)
+ }
+})
+
+// -- which price and which offer --------------------------------------------
+
+test("a deprecated price is not offered as a fresh choice, but is honoured once chosen", () => {
+ const payload = catalogPayload()
+ priceOf(payload, LEGEND_PRICE_ID).status = "deprecated"
+ const catalog = parseCatalog(payload)
+ assert.equal(priceForTier(catalog, "legend", "fresh"), null)
+ assert.equal(priceForTier(catalog, "legend", "chosen").priceId, LEGEND_PRICE_ID)
+ // D6 accepts deprecated and rejects disabled, and so does this.
+ priceOf(payload, LEGEND_PRICE_ID).status = "disabled"
+ const disabled = parseCatalog(payload)
+ assert.equal(priceForTier(disabled, "legend", "fresh"), null)
+ assert.equal(priceForTier(disabled, "legend", "chosen"), null)
+})
+
+test("a repriced tier sells at the newest active price, not the first row", () => {
+ // Repricing appends and deprecates (UX §3), so both rows arrive, and the
+ // deprecated one is first in the payload.
+ const payload = catalogPayload()
+ priceOf(payload, SUPPORTER_PRICE_ID).status = "deprecated"
+ payload.prices.push({
+ ...priceOf(payload, SUPPORTER_PRICE_ID),
+ priceId: "price-supporter-2",
+ monthPrice: 900,
+ status: "active",
+ createdAt: "2026-08-20T09:00:00Z",
+ })
+ const catalog = parseCatalog(payload)
+ assert.equal(priceForTier(catalog, "supporter", "fresh").monthPrice, 900)
+ // Two active rows: the newer one wins, whatever order they arrive in.
+ const both = parseCatalog(catalogPayload())
+ both.prices.push({...both.prices[0], priceId: "price-supporter-3", monthPrice: 950, createdAt: "2026-08-20T09:00:00Z"})
+ assert.equal(priceForTier(both, "supporter", "fresh").monthPrice, 950)
+ // ... including when the newer one is a fraction of a second newer, which a
+ // lexicographic comparison of the two timestamps would get wrong.
+ const closeTogether = parseCatalog(catalogPayload())
+ closeTogether.prices.push({...closeTogether.prices[0], priceId: "price-supporter-4", monthPrice: 950, createdAt: "2026-08-01T09:00:00.5Z"})
+ assert.equal(priceForTier(closeTogether, "supporter", "fresh").monthPrice, 950)
+})
+
+test("a deprecated offer is not offered fresh, and is honoured once chosen", () => {
+ const payload = catalogPayload()
+ offerOf(payload, "offer-supporter-12").status = "deprecated"
+ const catalog = parseCatalog(payload)
+ assert.equal(offerForMonths(catalog, SUPPORTER_PRICE_ID, 12, "fresh"), null)
+ assert.equal(offerForMonths(catalog, SUPPORTER_PRICE_ID, 12, "chosen").total, 4207)
+})
+
+test("an offer is selected by the chosen tier's own priceId", () => {
+ const catalog = fixture()
+ assert.equal(offerForMonths(catalog, SUPPORTER_PRICE_ID, 3, "fresh").offerId, "offer-supporter-3")
+ assert.equal(offerForMonths(catalog, LEGEND_PRICE_ID, 3, "fresh").offerId, "offer-legend-3")
+ // A duration nobody offers is not sold at a guess.
+ assert.equal(offerForMonths(catalog, SUPPORTER_PRICE_ID, 6, "fresh"), null)
+})
+
+// -- what gets charged ------------------------------------------------------
+
+test("the amount is the service's total, copied, never a product computed here", () => {
+ const catalog = fixture()
+ for (const [months, offerId, total] of [
+ [3, "offer-supporter-3", 1301],
+ [12, "offer-supporter-12", 4207],
+ ]) {
+ const selection = selectionFor(catalog, "supporter", months, "fresh")
+ assert.equal(selection.total, total, `${months} months must be charged the catalog's own number`)
+ assert.equal(selection.offer.offerId, offerId, "D6 reads the months back from this offerId")
+ assert.equal(selection.price.priceId, SUPPORTER_PRICE_ID)
+ // The products a browser-side calculation would have produced instead.
+ const {monthPrice} = selection.price
+ for (const wrong of [months * monthPrice, (months - 1) * monthPrice, (months / 3) * 2 * monthPrice, monthPrice]) {
+ assert.notEqual(selection.total, wrong, "the fixture must make a computed total look different")
+ }
+ }
+})
+
+test("one month has no offer, and is charged the price's own monthPrice", () => {
+ const catalog = fixture()
+ const selection = selectionFor(catalog, "supporter", MONTHS_WITHOUT_OFFER, "fresh")
+ assert.equal(selection.offer, null, "D6 reads exactly one month from a request with no offerId")
+ assert.equal(selection.total, 700)
+ assert.equal(selection.price.currency, "usd")
+})
+
+test("a tier or duration that cannot be sold has no selection at all", () => {
+ const payload = catalogPayload()
+ payload.prices = payload.prices.filter((p) => p.badgeType !== "legend")
+ const catalog = parseCatalog(payload)
+ assert.equal(selectionFor(catalog, "legend", 3, "fresh"), null)
+ assert.equal(selectionFor(catalog, "legend", MONTHS_WITHOUT_OFFER, "fresh"), null)
+ assert.equal(selectionFor(catalog, "investor", 3, "fresh"), null)
+ assert.equal(selectionFor(fixture(), "supporter", 6, "fresh"), null)
+})
+
+test("the saving is the undiscounted monthly cost less the total, or nothing", () => {
+ const catalog = fixture()
+ assert.equal(savingOn(selectionFor(catalog, "supporter", 3, "fresh")), 3 * 700 - 1301)
+ assert.equal(savingOn(selectionFor(catalog, "supporter", 12, "fresh")), 12 * 700 - 4207)
+ assert.equal(savingOn(selectionFor(catalog, "legend", 3, "fresh")), 3 * 7000 - 13001)
+ // One month is the monthly price, so there is nothing to compare it to.
+ assert.equal(savingOn(selectionFor(catalog, "supporter", MONTHS_WITHOUT_OFFER, "fresh")), null)
+})
+
+test("a mispriced offer claims no saving rather than a negative one", () => {
+ const payload = catalogPayload()
+ offerOf(payload, "offer-supporter-3").total = 2500
+ offerOf(payload, "offer-supporter-12").total = 12 * 700
+ const catalog = parseCatalog(payload)
+ assert.equal(savingOn(selectionFor(catalog, "supporter", 3, "fresh")), null, "2500 costs more than 3 x 700")
+ assert.equal(savingOn(selectionFor(catalog, "supporter", 12, "fresh")), null, "a saving of zero is not a saving")
+ // The total is still the service's number: it is charged, not corrected.
+ assert.equal(selectionFor(catalog, "supporter", 3, "fresh").total, 2500)
+})
+
+// -- the fetch --------------------------------------------------------------
+
+function sink() {
+ const seen = {catalogs: [], errors: []}
+ return {
+ seen,
+ setCatalog: (catalog) => seen.catalogs.push(catalog),
+ showError: (message) => seen.errors.push(message),
+ }
+}
+
+function response(body, {ok = true, status = 200} = {}) {
+ return Promise.resolve({ok, status, json: () => (body instanceof Error ? Promise.reject(body) : Promise.resolve(body))})
+}
+
+async function withQuietConsole(run) {
+ const logged = []
+ const original = console.error
+ console.error = (...args) => logged.push(args)
+ try {
+ await run()
+ } finally {
+ console.error = original
+ }
+ return logged
+}
+
+test("a served catalog reaches the shell, parsed, from /api/catalog", async () => {
+ const target = sink()
+ const asked = []
+ await loadCatalog((path) => {
+ asked.push(path)
+ return response(catalogPayload())
+ }, target)
+ assert.deepEqual(asked, [CATALOG_PATH])
+ assert.deepEqual(target.seen.errors, [], "a successful load says nothing in the banner")
+ assert.equal(target.seen.catalogs.length, 1)
+ assert.equal(target.seen.catalogs[0].prices.length, 2)
+ assert.equal(selectionFor(target.seen.catalogs[0], "supporter", 3, "fresh").total, 1301)
+})
+
+test("every way the fetch can fail shows the banner, prices nothing, and does not retry", async () => {
+ const failures = {
+ "a 500 from the service": () => response({}, {ok: false, status: 500}),
+ "a 404, in case the route moves": () => response({}, {ok: false, status: 404}),
+ "a body that is not a catalog": () => response({prices: "soon"}),
+ "a body that is not JSON at all": () => response(new SyntaxError("Unexpected token <")),
+ "a network error": () => Promise.reject(new TypeError("Failed to fetch")),
+ }
+ for (const [what, fetchLike] of Object.entries(failures)) {
+ const target = sink()
+ let calls = 0
+ const logged = await withQuietConsole(() =>
+ loadCatalog((path) => {
+ calls += 1
+ return fetchLike(path)
+ }, target)
+ )
+ assert.equal(calls, 1, `${what}: one attempt, no silent retry`)
+ assert.deepEqual(target.seen.catalogs, [], `${what}: nothing may be priced from a failed load`)
+ assert.equal(target.seen.errors.length, 1, `${what}: the visitor must be told`)
+ assert.match(target.seen.errors[0], /Prices could not be loaded/, what)
+ assert.match(target.seen.errors[0], /contact support/, `${what}: the banner points somewhere`)
+ assert.equal(logged.length, 1, `${what}: the reason belongs in the console`)
+ }
+})
+
+// -- the site constants the payload does not carry --------------------------
+
+test("every tier has a perk line and every duration the plan names is offered", () => {
+ assert.deepEqual(
+ TIERS.map((t) => [t.badgeType, t.label, t.perk]),
+ [
+ ["supporter", "Supporter", "2 GB files"],
+ ["legend", "Legend", "5 GB files"],
+ ]
+ )
+ assert.deepEqual([...DURATIONS], [1, 3, 12])
+ assert.equal(MONTHS_WITHOUT_OFFER, 1)
+})
diff --git a/apps/simplex-badge-service/web/test/el.mjs b/apps/simplex-badge-service/web/test/el.mjs
new file mode 100644
index 0000000000..5cfca317d9
--- /dev/null
+++ b/apps/simplex-badge-service/web/test/el.mjs
@@ -0,0 +1,66 @@
+// Reading a rendered screen back.
+//
+// view.ts describes a screen as an element tree and ui.ts turns that tree into
+// elements with createElement/textContent. There is no browser here, so the
+// tests assert over the tree — the same value a browser would be handed — and
+// read it back the way a reader sees it: an option card is its label and its
+// detail LINE, not the fields some function returned.
+
+export function walk(node, visit, parents = []) {
+ visit(node, parents)
+ const chain = [...parents, node]
+ for (const child of node.children) if (typeof child !== "string") walk(child, visit, chain)
+}
+
+export function findAll(root, tag) {
+ const found = []
+ walk(root, (node, parents) => {
+ if (node.tag === tag) found.push({node, parents})
+ })
+ return found
+}
+
+/** Every string in the subtree, in document order. */
+export function text(node) {
+ let out = ""
+ walk(node, (n) => {
+ for (const child of n.children) if (typeof child === "string") out += child
+ })
+ return out
+}
+
+function byClass(root, className) {
+ const found = []
+ walk(root, (node) => {
+ if (node.attrs.class === className) found.push(node)
+ })
+ return found
+}
+
+function oneByClass(root, className) {
+ const found = byClass(root, className)
+ if (found.length !== 1) throw new Error(`expected exactly one .${className}, found ${found.length}`)
+ return found[0]
+}
+
+/** The radio cards of a question screen, as rendered. */
+export function optionCards(view) {
+ return byClass(view, "option").map((card) => {
+ const input = oneByClass(card, "option__input")
+ return {
+ value: input.attrs.value,
+ disabled: "disabled" in input.attrs,
+ checked: "checked" in input.attrs,
+ label: text(oneByClass(card, "option__label")),
+ detail: text(oneByClass(card, "option__detail")),
+ }
+ })
+}
+
+/** The checkout summary as {term: value}, in the order it is rendered. */
+export function summaryRows(view) {
+ const terms = byClass(view, "summary__term").map(text)
+ const values = byClass(view, "summary__value").map(text)
+ if (terms.length !== values.length) throw new Error(`summary has ${terms.length} terms and ${values.length} values`)
+ return Object.fromEntries(terms.map((term, i) => [term, values[i]]))
+}
diff --git a/apps/simplex-badge-service/web/test/fixture.mjs b/apps/simplex-badge-service/web/test/fixture.mjs
new file mode 100644
index 0000000000..7c9c06584d
--- /dev/null
+++ b/apps/simplex-badge-service/web/test/fixture.mjs
@@ -0,0 +1,77 @@
+// A catalog payload to render against, in the wire shape `GET /api/catalog`
+// serves (the RPC BadgeCatalog encoding, A2). Tests parse it with the site's
+// own parseCatalog, so a fixture drives the whole path a real payload takes.
+//
+// THE TOTALS ARE DELIBERATELY NOT PRODUCTS. A4 prices 3 supporter months at
+// 2 x 700 = 1400 and 12 at 6 x 700 = 4200, and a site that multiplied months
+// by the monthly price itself would land on 1400, 2100, 4200 or 8400 and look
+// right. 1301 and 4207 are none of those, so any arithmetic the browser does
+// on the price shows up as a wrong number rather than as a coincidence. The
+// savings against the undiscounted 3 x 700 and 12 x 700 are 799 and 4193, and
+// they are not round either.
+
+const CREATED = "2026-08-01T09:00:00Z"
+
+export const SUPPORTER_PRICE_ID = "price-supporter"
+export const LEGEND_PRICE_ID = "price-legend"
+
+/** A fresh, mutable payload: a test may delete a price or edit a total. */
+export function catalogPayload() {
+ return {
+ prices: [
+ {
+ priceId: SUPPORTER_PRICE_ID,
+ badgeType: "supporter",
+ monthPrice: 700,
+ currency: "usd",
+ status: "active",
+ createdAt: CREATED,
+ },
+ {
+ priceId: LEGEND_PRICE_ID,
+ badgeType: "legend",
+ monthPrice: 7000,
+ currency: "usd",
+ status: "active",
+ createdAt: CREATED,
+ },
+ ],
+ offers: [
+ offer("offer-supporter-3", SUPPORTER_PRICE_ID, 3, 1301),
+ offer("offer-supporter-12", SUPPORTER_PRICE_ID, 12, 4207),
+ offer("offer-legend-3", LEGEND_PRICE_ID, 3, 13001),
+ offer("offer-legend-12", LEGEND_PRICE_ID, 12, 42007),
+ ],
+ }
+}
+
+/**
+ * The catalog A4 actually seeds, with the totals its `offerTotal` computes:
+ * $7 and $70 a month, one free month in three and six in twelve (UX §1, §6.12).
+ * The fixture above is for proving the site copies what it is served; this one
+ * is for reading the shipped prices back in the shipped copy.
+ */
+export function seededPayload() {
+ const payload = catalogPayload()
+ const totals = {"offer-supporter-3": 1400, "offer-supporter-12": 4200, "offer-legend-3": 14000, "offer-legend-12": 42000}
+ for (const o of payload.offers) o.total = totals[o.offerId]
+ return payload
+}
+
+export function offer(offerId, priceId, months, total, status = "active", createdAt = CREATED) {
+ // `discount` rides along unread: the site prices from `total` alone, and a
+ // fixture that omitted the field would not prove that.
+ return {offerId, priceId, months, discount: {type: "freeMonths", freeMonths: 1}, status, createdAt, total}
+}
+
+export function priceOf(payload, priceId) {
+ const price = payload.prices.find((p) => p.priceId === priceId)
+ if (!price) throw new Error(`no price ${priceId} in the fixture`)
+ return price
+}
+
+export function offerOf(payload, offerId) {
+ const found = payload.offers.find((o) => o.offerId === offerId)
+ if (!found) throw new Error(`no offer ${offerId} in the fixture`)
+ return found
+}
diff --git a/apps/simplex-badge-service/web/test/prices.test.mjs b/apps/simplex-badge-service/web/test/prices.test.mjs
new file mode 100644
index 0000000000..1ee1b815d0
--- /dev/null
+++ b/apps/simplex-badge-service/web/test/prices.test.mjs
@@ -0,0 +1,244 @@
+// D3's screens, rendered from a fixture catalog.
+//
+// Every assertion here reads the rendered card back — the label and the detail
+// LINE a visitor sees — rather than the value some function returned, so the
+// copy, the formatter and the selection are all in the path being checked.
+// The fixture's totals are deliberately not products of its monthly prices
+// (see fixture.mjs), so a site that computed a price instead of copying the
+// service's would print a different number here rather than the same one by
+// coincidence.
+
+import test from "node:test"
+import assert from "node:assert/strict"
+
+import {parseCatalog} from "../dist/catalog.js"
+import {optionsOfQuestion, screenView} from "../dist/view.js"
+import {optionCards, summaryRows, text} from "./el.mjs"
+import {LEGEND_PRICE_ID, SUPPORTER_PRICE_ID, catalogPayload, offerOf, priceOf, seededPayload} from "./fixture.mjs"
+
+const fixture = (edit) => {
+ const payload = catalogPayload()
+ if (edit) edit(payload)
+ return parseCatalog(payload)
+}
+
+const cardsOf = (id, answers, catalog) => optionCards(screenView(id, answers, catalog))
+const detailsOf = (id, answers, catalog) => Object.fromEntries(cardsOf(id, answers, catalog).map((c) => [c.value, c.detail]))
+
+// -- screen 1: choose your level --------------------------------------------
+
+test("each level shows its own monthly price and its perk line", () => {
+ const cards = cardsOf("tier", {}, fixture())
+ assert.deepEqual(
+ cards.map((c) => [c.value, c.label, c.detail, c.disabled]),
+ [
+ ["supporter", "Supporter", "$7.00 per month · 2 GB files", false],
+ ["legend", "Legend", "$70.00 per month · 5 GB files", false],
+ ]
+ )
+ assert.equal(text(screenView("tier", {}, fixture())).includes("Choose your level"), true)
+})
+
+test("a repriced tier shows the new price, since it renders what the payload holds", () => {
+ const catalog = fixture((p) => (priceOf(p, SUPPORTER_PRICE_ID).monthPrice = 1234))
+ assert.equal(detailsOf("tier", {}, catalog).supporter, "$12.34 per month · 2 GB files")
+})
+
+test("a level with no price is disabled, not hidden, and still says what it would give", () => {
+ // The step's manual line: "removing the legend price disables that card".
+ const catalog = fixture((p) => (p.prices = p.prices.filter((price) => price.priceId !== LEGEND_PRICE_ID)))
+ const cards = cardsOf("tier", {}, catalog)
+ assert.deepEqual(
+ cards.map((c) => c.value),
+ ["supporter", "legend"],
+ "the legend card must still be on the screen"
+ )
+ assert.deepEqual(
+ cards.map((c) => c.disabled),
+ [false, true]
+ )
+ assert.equal(cards[1].detail, "Unavailable · 5 GB files")
+})
+
+test("a deprecated price is not offered as a fresh choice, and is shown once chosen", () => {
+ const catalog = fixture((p) => (priceOf(p, LEGEND_PRICE_ID).status = "deprecated"))
+ const fresh = cardsOf("tier", {}, catalog)[1]
+ assert.equal(fresh.disabled, true, "a withdrawn price must not be a fresh choice")
+ // D5 lands here with ?tier=legend, and a walk back through the wizard must
+ // not lose the answer or misprice it.
+ const chosen = cardsOf("tier", {tier: "legend"}, catalog)[1]
+ assert.equal(chosen.disabled, false)
+ assert.equal(chosen.checked, true)
+ assert.equal(chosen.detail, "$70.00 per month · 5 GB files")
+})
+
+// -- screen 2: how long? ----------------------------------------------------
+
+test("the three durations show the served total, and only the offers show a saving", () => {
+ const details = detailsOf("months", {tier: "supporter"}, fixture())
+ assert.deepEqual(details, {
+ // No offer for one month: the price's own monthPrice, and nothing beside
+ // it. A saving here would mean an offer had been applied to a row that has
+ // none.
+ 1: "$7.00",
+ // 1301, not 3 x 700 = 2100 and not A4's 2 x 700 = 1400.
+ 3: "$13.01 · you save $7.99",
+ // 4207, not 12 x 700 = 8400 and not A4's 6 x 700 = 4200.
+ 12: "$42.07 · you save $41.93",
+ })
+})
+
+test("the durations are priced from the chosen tier, not from the first price", () => {
+ const details = detailsOf("months", {tier: "legend"}, fixture())
+ assert.deepEqual(details, {
+ 1: "$70.00",
+ 3: "$130.01 · you save $79.99",
+ 12: "$420.07 · you save $419.93",
+ })
+})
+
+test("changing only the served total changes only the price shown", () => {
+ // The tightest form of "the browser computes no chargeable amount": nothing
+ // about the site changed, one number in the payload did, and the screen
+ // follows it.
+ const catalog = fixture((p) => (offerOf(p, "offer-supporter-3").total = 111))
+ assert.equal(detailsOf("months", {tier: "supporter"}, catalog)[3], "$1.11 · you save $19.89")
+})
+
+test("an unpriced or missing offer disables that duration alone", () => {
+ const catalog = fixture((p) => {
+ offerOf(p, "offer-supporter-3").total = null
+ p.offers = p.offers.filter((o) => o.offerId !== "offer-supporter-12")
+ })
+ const cards = cardsOf("months", {tier: "supporter"}, catalog)
+ assert.deepEqual(
+ cards.map((c) => [c.value, c.detail, c.disabled]),
+ [
+ ["1", "$7.00", false],
+ ["3", "Unavailable", true],
+ ["12", "Unavailable", true],
+ ]
+ )
+})
+
+test("a deprecated offer is honoured for the duration already chosen", () => {
+ const catalog = fixture((p) => (offerOf(p, "offer-supporter-12").status = "deprecated"))
+ assert.equal(detailsOf("months", {tier: "supporter"}, catalog)[12], "Unavailable")
+ const chosen = detailsOf("months", {tier: "supporter", months: "12"}, catalog)
+ assert.equal(chosen[12], "$42.07 · you save $41.93")
+ assert.equal(chosen[3], "$13.01 · you save $7.99", "the other durations stay fresh choices")
+})
+
+test("with no tier chosen, every duration is disabled and says why", () => {
+ // Reachable by hand-editing the hash to #/months on a first visit.
+ const cards = cardsOf("months", {}, fixture())
+ assert.deepEqual(
+ cards.map((c) => [c.disabled, c.detail]),
+ [
+ [true, "Choose your level first"],
+ [true, "Choose your level first"],
+ [true, "Choose your level first"],
+ ]
+ )
+})
+
+test("the prices this service actually seeds read as the plan's own figures", () => {
+ // A4's seeded catalog, priced by its offerTotal: $7 and $70 a month, one
+ // free month in three and six in twelve. The fixture elsewhere in this file
+ // is deliberately unround to catch arithmetic; this is what a visitor will
+ // really see on the day, in the copy they will really see it in.
+ const catalog = parseCatalog(seededPayload())
+ assert.deepEqual(detailsOf("tier", {}, catalog), {
+ supporter: "$7.00 per month · 2 GB files",
+ legend: "$70.00 per month · 5 GB files",
+ })
+ assert.deepEqual(detailsOf("months", {tier: "supporter"}, catalog), {
+ 1: "$7.00",
+ 3: "$14.00 · you save $7.00",
+ 12: "$42.00 · you save $42.00",
+ })
+ assert.deepEqual(detailsOf("months", {tier: "legend"}, catalog), {
+ 1: "$70.00",
+ 3: "$140.00 · you save $70.00",
+ 12: "$420.00 · you save $420.00",
+ })
+})
+
+// -- screen 3: how would you like to pay? -----------------------------------
+
+test("the three payment methods are D6's own spellings and need no catalog", () => {
+ for (const catalog of [null, fixture()]) {
+ assert.deepEqual(
+ cardsOf("pay", {}, catalog).map((c) => [c.value, c.label, c.disabled]),
+ [
+ ["card", "Card", false],
+ ["btc", "Bitcoin", false],
+ ["xmr", "Monero", false],
+ ]
+ )
+ }
+})
+
+// -- screen 4: the summary --------------------------------------------------
+
+test("the summary shows the chosen tier, length, total and method", () => {
+ const rows = summaryRows(screenView("checkout", {tier: "supporter", months: "12", pay: "btc"}, fixture()))
+ assert.deepEqual(rows, {Level: "Supporter", Length: "12 months", Total: "$42.07", Payment: "Bitcoin"})
+})
+
+test("the summary's total is the same number the duration screen showed", () => {
+ const catalog = fixture()
+ for (const [tier, months] of [
+ ["supporter", "1"],
+ ["supporter", "3"],
+ ["supporter", "12"],
+ ["legend", "3"],
+ ]) {
+ const shown = detailsOf("months", {tier}, catalog)[months].split(" · ")[0]
+ const {Total} = summaryRows(screenView("checkout", {tier, months, pay: "card"}, catalog))
+ assert.equal(Total, shown, `${tier} for ${months} months`)
+ }
+})
+
+test("the summary never invents an amount", () => {
+ const catalog = fixture()
+ const rows = (answers, c = catalog) => summaryRows(screenView("checkout", answers, c))
+ assert.equal(rows({}).Total, "Not chosen yet")
+ assert.equal(rows({tier: "supporter"}).Total, "Not chosen yet")
+ assert.equal(rows({tier: "supporter", months: "12"}, null).Total, "Loading prices…")
+ assert.equal(rows({tier: "legend", months: "6"}).Total, "Unavailable", "a duration with no offer has no price")
+ assert.equal(rows({tier: "nonsense", months: "12"}).Total, "Unavailable")
+ assert.equal(rows({tier: "supporter", months: "12x"}).Total, "Unavailable")
+ // An answer that is not a month count is not silently coerced into one.
+ assert.equal(rows({tier: "supporter", months: "12x"}).Length, "12x")
+})
+
+// -- before the catalog lands -----------------------------------------------
+
+test("with no catalog every option is rendered, and none can be chosen", () => {
+ for (const id of ["tier", "months"]) {
+ const cards = cardsOf(id, {tier: "supporter"}, null)
+ assert.ok(cards.length > 0, `screen ${id} renders nothing without a catalog`)
+ for (const card of cards) {
+ assert.equal(card.disabled, true, `${id}/${card.value} is choosable before its price is known`)
+ assert.equal(card.detail, "Loading prices…")
+ }
+ }
+})
+
+test("no screen renders an empty detail line, in any state", () => {
+ const states = [
+ [null, {}],
+ [fixture(), {}],
+ [fixture(), {tier: "supporter", months: "3", pay: "card"}],
+ [fixture((p) => (p.prices = [])), {tier: "legend", months: "3"}],
+ ]
+ for (const [catalog, answers] of states) {
+ for (const q of ["tier", "months", "pay"]) {
+ for (const option of optionsOfQuestion(q, answers, catalog)) {
+ assert.notEqual(option.detail.trim(), "", `${q}/${option.value} has a blank second line`)
+ assert.notEqual(option.label.trim(), "", `${q}/${option.value} has no label`)
+ }
+ }
+ }
+})
diff --git a/apps/simplex-badge-service/web/test/static.test.mjs b/apps/simplex-badge-service/web/test/static.test.mjs
index 3fdc66889a..7f4a2da996 100644
--- a/apps/simplex-badge-service/web/test/static.test.mjs
+++ b/apps/simplex-badge-service/web/test/static.test.mjs
@@ -14,9 +14,12 @@ import assert from "node:assert/strict"
import {readFileSync, readdirSync} from "node:fs"
import {fileURLToPath} from "node:url"
+import {parseCatalog} from "../dist/catalog.js"
import {SCREEN_IDS} from "../dist/router.js"
-import {firstUnansweredScreen, optionsOfQuestion, questionOfScreen, screenView} from "../dist/view.js"
+import {firstUnansweredScreen, nothingChosenMessage, optionsOfQuestion, questionOfScreen, screenView} from "../dist/view.js"
import {allRules, customProperties, declaration, effectiveValue, mediaRules, referencedProperties, rules, stripComments} from "./css.mjs"
+import {findAll, text} from "./el.mjs"
+import {catalogPayload} from "./fixture.mjs"
const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8")
@@ -194,40 +197,28 @@ test("forced colours carry the selected card by border style, not by colour alon
// -- the screens ------------------------------------------------------------
-function walk(node, visit, parents = []) {
- visit(node, parents)
- const chain = [...parents, node]
- for (const child of node.children) if (typeof child !== "string") walk(child, visit, chain)
-}
-
-function findAll(root, tag) {
- const found = []
- walk(root, (node, parents) => {
- if (node.tag === tag) found.push({node, parents})
- })
- return found
-}
-
-function text(node) {
- let out = ""
- walk(node, (n) => {
- for (const child of n.children) if (typeof child === "string") out += child
- })
- return out
-}
+// Both states every screen has to survive: before the catalog arrives, and
+// after. D3's prices are asserted in prices.test.mjs; the structure below has
+// to hold in either state, and a screen that rendered nothing until the fetch
+// landed would be a blank page for as long as the fetch takes.
+const CATALOGS = [null, parseCatalog(catalogPayload())]
test("every screen has exactly one
, and it says something", () => {
- for (const id of SCREEN_IDS) {
- const headings = findAll(screenView(id, {}), "h1")
- assert.equal(headings.length, 1, `screen ${id} must have exactly one
, found ${headings.length}`)
- assert.notEqual(text(headings[0].node).trim(), "", `the
of screen ${id} is empty`)
+ for (const catalog of CATALOGS) {
+ for (const id of SCREEN_IDS) {
+ const headings = findAll(screenView(id, {}, catalog), "h1")
+ assert.equal(headings.length, 1, `screen ${id} must have exactly one
, found ${headings.length}`)
+ assert.notEqual(text(headings[0].node).trim(), "", `the
of screen ${id} is empty`)
+ }
}
})
test("every screen renders something under its heading", () => {
- for (const id of SCREEN_IDS) {
- const view = screenView(id, {})
- assert.ok(view.children.length > 1, `screen ${id} is a bare heading`)
+ for (const catalog of CATALOGS) {
+ for (const id of SCREEN_IDS) {
+ const view = screenView(id, {}, catalog)
+ assert.ok(view.children.length > 1, `screen ${id} is a bare heading`)
+ }
}
})
@@ -237,54 +228,64 @@ test("the three question screens are the ones the plan names", () => {
})
test("each question is a real fieldset with a real legend", () => {
- for (const id of QUESTIONS) {
- const view = screenView(id, {})
- const fieldsets = findAll(view, "fieldset")
- assert.equal(fieldsets.length, 1, `screen ${id} must group its radios in one