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
`) - const legends = findAll(fieldsets[0].node, "legend") - assert.equal(legends.length, 1, `the
of screen ${id} must have exactly one `) - assert.notEqual(text(legends[0].node).trim(), "", `the of screen ${id} is empty`) + for (const catalog of CATALOGS) { + for (const id of QUESTIONS) { + const view = screenView(id, {}, catalog) + const fieldsets = findAll(view, "fieldset") + assert.equal(fieldsets.length, 1, `screen ${id} must group its radios in one
`) + const legends = findAll(fieldsets[0].node, "legend") + assert.equal(legends.length, 1, `the
of screen ${id} must have exactly one `) + assert.notEqual(text(legends[0].node).trim(), "", `the of screen ${id} is empty`) + } } }) test("every option is a radio inside a label, and every option is offered", () => { - for (const id of QUESTIONS) { - const view = screenView(id, {}) - const radios = findAll(view, "input").filter(({node}) => node.attrs.type === "radio") - const options = optionsOfQuestion(questionOfScreen(id)) - assert.equal(radios.length, options.length, `screen ${id} offers ${options.length} options but renders ${radios.length} radios`) - for (const {node, parents} of radios) { - assert.ok( - parents.some((p) => p.tag === "label"), - `a radio on screen ${id} is not inside a