mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 04:48:58 +00:00
ui, tests, plan: price the site from the catalog
This commit is contained in:
+222
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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
|
||||
|
||||
+10
-4
@@ -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;
|
||||
|
||||
+141
-49
@@ -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 <h1> 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.
|
||||
|
||||
@@ -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<string, string> = 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<T extends Row>(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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${at} is not an object`)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<unknown>}
|
||||
export type FetchLike = (path: string) => Promise<FetchResponse>
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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<HTMLInputElement>("input[type=radio]:checked")
|
||||
if (!chosen) {
|
||||
shell.showError(CHOOSE_AN_OPTION)
|
||||
shell.showError(nothingChosenMessage(catalog))
|
||||
return
|
||||
}
|
||||
answers[question] = chosen.value
|
||||
|
||||
@@ -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 <h1> 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<Record<Question, QuestionScreen>> = {
|
||||
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<string> = 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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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]]))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 <h1>, 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 <h1>, found ${headings.length}`)
|
||||
assert.notEqual(text(headings[0].node).trim(), "", `the <h1> 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 <h1>, found ${headings.length}`)
|
||||
assert.notEqual(text(headings[0].node).trim(), "", `the <h1> 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 <fieldset>`)
|
||||
const legends = findAll(fieldsets[0].node, "legend")
|
||||
assert.equal(legends.length, 1, `the <fieldset> of screen ${id} must have exactly one <legend>`)
|
||||
assert.notEqual(text(legends[0].node).trim(), "", `the <legend> 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 <fieldset>`)
|
||||
const legends = findAll(fieldsets[0].node, "legend")
|
||||
assert.equal(legends.length, 1, `the <fieldset> of screen ${id} must have exactly one <legend>`)
|
||||
assert.notEqual(text(legends[0].node).trim(), "", `the <legend> 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 <label>, so its card is not clickable and it has no accessible name`
|
||||
for (const catalog of CATALOGS) {
|
||||
for (const id of QUESTIONS) {
|
||||
const view = screenView(id, {}, catalog)
|
||||
const radios = findAll(view, "input").filter(({node}) => node.attrs.type === "radio")
|
||||
const options = optionsOfQuestion(questionOfScreen(id), {}, catalog)
|
||||
assert.equal(radios.length, options.length, `screen ${id} offers ${options.length} options but renders ${radios.length} radios`)
|
||||
assert.ok(options.length > 1, `screen ${id} offers ${options.length} option(s), so the comparison above is vacuous`)
|
||||
for (const {node, parents} of radios) {
|
||||
assert.ok(
|
||||
parents.some((p) => p.tag === "label"),
|
||||
`a radio on screen ${id} is not inside a <label>, so its card is not clickable and it has no accessible name`
|
||||
)
|
||||
assert.equal(node.attrs.name, id, `a radio on screen ${id} is in the wrong group`)
|
||||
}
|
||||
assert.deepEqual(
|
||||
radios.map(({node}) => node.attrs.value),
|
||||
options.map((o) => o.value)
|
||||
)
|
||||
assert.equal(node.attrs.name, id, `a radio on screen ${id} is in the wrong group`)
|
||||
}
|
||||
assert.deepEqual(
|
||||
radios.map(({node}) => node.attrs.value),
|
||||
options.map((o) => o.value)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("the chosen option is the checked one, and only that one", () => {
|
||||
const catalog = CATALOGS[1]
|
||||
for (const id of QUESTIONS) {
|
||||
const options = optionsOfQuestion(questionOfScreen(id))
|
||||
const options = optionsOfQuestion(questionOfScreen(id), {}, catalog)
|
||||
const chosen = options[options.length - 1].value
|
||||
const radios = findAll(screenView(id, {[id]: chosen}), "input")
|
||||
// tier answers months's own options, which is why the whole answer set is
|
||||
// passed rather than only this screen's.
|
||||
const answers = {tier: "supporter", [id]: chosen}
|
||||
const radios = findAll(screenView(id, answers, catalog), "input")
|
||||
const checked = radios.filter(({node}) => "checked" in node.attrs).map(({node}) => node.attrs.value)
|
||||
assert.deepEqual(checked, [chosen], `screen ${id} must check exactly the chosen option`)
|
||||
}
|
||||
})
|
||||
|
||||
test("the checkout summary shows every answer, and says so when one is missing", () => {
|
||||
const answered = screenView("checkout", {tier: "legend", months: "12", pay: "xmr"})
|
||||
const catalog = CATALOGS[1]
|
||||
const answered = screenView("checkout", {tier: "legend", months: "12", pay: "xmr"}, catalog)
|
||||
const summary = text(answered)
|
||||
assert.match(summary, /Legend/)
|
||||
assert.match(summary, /12 months/)
|
||||
assert.match(summary, /Monero/)
|
||||
assert.doesNotMatch(summary, /Not chosen/)
|
||||
assert.match(text(screenView("checkout", {})), /Not chosen/, "an unanswered question must not render as blank")
|
||||
assert.match(text(screenView("checkout", {}, catalog)), /Not chosen/, "an unanswered question must not render as blank")
|
||||
})
|
||||
|
||||
// -- where a visit starts (D5's prefill rests on this) -----------------------
|
||||
@@ -317,7 +318,7 @@ test("nothing can raise a native validation bubble", () => {
|
||||
// whether a bubble is possible does not. It takes a constraint to fail and
|
||||
// a submit that reaches the browser's default: neither exists.
|
||||
for (const id of SCREEN_IDS) {
|
||||
for (const {node} of findAll(screenView(id, {}), "input")) {
|
||||
for (const {node} of findAll(screenView(id, {}, CATALOGS[1]), "input")) {
|
||||
for (const attr of ["required", "pattern", "min", "max", "minlength", "maxlength"]) {
|
||||
assert.ok(!(attr in node.attrs), `an input on screen ${id} carries ${attr}, which can raise a native bubble`)
|
||||
}
|
||||
@@ -328,12 +329,28 @@ test("nothing can raise a native validation bubble", () => {
|
||||
})
|
||||
|
||||
test("the shell can redraw in place, without a history entry", () => {
|
||||
// D3's catalog arrives after the first render. Without refresh() its only
|
||||
// The catalog arrives after the first render. Without refresh() its only
|
||||
// way back onto the screen is go(), which pushes a duplicate entry and
|
||||
// breaks the back button.
|
||||
assert.match(uiJs, /refresh\(\)\s*\{/, "Shell must expose refresh()")
|
||||
})
|
||||
|
||||
test("the catalog reaches the screens through the shell, and redraws them", () => {
|
||||
// The shell is the one module a test cannot execute here (it needs a DOM),
|
||||
// so these two are read out of the built file. What they check is on the
|
||||
// browser list as well: that the prices actually appear when the fetch lands.
|
||||
assert.match(uiJs, /screenView\(\w+,\s*\w+,\s*catalog\)/, "the shell must render the screens from the catalog it holds")
|
||||
assert.match(uiJs, /setCatalog\([^)]*\)\s*\{[^}]*refresh\(\)/, "setCatalog must redraw, or the prices land in a variable and nowhere else")
|
||||
})
|
||||
|
||||
test("the message for an unanswered question depends on whether prices have arrived", () => {
|
||||
// Every option is disabled until the catalog lands, so "choose an option"
|
||||
// would blame the visitor for the fetch.
|
||||
assert.match(nothingChosenMessage(null), /Prices are still loading/)
|
||||
assert.match(nothingChosenMessage(CATALOGS[1]), /Choose an option/)
|
||||
assert.match(uiJs, /nothingChosenMessage\(catalog\)/, "the shell must use it, not a message of its own")
|
||||
})
|
||||
|
||||
test("the shell navigates with pushState and never reloads the page", () => {
|
||||
assert.match(uiJs, /history\.pushState\(/)
|
||||
assert.equal(uiJs.match(/location\.(assign|reload|replace)\(/g), null, "a page reload would lose the wizard's answers")
|
||||
|
||||
@@ -152,7 +152,7 @@ The two `-m` filters are needed because the badge tests live under two hspec pat
|
||||
| D0 | Store layer: orders, invoices, provider events | A3, A5, B1 | ☑ |
|
||||
| D1 | Web project skeleton and tsc build | — | ☑ |
|
||||
| D2 | Design system and site wizard shell | D1 | ☑ |
|
||||
| D3 | Catalog fetch and the four site screens | D2, D4 | ☐ |
|
||||
| D3 | Catalog fetch and the four site screens | D2, D4 | ☑ |
|
||||
| D4 | Warp listener, asset embedding, routing | A2, A4, A6, B1, D1 | ☑ |
|
||||
| D5 | URL prefill | D3 | ☐ |
|
||||
| D6 | `POST /api/checkout`, provider interface, order creation | A4, B3, D0, D4 | ☐ |
|
||||
@@ -805,7 +805,7 @@ Phase D ends with a browsable, priced site wizard whose Pay button reaches a rea
|
||||
|
||||
#### D3 — Catalog fetch and the four site screens
|
||||
|
||||
**Files:** `apps/simplex-badge-service/web/src/{catalog.ts,steps.ts,view.ts,ui.ts,main.ts}` — `view.ts` because D2 put the screens' structure there and its `QUESTIONS` placeholder is what this step replaces with catalog data; `ui.ts` because the fetch needs `Shell.refresh()` to redraw when the catalog lands (§9)
|
||||
**Files:** `apps/simplex-badge-service/web/src/{catalog.ts,view.ts,ui.ts,main.ts}`, `apps/simplex-badge-service/web/test/{catalog.test.mjs,prices.test.mjs,static.test.mjs,el.mjs,fixture.mjs}`, `apps/simplex-badge-service/web/dist/` — `view.ts` because D2 put the screens' structure there and its `QUESTIONS` placeholder is what this step replaces with catalog data; `ui.ts` because the fetch needs `Shell.refresh()` to redraw when the catalog lands (§9). **There is no `steps.ts`**: D2 shipped `main.ts`, `ui.ts`, `router.ts` and `view.ts`, and the site keeps that split (§9)
|
||||
|
||||
**Do:** Four screens, with this base-locale copy:
|
||||
|
||||
@@ -818,7 +818,7 @@ Phase D ends with a browsable, priced site wizard whose Pay button reaches a rea
|
||||
- The perk line (2 GB and 5 GB files) is a constant in `catalog.ts` keyed by `badgeType`. It is not in the catalog payload, so changing a perk requires a site rebuild; H6 records that.
|
||||
- A tier or duration with no `active` price renders **disabled**, not hidden (UX §2.1). A `deprecated` price or offer is never offered as a fresh choice but is honoured when it arrives as an explicit `?tier=` or `?months=` parameter, or on a resumed order. This matches D6, which accepts `deprecated` and rejects `disabled`.
|
||||
|
||||
**Verify:** Manual: with a fixture catalog all four screens render, removing the legend price disables that card, and the summary shows the correct total.
|
||||
**Verify:** `npm test` in `apps/simplex-badge-service/web`, on `node:test`, with every assertion proved able to fail by breaking what it names (§9): the money formatter directly — zero, an amount under one major unit, one needing the two-digit pad, the top of `CurrencyAmount`'s `Word32` range, an unknown currency, a currency matched case-insensitively, and a refusal of anything that is not a minor-unit amount; a fixture catalog driven through `parseCatalog` and the real `screenView` path, reading each rendered card's detail LINE back, for the three durations' prices, the two savings, and **the 1-month row showing no saving** — the fixture's totals are deliberately not products of its monthly price, so a browser-side multiplication prints a different number rather than the same one by chance; a second fixture carrying A4's own seeded totals, so the shipped prices are read back in the shipped copy; removing the legend price renders that card **disabled and still present**; a deprecated price or offer is absent from the fresh choices and present once it is the answer held; an unpriced offer (`total: null`) disables its duration rather than pricing it at zero; the summary's total is the same string the duration screen showed, and is `Unavailable` rather than an invented amount when the catalog cannot price the answers; and the fetch itself, with a stubbed `fetch`: a served payload reaches the shell parsed, while a 500, a 404, a body that is not a catalog, a body that is not JSON and a network error each show the banner, price nothing and make exactly one attempt. `npx tsc --noEmit` clean; a rebuild leaves `git status --porcelain apps/simplex-badge-service/web/dist` empty (D8). Not automated: that a browser draws any of it (§9 browser pass).
|
||||
|
||||
#### D4 — Warp listener, asset embedding, routing
|
||||
|
||||
@@ -884,7 +884,7 @@ POST /api/checkout { priceId, offerId?, method: "card"|"btc"|"xmr" }
|
||||
|
||||
#### D7 — Pay button and checkout error states
|
||||
|
||||
**Files:** `apps/simplex-badge-service/web/src/{checkout.ts,steps.ts,ui.ts}` — `ui.ts` because D2's shell answers a checkout submit with "payment is not available yet", and this step replaces that branch (§9)
|
||||
**Files:** `apps/simplex-badge-service/web/src/{checkout.ts,ui.ts}` — `ui.ts` because D2's shell answers a checkout submit with "payment is not available yet", and this step replaces that branch (§9)
|
||||
|
||||
**Do:** Wire D3's `#/checkout` Pay button to D6.
|
||||
|
||||
@@ -1500,6 +1500,7 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
|
||||
- From D1: that `dev.html` **loads at all** rather than rendering an empty shell; that `../styles.css` is **applied** and not merely served with a `text/css` header, for which the colour tokens are the visible proof; and that the browser **executes the module graph across files** — `main.js` importing `./ui.js`, and `ui.js` importing `./router.js` and `./view.js`, by the relative specifiers `tsc` does not rewrite. The third is what decision 7 and D4's single-prefix asset hash both rest on. A blank or unstyled page at this pass is as likely to be D1's mechanism as D2's markup, so confirm each separately. (D1's fourth deferral, that a `file://` open fails where HTTP succeeds, is still carried untested and nothing depends on it.)
|
||||
- From D4: that the served site (not only `dist/dev.html`) loads under `Content-Security-Policy: default-src 'self'` with **no console violation**. The shipped bytes are statically clean today — `index.html` has no inline `<script>`, `<style>` or `on*` handler, and `ui.js` sets no `style` attribute, all four checked by reading `web/dist` and `web/index.html` — so this is on the list for what a *later* step's markup could add, not for a known problem. Everything else D4 could be asked to show over a browser is asserted mechanically instead (its Verify line), including the module graph resolving under one prefix, which is the served-site half of D1's third item above.
|
||||
- From D2: a keyboard-only pass through every screen, reaching and activating **every** option and the Continue button, with browser back and forward landing on the right screen — the hash-to-screen mapping is unit-tested but `pushState`/`popstate` wiring is not; at 320 px in both colour schemes, that no screen scrolls horizontally; that the selected card's 2 px accent border is visible in both schemes; that the `:focus-visible` ring is visible against both backgrounds, **including the ring the visually hidden radio projects onto its card** — the one place where hiding an input could silently cost the focus indicator; that the 150 ms fade runs, and does not under `prefers-reduced-motion: reduce`; that selection stays distinguishable under forced colours; and that submitting a question unanswered **shows the error banner** — the other half of that claim, that no *native validation bubble* can appear instead, is statically decidable and has been moved off this list: it needs a constraint attribute to fail and a submit that reaches the browser's default handling, and `static.test.mjs` asserts that no input carries `required`, `pattern`, `min`, `max`, `minlength` or `maxlength` and that the built shell calls `preventDefault()` on submit. Only "the banner is visible and announced" still needs eyes.
|
||||
- From D3: that the prices **appear** when the catalog lands — the fetch, the parse, the banner on every way it can fail, and the one-attempt rule are all unit-tested against a stubbed `fetch`, and the screens are asserted from a fixture catalog, but the redraw itself is `Shell.setCatalog` calling `refresh()`, and no test here executes the shell; and that a disabled card **reads** as unavailable in both colour schemes and under `forced-colors: active`, where the 0.5 opacity that carries it is the only thing distinguishing it from a card that can be chosen. Both were checked for static decidability first: what the screens contain in each state, which options are disabled, and every string on them are asserted in `prices.test.mjs`, and only "a human sees it" is left here.
|
||||
- **Before adding anything to this list, check whether it is statically decidable, and if it is, check it instead.** "Each scheme shows its own logo" was on this list and has been struck from it: it is not a rendering question but a stylesheet *ordering* question, and it shipped broken precisely because the list absorbed it. All four logo selectors are specificity (0,1,0), a conditional group rule adds none and does not reorder its contents, so the dark-scheme rules won nothing by being in a media query — they sat above the defaults they had to override, lost the cascade, and dark mode rendered the `#023789` logo on the `#000832` background with no error anywhere. `static.test.mjs` now asserts that the dark-scheme `display` declarations for `.logo--light`/`.logo--dark` are the **last** ones in the file for those selectors, and the fix moved that block to the end of the stylesheet. A list that absorbs statically-checkable items stops being a way to hold them and becomes a way to defer them.
|
||||
- **D2 — the shell is four modules, not the two the Files list named, and there is now a `web/test/` directory.** The step named `src/{ui.ts,main.ts}`. Splitting out `router.ts` (the pure hash-to-screen mapping) and `view.ts` (each screen as a pure element tree, with no DOM call in it) is what makes the entry above a short list instead of the whole step: with the pure half separated, "every screen has exactly one `<h1>`", "every radio is inside a `<label>`" and "all six hashes resolve" are assertions a machine can run, and only *rendering* needs a human. `ui.ts` is the only module that touches the DOM and stays unasserted. The tests are `node:test` and `node:assert`, **built-in modules**, run by a new `npm test` script: nothing was added to `package.json`'s dependencies or to the lockfile, so decision 7's one-devDependency cap is intact. They import from `dist/`, not `src/`, so they assert on the bytes a browser is served. `test/css.mjs` is a 60-line reader for the stylesheet — a CSS parser is a dependency this project may not have, and grepping raw text cannot tell a declaration inside the dark media query from one outside it, which is the exact distinction the light-mode trap turns on. **Files** corrected; `package.json` added to it.
|
||||
- **D2 — the Verify line was a browser pass, which no CI and no agent environment can run; it is now a static suite that was proved able to fail.** Rule 4 requires the Verify line to be run before the step is ticked, so a line that cannot be run anywhere but on a human's screen makes the step unfalsifiable. The line now names the assertions listed in the step, and the browser half moved into the phase entry above. Being able to *fail* is the point: fourteen mutations were applied one at a time and the suite caught each — a colour token moved into the dark block only, the accent off by one digit, a screen's `<h1>` removed, a second `<h1>` added, `<fieldset>` replaced by `<div>`, the `<label>` wrapper removed, the `popstate` listener deleted, a hash dropped from the route set, a blanket `*:focus { outline: none }` added, the reduced-motion block removed, `max-width` moved off 560px, a token written into a CSS rule, a Google Fonts `@import` added, and a logo token dropped from `index.html`. A check nobody has watched fail is not a check.
|
||||
@@ -1529,6 +1530,20 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
|
||||
|
||||
- **D4 — `logUnpricedOffers` is asserted at neither call site, and was not before the move either.** No test produces an offer that is pinned to a returned price and still has no total, so the only evidence that either the RPC handler or `/api/catalog` logs it is the call site and a clean compile. The gap is pre-existing and symmetric — the RPC side was never asserted either, so fix round 1's move weakened nothing — but it is a gap, and the fixture is not free: it needs a `badge_offers` row with `free_months >= months` (or a discount over 100) written straight to the database, past `seedCatalog`'s own `requireTotal` guard, and an assertion over the log rather than over a response. B10 or H4 is the natural home; whoever takes it should assert both call sites, since the point of moving the function was that the site path logs too.
|
||||
|
||||
- **D3 — the Files list still named `steps.ts`, which has never existed, and now names the tests and `dist/`.** D2's fix round recorded that D3's and D7's lists were stale and added `view.ts`/`ui.ts` to D3's, but left `steps.ts` in both; it is removed from both now. The site's modules are `main.ts`, `ui.ts`, `router.ts`, `view.ts` and, from this step, `catalog.ts`. D3's list also omitted the test modules it must ship (rule 10: a step whose Verify is automated names the files that carry the assertions) and `dist/`, which every step touching `src/` must rebuild and commit or D8's gate fails.
|
||||
|
||||
- **D3 — the Verify line was manual and is now the static suite, proved able to fail by eighteen mutations.** "With a fixture catalog all four screens render" cannot be run anywhere but on a human's screen, which under rule 4 leaves the step unfalsifiable; and this phase has twice shipped an assertion that could not fail. Each mutation was applied alone, the suite rebuilt and run, and the assertion aimed at was the one that failed: the formatter's two-digit pad dropped, its unknown-currency branch made to render a bare number, its range guard defeated, and its symbol table moved from a `Map` to a plain object (`formatAmount(100, "constructor")` then renders `Object.prototype.constructor` in front of the digits); `selectionFor` made to return `months x monthPrice` instead of the served total, and the same in the summary alone; the saving's `>` guard removed, so one month claims a saving of `$0.00`; `deprecated` filtered out inside `parseCatalog`; an unavailable tier hidden instead of disabled; every lookup made to honour `deprecated` as a fresh choice; an unpriced offer charged zero; `createdAt` accepted unvalidated; the best-row choice reduced to "keep the first"; the catalog fetch given a silent retry, and its banner suppressed; `setCatalog` made to store without redrawing; the shell made to render with a `null` catalog; and the loading message replaced by "choose an option". The fixture is built so that the accidental answer is the wrong one: its totals (1301, 4207, 13001, 42007) are not products of its monthly prices, so a browser-side multiplication prints a different number rather than the same one by coincidence. A second fixture carries A4's real seeded totals, so `$14.00 / you save $7.00` is read back in the shipped copy.
|
||||
|
||||
- **D3 — an answer stays a badge type and a month count; the catalog identifiers are resolved at the last moment.** D2's `Answers` comment said this step would replace the values with catalog identifiers. It does not, and should not: D5's parameters are `?tier=supporter&months=12`, the app's hand-off (G1) sends the same, and a `priceId` captured into an answer would go stale the moment the catalog changed under a visitor who left the tab open. `selectionFor(catalog, badgeType, months)` resolves the price and the offer on every render, so a repricing reprices the answers instead of carrying a withdrawn id to checkout. D7 reads `selection.price.priceId` and `selection.offer?.offerId` for its request body at the moment it posts.
|
||||
|
||||
- **D3 — `deprecated` is a lookup mode, not a filter.** The step requires a deprecated price or offer to be kept out of the fresh choices yet honoured when it arrives as an explicit parameter or on a resumed order, and the obvious implementation — dropping deprecated rows on the way in — cannot express the second half. Every lookup therefore takes `Use = "fresh" | "chosen"`, and an option is rendered enabled when it is either sellable fresh or the answer already held. `disabled` is neither, in either mode, matching D6, which accepts `deprecated` and rejects `disabled`. A row whose status the site does not recognise at all is treated as `disabled`, so a newer service can add one without a stale site selling it.
|
||||
|
||||
- **D3 — `TIERS` decides what the site sells, so a level with no price is disabled rather than missing, and a badge type the site does not know is not offered.** The payload cannot express "supporter is unavailable": B1's `getActiveCatalog` filters disabled rows out, so an unsellable tier is simply absent from it, and a screen built from the payload alone would silently drop the card that UX §2.1 requires to be shown disabled. The tier list, its labels and its perk lines are therefore site constants in `catalog.ts` (H6: changing a perk needs a site rebuild), and the durations 1/3/12 with them. The consequence in the other direction is worth naming: a price for a badge type not in `TIERS` — `investor`, say — is parsed and ignored, so selling a third level needs a site rebuild too, not just a database row.
|
||||
|
||||
- **D3 — one line of the shell's copy moved into `view.ts` so it could be tested.** `ui.ts` is the one module no test here can execute, and every branch added to it is a branch nothing checks. The choice of what to say when a question is submitted with nothing chosen — "prices are still loading" before the catalog lands, "choose an option" after, since every option is disabled until then — is `nothingChosenMessage(catalog)` in `view.ts`, unit-tested, with the shell calling it. `Shell.setCatalog` is the only genuinely new shell behaviour, and it is two lines: store, then `refresh()` — D2's seam, used as intended, so the catalog landing does not push a history entry.
|
||||
|
||||
- **D3 — a change under `web/dist/` does not invalidate cabal's build, so the embedded site goes stale on a local run.** Measured after this step's rebuild: `cabal build simplex-badge-service` printed `Up to date`, and the already-linked binary still carried D2's site — `Choose an option to continue.` present, D3's `nothingChosenMessage` absent. `web/dist` is not in `extra-source-files`, and cabal compares the *content* of the package's declared sources, so a bare `touch` of `Assets.hs` leaves it `Up to date` too; the file-embed dependency is invisible to it. Any real content change to a Haskell source makes cabal invoke ghc, and the current `dist/` is embedded then. Clean builds are unaffected, so CI and D8's gate are unaffected — but a local run of D4's web examples after a site change asserts against the PREVIOUSLY embedded page unless the suite is forced to rebuild, which is exactly the reassurance a web step reaches for. Forced here, `should serve the index with every token resolved …` and `should serve every module a served module imports under that module's own prefix` both pass with D3's `catalog.js` in the module graph. The remedy for whoever next has reason to touch the cabal file is `extra-source-files` covering `apps/simplex-badge-service/web/dist/**`, `web/index.html` and `web/styles.css`; it was not taken here because this step must not touch the Haskell build.
|
||||
|
||||
## 10. End-to-end verification
|
||||
|
||||
After F5:
|
||||
|
||||
Reference in New Issue
Block a user