carlos / carloku Public

Pricing page: the approved grouped cards and currency controls, every amount from the platform's price book

build.mjs now renders the layout the review approved (team inclusions
under the shared Each member adds heading, Business's extras as their own
list) and the five approved currency strings as controls. Every price,
band cell, overage rate and the invoice floor comes from pricebook.json,
the platform's own export (pricebook.source.txt names the commit), in
all three currencies; the approved EUR strings are the book's EUR and
the test fails if they ever differ. The regenerated page replaces the
stale one under src/pricing. USD and GBP overage rates and floors are the
platform's draft and not yet approved commercially.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev b4e5f20e428b37ad88597fee87446c1a7773f86d parent 7bd8fcc
6 files changed, +687 −61
  • copy-review/pricing/build.mjs +104 −16
  • copy-review/pricing/pricebook.json +414 −0
  • copy-review/pricing/pricebook.source.txt +1 −0
  • src/pricing/index.html +66 −45
  • src/site.css +10 −0
  • test/pricing-pricebook.test.mjs +92 −0
diff --git a/copy-review/pricing/build.mjs b/copy-review/pricing/build.mjs
index ec98cc9..b016868 100644
--- a/copy-review/pricing/build.mjs
+++ b/copy-review/pricing/build.mjs
@@ -9,6 +9,51 @@ const here = path.dirname(new URL(import.meta.url).pathname);
const root = path.resolve(here, "..", "..");
const strings = JSON.parse(fs.readFileSync(path.join(here, "strings.json"), "utf8"));
const S = Object.fromEntries(strings.map((s) => [s.id, s.text]));
+// The five currency-control strings have their own review (approved
+// 2026-10-01, copy-review/currency/result.json).
+const currencyStrings = JSON.parse(fs.readFileSync(path.join(here, "..", "currency", "strings.json"), "utf8"));
+for (const c of currencyStrings) S[c.id] = c.text;
+
+// Every price, rate and default on the page comes from the platform's own
+// price book (pricebook.json, generated by `go run ./cmd/pricebook-export`
+// in carlos/platform and copied here; see pricebook.source.txt). Nothing
+// commercial is typed into this script or into the strings.
+const book = JSON.parse(fs.readFileSync(path.join(here, "pricebook.json"), "utf8"));
+const CURRENCIES = book.currencies.map((c) => c.code.toUpperCase());
+const symbol = Object.fromEntries(book.currencies.map((c) => [c.code.toUpperCase(), c.symbol]));
+const plansByKey = Object.fromEntries(book.plans.map((pl) => [pl.key, pl]));
+// money renders minor units as the approved strings do: "€24", "€8.40",
+// "$1,575" is not needed (no price reaches a thousand).
+const money = (cur, minor) => {
+ const major = Math.floor(minor / 100), rem = minor % 100;
+ return symbol[cur] + major + (rem ? "." + String(rem).padStart(2, "0") : "");
+};
+// rate renders micro units to three decimals, as the approved EUR rate
+// strings do ("€0.010 per hour").
+const rate = (cur, micro) => symbol[cur] + (micro / 1e6).toFixed(3);
+// unitMinor is a plan's per-member annual price in a currency at a band.
+const unitMinor = (plan, cur, band) => plansByKey[plan].annual_unit_minor[cur.toLowerCase()][band];
+// values builds the data attribute the selector reads: the text for each
+// currency, made by swapping the amount in the approved EUR string.
+const values = (byCurrency) => esc(JSON.stringify(byCurrency));
+// swapAmount puts an amount into an approved string in place of its euro
+// amount, keeping every other word of the string as approved.
+const swapAmount = (text, cur, amount) => {
+ if (!/€[0-9][0-9.,]*/.test(text)) throw new Error("no euro amount in " + JSON.stringify(text));
+ return text.replace(/€[0-9][0-9.,]*/, amount);
+};
+// priced renders an approved string whose euro amount varies by currency:
+// the approved text as written (EUR), with the other currencies' texts in
+// data-currency-values for the selector.
+const priced = (id, amountFor, tag = "span", cls = "") => {
+ if (!(id in S)) throw new Error("missing string " + id);
+ const by = Object.fromEntries(CURRENCIES.map((cur) => [cur, swapAmount(S[id], cur, amountFor(cur))]));
+ if (by.EUR !== S[id]) throw new Error("the book's EUR amount differs from the approved string " + id + ": " + by.EUR + " vs " + S[id]);
+ return `<${tag}${cls ? ` class="${cls}"` : ""} data-copy="${id}" data-currency-values="${values(by)}">${esc(S[id])}</${tag}>`;
+};
+const bandCell = (plan, band) => `<td data-currency-values="${values(Object.fromEntries(CURRENCIES.map((cur) => [cur, money(cur, unitMinor(plan, cur, band))])))}">${esc(money("EUR", unitMinor(plan, "EUR", band)))}</td>`;
+const overageRate = (meter) => (cur) => rate(cur, book.overage[cur.toLowerCase()][meter]);
+const defaultCurrencyFor = (iso2) => (book.country_currency[iso2] || book.default_currency).toUpperCase();
const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const t = (id, tag = "span", cls = "") => {
if (!(id in S)) throw new Error("missing string " + id);
@@ -22,15 +67,57 @@ for (const r of recs) byBand[r.band].push(r.name);
const bandList = Object.entries(byBand).map(([b, names]) =>
` <div class="band-col"><h3>Band ${b}</h3><p>${names.map(esc).join(", ")}</p></div>`).join("\n");
-const card = (p, featured) => ` <article class="plan${featured ? " featured" : ""}">
+// The plan cards, grouped as the approved review laid them out: a team
+// plan's per-member inclusions sit under the shared "Each member adds"
+// heading (plan.team.allowances), and what Business adds beyond them
+// (continuous replication, priority support) is its own list.
+const planCardLists = {
+ free: { plain: ["i1", "i2", "i3", "i4", "i5", "i6", "i7", "i8"] },
+ personal: { plain: ["i1", "i2", "i3", "i4"] },
+ community: { allowances: ["i1", "i2", "i3"] },
+ business: { allowances: ["i1", "i2", "i3", "i4"], features: ["i5", "i6"] },
+};
+const priceLine = (p) => {
+ if (p === "free") return t(`plan.${p}.price`);
+ const plan = plansByKey[p];
+ return priced(`plan.${p}.price`, (cur) => money(cur, plan.annual_unit_minor[cur.toLowerCase()].A));
+};
+const card = (p, featured) => {
+ const lists = planCardLists[p];
+ const items = (ids) => ids.map((i) => li(`plan.${p}.${i}`)).join("\n");
+ const seen = new Set([...(lists.plain || []), ...(lists.allowances || []), ...(lists.features || [])].map((i) => `plan.${p}.${i}`));
+ for (const k of Object.keys(S).filter((k) => k.startsWith(`plan.${p}.i`))) if (!seen.has(k)) throw new Error("unplaced string " + k);
+ return ` <article class="plan${featured ? " featured" : ""}">
${t(`plan.${p}.name`, "h3")}
- <p class="price">${t(`plan.${p}.price`)}</p>
+ <p class="price">${priceLine(p)}</p>
${S[`plan.${p}.min`] ? ` <p class="min">${t(`plan.${p}.min`)}</p>\n` : ""} <p class="for">${t(`plan.${p}.for`)}</p>
- <ul>
-${Object.keys(S).filter((k) => k.startsWith(`plan.${p}.i`)).map(li).join("\n")}
- </ul>
- <a class="btn ${featured ? "btn-primary" : "btn-quiet"}" href="https://console.carloku.com" data-copy="plan.${p}.cta">${esc(S[`plan.${p}.cta`])}</a>
+${lists.plain ? ` <ul>\n${items(lists.plain)}\n </ul>\n` : ""}${lists.allowances ? ` <p class="allowance-heading" data-copy="plan.team.allowances">${esc(S["plan.team.allowances"])}</p>\n <ul class="allowances">\n${items(lists.allowances)}\n </ul>\n` : ""}${lists.features ? ` <ul class="plan-features">\n${items(lists.features)}\n </ul>\n` : ""} <a class="btn ${featured ? "btn-primary" : "btn-quiet"}" href="https://console.carloku.com/?plan=${p}" data-pricing-signup data-copy="plan.${p}.cta">${esc(S[`plan.${p}.cta`])}</a>
</article>`;
+};
+
+// The currency and billing-country controls, in the approved words. The
+// country list is every country the book sells to, with its default
+// currency; the selector script (site.js) applies the choice to every
+// priced string on the page and to the signup links. No price is charged
+// from this page: checkout prices from the same book.
+const countryOptions = recs.map((r) => `<option value="${r.iso2}" data-currency="${defaultCurrencyFor(r.iso2)}">${esc(r.name)}</option>`).join("");
+const controls = ` <section class="pricing-controls">
+ <div class="wrap narrow">
+ <form class="currency-form" onsubmit="return false">
+ <div class="control">
+ <label for="pricing-currency" data-copy="currency.label">${esc(S["currency.label"])}</label>
+ <select id="pricing-currency" data-pricing-currency>${CURRENCIES.map((c) => `<option value="${c}">${c}</option>`).join("")}</select>
+ <p class="control-note" data-copy="currency.explanation">${esc(S["currency.explanation"])}</p>
+ </div>
+ <div class="control">
+ <label for="pricing-country" data-copy="country.label">${esc(S["country.label"])}</label>
+ <select id="pricing-country" data-pricing-country><option value=""></option>${countryOptions}</select>
+ <p class="control-note" data-copy="country.explanation">${esc(S["country.explanation"])}</p>
+ </div>
+ </form>
+ <p class="control-note fixed" data-copy="currency.fixed">${esc(S["currency.fixed"])}</p>
+ </div>
+ </section>`;
const page = `<!doctype html>
<html lang="en">
@@ -72,6 +159,7 @@ const page = `<!doctype html>
<p class="hero-note" data-copy="hero.new">${esc(S["hero.new"])}</p>
</div>
</div>
+${controls}
<section class="plans-section">
<div class="wrap">
${t("plans.heading", "h2")}
@@ -101,9 +189,7 @@ ${["a", "b", "c", "d"].map((b) => li("bands." + b)).join("\n")}
<table class="price-table">
<thead><tr><th scope="col"><span class="visually-hidden">Plan</span></th><th scope="col">A</th><th scope="col">B</th><th scope="col">C</th><th scope="col">D</th></tr></thead>
<tbody>
- <tr><th scope="row" data-copy="bands.table.personal">${esc(S["bands.table.personal"])}</th><td>€24</td><td>€18</td><td>€12</td><td>€8.40</td></tr>
- <tr><th scope="row" data-copy="bands.table.community">${esc(S["bands.table.community"])}</th><td>€5</td><td>€3.75</td><td>€2.50</td><td>€1.75</td></tr>
- <tr><th scope="row" data-copy="bands.table.business">${esc(S["bands.table.business"])}</th><td>€50</td><td>€37.50</td><td>€25</td><td>€17.50</td></tr>
+${["personal", "community", "business"].map((pl) => ` <tr><th scope="row" data-copy="bands.table.${pl}">${esc(S[`bands.table.${pl}`])}</th>${["A", "B", "C", "D"].map((b) => bandCell(pl, b)).join("")}</tr>`).join("\n")}
</tbody>
</table>
<p data-copy="bands.p2">${esc(S["bands.p2"])}</p>
@@ -120,13 +206,13 @@ ${bandList}
<p data-copy="over.p1">${esc(S["over.p1"])}</p>
<table class="price-table rates">
<tbody>
- <tr><th scope="row" data-copy="over.table.awake">${esc(S["over.table.awake"])}</th><td data-copy="over.rate.awake">${esc(S["over.rate.awake"])}</td></tr>
- <tr><th scope="row" data-copy="over.table.storage">${esc(S["over.table.storage"])}</th><td data-copy="over.rate.storage">${esc(S["over.rate.storage"])}</td></tr>
- <tr><th scope="row" data-copy="over.table.transfer">${esc(S["over.table.transfer"])}</th><td data-copy="over.rate.transfer">${esc(S["over.rate.transfer"])}</td></tr>
- <tr><th scope="row" data-copy="over.table.writes">${esc(S["over.table.writes"])}</th><td data-copy="over.rate.writes">${esc(S["over.rate.writes"])}</td></tr>
+ <tr><th scope="row" data-copy="over.table.awake">${esc(S["over.table.awake"])}</th>${priced("over.rate.awake", overageRate("awake_hour_micro"), "td")}</tr>
+ <tr><th scope="row" data-copy="over.table.storage">${esc(S["over.table.storage"])}</th>${priced("over.rate.storage", overageRate("storage_gb_month_micro"), "td")}</tr>
+ <tr><th scope="row" data-copy="over.table.transfer">${esc(S["over.table.transfer"])}</th>${priced("over.rate.transfer", overageRate("transfer_gb_micro"), "td")}</tr>
+ <tr><th scope="row" data-copy="over.table.writes">${esc(S["over.table.writes"])}</th>${priced("over.rate.writes", overageRate("writes_1000_micro"), "td")}</tr>
</tbody>
</table>
- <p data-copy="over.p2">${esc(S["over.p2"])}</p>
+ ${priced("over.p2", (cur) => money(cur, book.overage[cur.toLowerCase()].invoice_floor_micro / 10000), "p")}
<p data-copy="over.p3">${esc(S["over.p3"])}</p>
</div>
</section>
@@ -152,7 +238,7 @@ ${bandList}
<h2><span data-copy="start.heading">${esc(S["start.heading"])}</span><span class="dot">.</span></h2>
<p data-copy="start.p1">${esc(S["start.p1"])}</p>
<div class="cta">
- <a class="btn btn-primary" href="https://console.carloku.com" data-copy="start.cta.console">${esc(S["start.cta.console"])}</a>
+ <a class="btn btn-primary" href="https://console.carloku.com" data-pricing-signup data-copy="start.cta.console">${esc(S["start.cta.console"])}</a>
<a class="btn btn-quiet" href="/get-started/" data-copy="start.cta.guide">${esc(S["start.cta.guide"])}</a>
</div>
</div>
@@ -176,10 +262,12 @@ const prev = path.join(here, "preview");
fs.mkdirSync(path.join(prev, "fonts"), { recursive: true });
for (const f of ["site.css", "favicon.svg"]) fs.copyFileSync(path.join(root, "src", f), path.join(prev, f));
for (const f of fs.readdirSync(path.join(root, "src", "fonts"))) fs.copyFileSync(path.join(root, "src", "fonts", f), path.join(prev, "fonts", f));
+for (const f of ["site.js"]) fs.copyFileSync(path.join(root, "src", f), path.join(prev, f));
let previewHtml = page
.replace(/href="\/site\.css"/, 'href="site.css"')
+ .replace(/<script src="\/site\.js" defer><\/script>/, '<script src="site.js" defer></script>')
.replace(/href="\/fonts\//g, 'href="fonts/"'.slice(0, -1))
.replace(/href="\/favicon\.svg"/, 'href="favicon.svg"')
- .replace(/<script src="\/site\.js" defer><\/script>\n/, "");
+ ;
fs.writeFileSync(path.join(prev, "index.html"), previewHtml);
console.log("wrote src/pricing/index.html and copy-review/pricing/preview/index.html", strings.length, "strings");
diff --git a/copy-review/pricing/pricebook.json b/copy-review/pricing/pricebook.json
new file mode 100644
index 0000000..9e53c66
--- /dev/null
+++ b/copy-review/pricing/pricebook.json
@@ -0,0 +1,414 @@
+{
+ "schema": 1,
+ "price_book": "2026-10-01-draft1",
+ "terms_version": "2026-09-07-v1",
+ "status": "draft",
+ "default_currency": "usd",
+ "currencies": [
+ {
+ "code": "usd",
+ "symbol": "$",
+ "minor_per_major": 100
+ },
+ {
+ "code": "eur",
+ "symbol": "€",
+ "minor_per_major": 100
+ },
+ {
+ "code": "gbp",
+ "symbol": "£",
+ "minor_per_major": 100
+ }
+ ],
+ "country_currency": {
+ "AT": "eur",
+ "BE": "eur",
+ "BG": "eur",
+ "CY": "eur",
+ "CZ": "eur",
+ "DE": "eur",
+ "DK": "eur",
+ "EE": "eur",
+ "ES": "eur",
+ "FI": "eur",
+ "FR": "eur",
+ "GB": "gbp",
+ "GR": "eur",
+ "HR": "eur",
+ "HU": "eur",
+ "IE": "eur",
+ "IT": "eur",
+ "LT": "eur",
+ "LU": "eur",
+ "LV": "eur",
+ "MT": "eur",
+ "NL": "eur",
+ "PL": "eur",
+ "PT": "eur",
+ "RO": "eur",
+ "SE": "eur",
+ "SI": "eur",
+ "SK": "eur"
+ },
+ "country_band": {
+ "AD": "B",
+ "AE": "B",
+ "AF": "D",
+ "AG": "B",
+ "AL": "C",
+ "AM": "D",
+ "AO": "D",
+ "AR": "C",
+ "AT": "A",
+ "AU": "A",
+ "AW": "B",
+ "AZ": "D",
+ "BA": "D",
+ "BB": "A",
+ "BD": "D",
+ "BE": "A",
+ "BF": "D",
+ "BG": "D",
+ "BH": "C",
+ "BI": "D",
+ "BJ": "D",
+ "BM": "A",
+ "BN": "D",
+ "BO": "D",
+ "BR": "C",
+ "BS": "A",
+ "BT": "D",
+ "BW": "D",
+ "BY": "D",
+ "BZ": "C",
+ "CA": "A",
+ "CD": "D",
+ "CF": "D",
+ "CG": "D",
+ "CH": "A",
+ "CI": "D",
+ "CL": "C",
+ "CM": "D",
+ "CN": "C",
+ "CO": "D",
+ "CR": "C",
+ "CV": "C",
+ "CW": "B",
+ "CY": "C",
+ "CZ": "C",
+ "DE": "A",
+ "DJ": "C",
+ "DK": "A",
+ "DM": "C",
+ "DO": "D",
+ "DZ": "D",
+ "EC": "C",
+ "EE": "C",
+ "EG": "D",
+ "ES": "A",
+ "ET": "D",
+ "FI": "A",
+ "FJ": "D",
+ "FM": "A",
+ "FO": "A",
+ "FR": "A",
+ "GA": "D",
+ "GB": "A",
+ "GD": "C",
+ "GE": "D",
+ "GH": "D",
+ "GL": "B",
+ "GM": "D",
+ "GN": "D",
+ "GQ": "D",
+ "GR": "A",
+ "GT": "C",
+ "GW": "D",
+ "GY": "D",
+ "HK": "B",
+ "HN": "C",
+ "HR": "C",
+ "HT": "B",
+ "HU": "C",
+ "ID": "D",
+ "IE": "A",
+ "IL": "A",
+ "IN": "D",
+ "IQ": "D",
+ "IR": "D",
+ "IS": "A",
+ "IT": "A",
+ "JM": "C",
+ "JO": "C",
+ "JP": "C",
+ "KE": "D",
+ "KG": "D",
+ "KH": "D",
+ "KI": "B",
+ "KM": "C",
+ "KN": "B",
+ "KR": "C",
+ "KW": "C",
+ "KY": "A",
+ "KZ": "D",
+ "LA": "D",
+ "LB": "D",
+ "LC": "C",
+ "LK": "D",
+ "LR": "C",
+ "LS": "D",
+ "LT": "C",
+ "LU": "A",
+ "LV": "C",
+ "LY": "C",
+ "MA": "D",
+ "MD": "D",
+ "ME": "D",
+ "MG": "D",
+ "MH": "A",
+ "MK": "D",
+ "ML": "D",
+ "MM": "D",
+ "MN": "D",
+ "MO": "C",
+ "MR": "D",
+ "MT": "B",
+ "MU": "D",
+ "MV": "C",
+ "MW": "D",
+ "MX": "C",
+ "MY": "D",
+ "MZ": "D",
+ "NA": "D",
+ "NE": "D",
+ "NG": "D",
+ "NI": "D",
+ "NL": "A",
+ "NO": "B",
+ "NP": "D",
+ "NR": "A",
+ "NZ": "A",
+ "OM": "C",
+ "PA": "C",
+ "PE": "C",
+ "PG": "C",
+ "PH": "D",
+ "PK": "D",
+ "PL": "C",
+ "PR": "B",
+ "PS": "B",
+ "PT": "A",
+ "PW": "B",
+ "PY": "D",
+ "QA": "C",
+ "RO": "D",
+ "RS": "D",
+ "RU": "D",
+ "RW": "D",
+ "SA": "C",
+ "SB": "B",
+ "SC": "C",
+ "SD": "C",
+ "SE": "A",
+ "SG": "C",
+ "SI": "C",
+ "SK": "C",
+ "SL": "D",
+ "SM": "B",
+ "SN": "D",
+ "SO": "D",
+ "SR": "D",
+ "ST": "C",
+ "SV": "D",
+ "SX": "B",
+ "SY": "D",
+ "SZ": "D",
+ "TC": "A",
+ "TD": "D",
+ "TG": "D",
+ "TH": "D",
+ "TJ": "D",
+ "TL": "D",
+ "TM": "D",
+ "TN": "D",
+ "TO": "B",
+ "TR": "D",
+ "TT": "C",
+ "TV": "A",
+ "TZ": "D",
+ "UA": "D",
+ "UG": "D",
+ "US": "A",
+ "UY": "B",
+ "UZ": "D",
+ "VC": "C",
+ "VI": "A",
+ "VN": "D",
+ "VU": "A",
+ "WS": "C",
+ "XK": "D",
+ "ZA": "D",
+ "ZM": "D",
+ "ZW": "D"
+ },
+ "band_factors_bps": {
+ "A": 10000,
+ "B": 7500,
+ "C": 5000,
+ "D": 3500
+ },
+ "plans": [
+ {
+ "key": "free",
+ "min_quantity": 1,
+ "max_quantity": 1,
+ "band_scaled": false,
+ "annual_unit_minor": {
+ "eur": {
+ "A": 0,
+ "B": 0,
+ "C": 0,
+ "D": 0
+ },
+ "gbp": {
+ "A": 0,
+ "B": 0,
+ "C": 0,
+ "D": 0
+ },
+ "usd": {
+ "A": 0,
+ "B": 0,
+ "C": 0,
+ "D": 0
+ }
+ },
+ "resources_per_member": {
+ "awake_seconds": 360000,
+ "storage_byte_months": 250000000,
+ "transfer_bytes": 1000000000,
+ "writes": 0
+ }
+ },
+ {
+ "key": "personal",
+ "min_quantity": 1,
+ "max_quantity": 1,
+ "band_scaled": true,
+ "annual_unit_minor": {
+ "eur": {
+ "A": 2400,
+ "B": 1800,
+ "C": 1200,
+ "D": 840
+ },
+ "gbp": {
+ "A": 2200,
+ "B": 1650,
+ "C": 1100,
+ "D": 770
+ },
+ "usd": {
+ "A": 2800,
+ "B": 2100,
+ "C": 1400,
+ "D": 980
+ }
+ },
+ "resources_per_member": {
+ "awake_seconds": 306000,
+ "storage_byte_months": 2000000000,
+ "transfer_bytes": 2000000000,
+ "writes": 0
+ }
+ },
+ {
+ "key": "community",
+ "min_quantity": 10,
+ "max_quantity": 1000000,
+ "band_scaled": true,
+ "annual_unit_minor": {
+ "eur": {
+ "A": 500,
+ "B": 375,
+ "C": 250,
+ "D": 175
+ },
+ "gbp": {
+ "A": 450,
+ "B": 338,
+ "C": 225,
+ "D": 158
+ },
+ "usd": {
+ "A": 600,
+ "B": 450,
+ "C": 300,
+ "D": 210
+ }
+ },
+ "resources_per_member": {
+ "awake_seconds": 61200,
+ "storage_byte_months": 500000000,
+ "transfer_bytes": 400000000,
+ "writes": 0
+ }
+ },
+ {
+ "key": "business",
+ "min_quantity": 10,
+ "max_quantity": 1000000,
+ "band_scaled": true,
+ "annual_unit_minor": {
+ "eur": {
+ "A": 5000,
+ "B": 3750,
+ "C": 2500,
+ "D": 1750
+ },
+ "gbp": {
+ "A": 4500,
+ "B": 3375,
+ "C": 2250,
+ "D": 1575
+ },
+ "usd": {
+ "A": 6000,
+ "B": 4500,
+ "C": 3000,
+ "D": 2100
+ }
+ },
+ "resources_per_member": {
+ "awake_seconds": 612000,
+ "storage_byte_months": 5000000000,
+ "transfer_bytes": 3000000000,
+ "writes": 30000
+ }
+ }
+ ],
+ "overage": {
+ "eur": {
+ "awake_hour_micro": 10000,
+ "storage_gb_month_micro": 106000,
+ "transfer_gb_micro": 414000,
+ "writes_1000_micro": 23000,
+ "invoice_floor_micro": 1000000
+ },
+ "gbp": {
+ "awake_hour_micro": 9000,
+ "storage_gb_month_micro": 95400,
+ "transfer_gb_micro": 372600,
+ "writes_1000_micro": 20700,
+ "invoice_floor_micro": 900000
+ },
+ "usd": {
+ "awake_hour_micro": 12000,
+ "storage_gb_month_micro": 127200,
+ "transfer_gb_micro": 496800,
+ "writes_1000_micro": 27600,
+ "invoice_floor_micro": 1200000
+ }
+ }
+}
diff --git a/copy-review/pricing/pricebook.source.txt b/copy-review/pricing/pricebook.source.txt
new file mode 100644
index 0000000..6cdedfc
--- /dev/null
+++ b/copy-review/pricing/pricebook.source.txt
@@ -0,0 +1 @@
+copy-review/pricing/pricebook.json is generated by the platform (carlos/platform, go run ./cmd/pricebook-export) and copied here unchanged. Source: branch feat/carloku-multi-currency-20261001, internal/plans/public-pricebook.json at commit 5047494b. Do not edit; replace with a fresh export.
diff --git a/src/pricing/index.html b/src/pricing/index.html
index 223c0f2..84cc146 100644
--- a/src/pricing/index.html
+++ b/src/pricing/index.html
@@ -3,10 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Pricing — Carloku</title>
-<meta name="description" content="🤖 Four plans, priced per year in euro. Start on Free. Personal, Community and Business add room and continuous replication. Usage beyond the inclusions is billed monthly at published rates.">
-<meta property="og:title" content="Pricing — Carloku">
-<meta property="og:description" content="Four plans, priced per year in euro. Start on Free. Personal, Community and Business add room and continuous replication. Usage beyond the inclusions is billed monthly at published rates.">
+<title>Carloku Pricing</title>
+<meta name="description" content="🤖 Carloku is priced for convenience and fairness. Four plans covering hobbyists to pro teams.">
+<meta property="og:title" content="Carloku Pricing">
+<meta property="og:description" content="Carloku is priced for convenience and fairness. Four plans covering hobbyists to pro teams.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://carloku.com/pricing/">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
@@ -34,10 +34,27 @@
<div class="page-hero">
<div class="wrap narrow">
<h1><span>Pricing</span><span class="dot">.</span></h1>
- <p class="lede">Four plans, priced per year in euro before VAT. Start on Free. When you want more room, pick a paid plan from your account's Billing page.</p>
- <p class="hero-note">Paid plans and usage billing are new on Carloku.</p>
+ <p class="lede">Carloku is priced for convenience and fairness. Four plans covering hobbyists to pro teams.</p>
+ <p class="hero-note">Choose the plan that works best for you. CARLOS apps scale to zero when they're not in use. You only pay for the time that they're awake.</p>
</div>
</div>
+ <section class="pricing-controls">
+ <div class="wrap narrow">
+ <form class="currency-form" onsubmit="return false">
+ <div class="control">
+ <label for="pricing-currency">Currency</label>
+ <select id="pricing-currency" data-pricing-currency><option value="USD">USD</option><option value="EUR">EUR</option><option value="GBP">GBP</option></select>
+ <p class="control-note">Choose USD, EUR or GBP for your subscription and usage bills.</p>
+ </div>
+ <div class="control">
+ <label for="pricing-country">Billing country</label>
+ <select id="pricing-country" data-pricing-country><option value=""></option><option value="AF" data-currency="USD">Afghanistan</option><option value="AL" data-currency="USD">Albania</option><option value="DZ" data-currency="USD">Algeria</option><option value="AD" data-currency="USD">Andorra</option><option value="AO" data-currency="USD">Angola</option><option value="AG" data-currency="USD">Antigua and Barbuda</option><option value="AR" data-currency="USD">Argentina</option><option value="AM" data-currency="USD">Armenia</option><option value="AW" data-currency="USD">Aruba</option><option value="AU" data-currency="USD">Australia</option><option value="AT" data-currency="EUR">Austria</option><option value="AZ" data-currency="USD">Azerbaijan</option><option value="BS" data-currency="USD">Bahamas, The</option><option value="BH" data-currency="USD">Bahrain</option><option value="BD" data-currency="USD">Bangladesh</option><option value="BB" data-currency="USD">Barbados</option><option value="BY" data-currency="USD">Belarus</option><option value="BE" data-currency="EUR">Belgium</option><option value="BZ" data-currency="USD">Belize</option><option value="BJ" data-currency="USD">Benin</option><option value="BM" data-currency="USD">Bermuda</option><option value="BT" data-currency="USD">Bhutan</option><option value="BO" data-currency="USD">Bolivia</option><option value="BA" data-currency="USD">Bosnia and Herzegovina</option><option value="BW" data-currency="USD">Botswana</option><option value="BR" data-currency="USD">Brazil</option><option value="BN" data-currency="USD">Brunei Darussalam</option><option value="BG" data-currency="EUR">Bulgaria</option><option value="BF" data-currency="USD">Burkina Faso</option><option value="BI" data-currency="USD">Burundi</option><option value="CV" data-currency="USD">Cabo Verde</option><option value="KH" data-currency="USD">Cambodia</option><option value="CM" data-currency="USD">Cameroon</option><option value="CA" data-currency="USD">Canada</option><option value="KY" data-currency="USD">Cayman Islands</option><option value="CF" data-currency="USD">Central African Republic</option><option value="TD" data-currency="USD">Chad</option><option value="CL" data-currency="USD">Chile</option><option value="CN" data-currency="USD">China</option><option value="CO" data-currency="USD">Colombia</option><option value="KM" data-currency="USD">Comoros</option><option value="CD" data-currency="USD">Congo, Dem. Rep.</option><option value="CG" data-currency="USD">Congo, Rep.</option><option value="CR" data-currency="USD">Costa Rica</option><option value="CI" data-currency="USD">Cote d'Ivoire</option><option value="HR" data-currency="EUR">Croatia</option><option value="CW" data-currency="USD">Curacao</option><option value="CY" data-currency="EUR">Cyprus</option><option value="CZ" data-currency="EUR">Czechia</option><option value="DK" data-currency="EUR">Denmark</option><option value="DJ" data-currency="USD">Djibouti</option><option value="DM" data-currency="USD">Dominica</option><option value="DO" data-currency="USD">Dominican Republic</option><option value="EC" data-currency="USD">Ecuador</option><option value="EG" data-currency="USD">Egypt, Arab Rep.</option><option value="SV" data-currency="USD">El Salvador</option><option value="GQ" data-currency="USD">Equatorial Guinea</option><option value="EE" data-currency="EUR">Estonia</option><option value="SZ" data-currency="USD">Eswatini</option><option value="ET" data-currency="USD">Ethiopia</option><option value="FO" data-currency="USD">Faroe Islands</option><option value="FJ" data-currency="USD">Fiji</option><option value="FI" data-currency="EUR">Finland</option><option value="FR" data-currency="EUR">France</option><option value="GA" data-currency="USD">Gabon</option><option value="GM" data-currency="USD">Gambia, The</option><option value="GE" data-currency="USD">Georgia</option><option value="DE" data-currency="EUR">Germany</option><option value="GH" data-currency="USD">Ghana</option><option value="GR" data-currency="EUR">Greece</option><option value="GL" data-currency="USD">Greenland</option><option value="GD" data-currency="USD">Grenada</option><option value="GT" data-currency="USD">Guatemala</option><option value="GN" data-currency="USD">Guinea</option><option value="GW" data-currency="USD">Guinea-Bissau</option><option value="GY" data-currency="USD">Guyana</option><option value="HT" data-currency="USD">Haiti</option><option value="HN" data-currency="USD">Honduras</option><option value="HK" data-currency="USD">Hong Kong SAR, China</option><option value="HU" data-currency="EUR">Hungary</option><option value="IS" data-currency="USD">Iceland</option><option value="IN" data-currency="USD">India</option><option value="ID" data-currency="USD">Indonesia</option><option value="IR" data-currency="USD">Iran, Islamic Rep.</option><option value="IQ" data-currency="USD">Iraq</option><option value="IE" data-currency="EUR">Ireland</option><option value="IL" data-currency="USD">Israel</option><option value="IT" data-currency="EUR">Italy</option><option value="JM" data-currency="USD">Jamaica</option><option value="JP" data-currency="USD">Japan</option><option value="JO" data-currency="USD">Jordan</option><option value="KZ" data-currency="USD">Kazakhstan</option><option value="KE" data-currency="USD">Kenya</option><option value="KI" data-currency="USD">Kiribati</option><option value="KR" data-currency="USD">Korea, Rep.</option><option value="XK" data-currency="USD">Kosovo</option><option value="KW" data-currency="USD">Kuwait</option><option value="KG" data-currency="USD">Kyrgyz Republic</option><option value="LA" data-currency="USD">Lao PDR</option><option value="LV" data-currency="EUR">Latvia</option><option value="LB" data-currency="USD">Lebanon</option><option value="LS" data-currency="USD">Lesotho</option><option value="LR" data-currency="USD">Liberia</option><option value="LY" data-currency="USD">Libya</option><option value="LT" data-currency="EUR">Lithuania</option><option value="LU" data-currency="EUR">Luxembourg</option><option value="MO" data-currency="USD">Macao SAR, China</option><option value="MG" data-currency="USD">Madagascar</option><option value="MW" data-currency="USD">Malawi</option><option value="MY" data-currency="USD">Malaysia</option><option value="MV" data-currency="USD">Maldives</option><option value="ML" data-currency="USD">Mali</option><option value="MT" data-currency="EUR">Malta</option><option value="MH" data-currency="USD">Marshall Islands</option><option value="MR" data-currency="USD">Mauritania</option><option value="MU" data-currency="USD">Mauritius</option><option value="MX" data-currency="USD">Mexico</option><option value="FM" data-currency="USD">Micronesia, Fed. Sts.</option><option value="MD" data-currency="USD">Moldova</option><option value="MN" data-currency="USD">Mongolia</option><option value="ME" data-currency="USD">Montenegro</option><option value="MA" data-currency="USD">Morocco</option><option value="MZ" data-currency="USD">Mozambique</option><option value="MM" data-currency="USD">Myanmar</option><option value="NA" data-currency="USD">Namibia</option><option value="NR" data-currency="USD">Naoero</option><option value="NP" data-currency="USD">Nepal</option><option value="NL" data-currency="EUR">Netherlands</option><option value="NZ" data-currency="USD">New Zealand</option><option value="NI" data-currency="USD">Nicaragua</option><option value="NE" data-currency="USD">Niger</option><option value="NG" data-currency="USD">Nigeria</option><option value="MK" data-currency="USD">North Macedonia</option><option value="NO" data-currency="USD">Norway</option><option value="OM" data-currency="USD">Oman</option><option value="PK" data-currency="USD">Pakistan</option><option value="PW" data-currency="USD">Palau</option><option value="PA" data-currency="USD">Panama</option><option value="PG" data-currency="USD">Papua New Guinea</option><option value="PY" data-currency="USD">Paraguay</option><option value="PE" data-currency="USD">Peru</option><option value="PH" data-currency="USD">Philippines</option><option value="PL" data-currency="EUR">Poland</option><option value="PT" data-currency="EUR">Portugal</option><option value="PR" data-currency="USD">Puerto Rico (US)</option><option value="QA" data-currency="USD">Qatar</option><option value="RO" data-currency="EUR">Romania</option><option value="RU" data-currency="USD">Russian Federation</option><option value="RW" data-currency="USD">Rwanda</option><option value="WS" data-currency="USD">Samoa</option><option value="SM" data-currency="USD">San Marino</option><option value="ST" data-currency="USD">Sao Tome and Principe</option><option value="SA" data-currency="USD">Saudi Arabia</option><option value="SN" data-currency="USD">Senegal</option><option value="RS" data-currency="USD">Serbia</option><option value="SC" data-currency="USD">Seychelles</option><option value="SL" data-currency="USD">Sierra Leone</option><option value="SG" data-currency="USD">Singapore</option><option value="SX" data-currency="USD">Sint Maarten (Dutch part)</option><option value="SK" data-currency="EUR">Slovak Republic</option><option value="SI" data-currency="EUR">Slovenia</option><option value="SB" data-currency="USD">Solomon Islands</option><option value="SO" data-currency="USD">Somalia, Fed. Rep.</option><option value="ZA" data-currency="USD">South Africa</option><option value="ES" data-currency="EUR">Spain</option><option value="LK" data-currency="USD">Sri Lanka</option><option value="KN" data-currency="USD">St. Kitts and Nevis</option><option value="LC" data-currency="USD">St. Lucia</option><option value="VC" data-currency="USD">St. Vincent and the Grenadines</option><option value="SD" data-currency="USD">Sudan</option><option value="SR" data-currency="USD">Suriname</option><option value="SE" data-currency="EUR">Sweden</option><option value="CH" data-currency="USD">Switzerland</option><option value="SY" data-currency="USD">Syrian Arab Republic</option><option value="TJ" data-currency="USD">Tajikistan</option><option value="TZ" data-currency="USD">Tanzania</option><option value="TH" data-currency="USD">Thailand</option><option value="TL" data-currency="USD">Timor-Leste</option><option value="TG" data-currency="USD">Togo</option><option value="TO" data-currency="USD">Tonga</option><option value="TT" data-currency="USD">Trinidad and Tobago</option><option value="TN" data-currency="USD">Tunisia</option><option value="TR" data-currency="USD">Turkiye</option><option value="TM" data-currency="USD">Turkmenistan</option><option value="TC" data-currency="USD">Turks and Caicos Islands</option><option value="TV" data-currency="USD">Tuvalu</option><option value="UG" data-currency="USD">Uganda</option><option value="UA" data-currency="USD">Ukraine</option><option value="AE" data-currency="USD">United Arab Emirates</option><option value="GB" data-currency="GBP">United Kingdom</option><option value="US" data-currency="USD">United States</option><option value="UY" data-currency="USD">Uruguay</option><option value="UZ" data-currency="USD">Uzbekistan</option><option value="VU" data-currency="USD">Vanuatu</option><option value="VN" data-currency="USD">Viet Nam</option><option value="VI" data-currency="USD">Virgin Islands (U.S.)</option><option value="PS" data-currency="USD">West Bank and Gaza</option><option value="ZM" data-currency="USD">Zambia</option><option value="ZW" data-currency="USD">Zimbabwe</option></select>
+ <p class="control-note">Your billing country sets your pricing band. Changing currency does not change your inclusions.</p>
+ </div>
+ </form>
+ <p class="control-note fixed">Your selected currency stays with your subscription and usage bills.</p>
+ </div>
+ </section>
<section class="plans-section">
<div class="wrap">
<h2>The plans</h2>
@@ -45,7 +62,7 @@
<article class="plan">
<h3>Free</h3>
<p class="price"><span>€0</span></p>
- <p class="for"><span>For trying Carloku, or for a small app you never want to pay for.</span></p>
+ <p class="for"><span>Free to try, great for tiny apps.</span></p>
<ul>
<li>One member</li>
<li>3 apps</li>
@@ -53,64 +70,68 @@
<li>250 MB of storage</li>
<li>1 GB of transfer a month</li>
<li>Apps sleep after 2 minutes idle</li>
- <li>Your data is saved when an app sleeps. There is no continuous replication and no restore to an earlier moment.</li>
- <li>An app nobody visits for 90 days is archived: its address stops answering and its data is kept, compressed, and restored on request. You get an email at 60 and 80 days. Nothing is ever deleted.</li>
+ <li>Data stored on sleep. No data replication, no data restore.</li>
+ <li>Apps not visited after 90 days are archived with notice. These apps can be restored later by request.</li>
</ul>
- <a class="btn btn-quiet" href="https://console.carloku.com">Create an account</a>
+ <a class="btn btn-quiet" href="https://console.carloku.com/?plan=free" data-pricing-signup>Create an account</a>
</article>
<article class="plan featured">
<h3>Personal</h3>
- <p class="price"><span>€24 a year</span></p>
- <p class="for"><span>For one person running a few apps.</span></p>
+ <p class="price"><span data-currency-values="{&quot;USD&quot;:&quot;$28/y&quot;,&quot;EUR&quot;:&quot;€24/y&quot;,&quot;GBP&quot;:&quot;£22/y&quot;}">€24/y</span></p>
+ <p class="for"><span>For individuals running a few apps.</span></p>
<ul>
<li>One member</li>
<li>85 awake hours a month</li>
<li>2 GB of storage</li>
<li>2 GB of transfer a month</li>
</ul>
- <a class="btn btn-primary" href="https://console.carloku.com">Choose Personal</a>
+ <a class="btn btn-primary" href="https://console.carloku.com/?plan=personal" data-pricing-signup>Choose Personal</a>
</article>
<article class="plan">
<h3>Community</h3>
- <p class="price"><span>€5 per member a year</span></p>
- <p class="min"><span>Minimum 10 members, so €50 a year to start.</span></p>
- <p class="for"><span>For a group sharing one account: a club, a class, an open-source project.</span></p>
- <ul>
- <li>Each member adds 17 awake hours a month</li>
- <li>Each member adds 0.5 GB of storage</li>
- <li>Each member adds 0.4 GB of transfer a month</li>
+ <p class="price"><span data-currency-values="{&quot;USD&quot;:&quot;$6/y per member&quot;,&quot;EUR&quot;:&quot;€5/y per member&quot;,&quot;GBP&quot;:&quot;£4.50/y per member&quot;}">€5/y per member</span></p>
+ <p class="min"><span>Minimum 10 members.</span></p>
+ <p class="for"><span>For a groups, clubs, classes, an open-source projects.</span></p>
+ <p class="allowance-heading">Each member adds</p>
+ <ul class="allowances">
+ <li>17 awake hours a month</li>
+ <li>0.5 GB of storage</li>
+ <li>0.4 GB of transfer a month</li>
</ul>
- <a class="btn btn-quiet" href="https://console.carloku.com">Choose Community</a>
+ <a class="btn btn-quiet" href="https://console.carloku.com/?plan=community" data-pricing-signup>Choose Community</a>
</article>
<article class="plan">
<h3>Business</h3>
- <p class="price"><span>€50 per member a year</span></p>
- <p class="min"><span>Minimum 10 members, so €500 a year to start.</span></p>
+ <p class="price"><span data-currency-values="{&quot;USD&quot;:&quot;$60/y per member&quot;,&quot;EUR&quot;:&quot;€50/y per member&quot;,&quot;GBP&quot;:&quot;£45/y per member&quot;}">€50/y per member</span></p>
+ <p class="min"><span>Minimum 10 members.</span></p>
<p class="for"><span>For teams that need continuous replication and restore.</span></p>
- <ul>
- <li>Each member adds 170 awake hours a month</li>
- <li>Each member adds 5 GB of storage</li>
- <li>Each member adds 3 GB of transfer a month</li>
- <li>Each member adds 30,000 replication writes a month</li>
+ <p class="allowance-heading">Each member adds</p>
+ <ul class="allowances">
+ <li>170 extra awake hours/m</li>
+ <li>5 GB/m extra of storage</li>
+ <li>3 GB of transfer a month</li>
+ <li>30,000 replication writes a month</li>
+ </ul>
+ <ul class="plan-features">
<li>Continuous replication, with restore to an earlier moment</li>
<li>Priority support</li>
</ul>
- <a class="btn btn-quiet" href="https://console.carloku.com">Choose Business</a>
+ <a class="btn btn-quiet" href="https://console.carloku.com/?plan=business" data-pricing-signup>Choose Business</a>
</article>
</div>
- <p class="plans-note">Paid plans are never archived, however quiet the app.</p>
+ <p class="plans-note">Paid plans are never archived. CARLOS apps typically wake in under 500ms.</p>
</div>
</section>
<section class="tint">
<div class="wrap narrow prose">
- <h2>What a member is</h2>
- <p>A member is a person with access to the account in the console. People who use the apps you deploy are never counted and never priced.</p>
+ <h2>Members</h2>
+ <p>Members are team members who can access and ship your sites and apps.</p>
<p>Inclusions are per member, per month. They are pooled across the account and do not roll over.</p>
</div>
</section>
<section>
<div class="wrap narrow prose">
- <h2>Prices where you live</h2>
+ <h2>Indexed pricing</h2>
<p>Prices are indexed to local affordability, using a World Bank price-level measure for your billing country. There are four bands.</p>
<ul>
<li>Band A pays the full price and gets the full inclusions.</li>
@@ -121,9 +142,9 @@
<table class="price-table">
<thead><tr><th scope="col"><span class="visually-hidden">Plan</span></th><th scope="col">A</th><th scope="col">B</th><th scope="col">C</th><th scope="col">D</th></tr></thead>
<tbody>
- <tr><th scope="row">Personal, a year</th><td>€24</td><td>€18</td><td>€12</td><td>€8.40</td></tr>
- <tr><th scope="row">Community, per member a year</th><td>€5</td><td>€3.75</td><td>€2.50</td><td>€1.75</td></tr>
- <tr><th scope="row">Business, per member a year</th><td>€50</td><td>€37.50</td><td>€25</td><td>€17.50</td></tr>
+ <tr><th scope="row">Personal, a year</th><td data-currency-values="{&quot;USD&quot;:&quot;$28&quot;,&quot;EUR&quot;:&quot;€24&quot;,&quot;GBP&quot;:&quot;£22&quot;}">€24</td><td data-currency-values="{&quot;USD&quot;:&quot;$21&quot;,&quot;EUR&quot;:&quot;€18&quot;,&quot;GBP&quot;:&quot;£16.50&quot;}">€18</td><td data-currency-values="{&quot;USD&quot;:&quot;$14&quot;,&quot;EUR&quot;:&quot;€12&quot;,&quot;GBP&quot;:&quot;£11&quot;}">€12</td><td data-currency-values="{&quot;USD&quot;:&quot;$9.80&quot;,&quot;EUR&quot;:&quot;€8.40&quot;,&quot;GBP&quot;:&quot;£7.70&quot;}">€8.40</td></tr>
+ <tr><th scope="row">Community, per member a year</th><td data-currency-values="{&quot;USD&quot;:&quot;$6&quot;,&quot;EUR&quot;:&quot;€5&quot;,&quot;GBP&quot;:&quot;£4.50&quot;}">€5</td><td data-currency-values="{&quot;USD&quot;:&quot;$4.50&quot;,&quot;EUR&quot;:&quot;€3.75&quot;,&quot;GBP&quot;:&quot;£3.38&quot;}">€3.75</td><td data-currency-values="{&quot;USD&quot;:&quot;$3&quot;,&quot;EUR&quot;:&quot;€2.50&quot;,&quot;GBP&quot;:&quot;£2.25&quot;}">€2.50</td><td data-currency-values="{&quot;USD&quot;:&quot;$2.10&quot;,&quot;EUR&quot;:&quot;€1.75&quot;,&quot;GBP&quot;:&quot;£1.58&quot;}">€1.75</td></tr>
+ <tr><th scope="row">Business, per member a year</th><td data-currency-values="{&quot;USD&quot;:&quot;$60&quot;,&quot;EUR&quot;:&quot;€50&quot;,&quot;GBP&quot;:&quot;£45&quot;}">€50</td><td data-currency-values="{&quot;USD&quot;:&quot;$45&quot;,&quot;EUR&quot;:&quot;€37.50&quot;,&quot;GBP&quot;:&quot;£33.75&quot;}">€37.50</td><td data-currency-values="{&quot;USD&quot;:&quot;$30&quot;,&quot;EUR&quot;:&quot;€25&quot;,&quot;GBP&quot;:&quot;£22.50&quot;}">€25</td><td data-currency-values="{&quot;USD&quot;:&quot;$21&quot;,&quot;EUR&quot;:&quot;€17.50&quot;,&quot;GBP&quot;:&quot;£15.75&quot;}">€17.50</td></tr>
</tbody>
</table>
<p>Ireland, the EU-15, the US, the UK, Australia and Canada are band A. Your billing country sets the band at checkout. Usage beyond the inclusions costs the same in every band.</p>
@@ -139,18 +160,18 @@
</section>
<section class="tint">
<div class="wrap narrow prose">
- <h2>Going past what's included</h2>
- <p>Usage beyond the pooled inclusions is billed monthly, from our own usage statements.</p>
+ <h2>Overages</h2>
+ <p>Extra usage is billed monthly, from our own usage statements.</p>
<table class="price-table rates">
<tbody>
- <tr><th scope="row">Awake time</th><td>€0.010 per hour</td></tr>
- <tr><th scope="row">Storage</th><td>€0.106 per GB per month</td></tr>
- <tr><th scope="row">Transfer</th><td>€0.414 per GB</td></tr>
- <tr><th scope="row">Replication writes</th><td>€0.023 per 1,000</td></tr>
+ <tr><th scope="row">Awake time</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.012 per hour&quot;,&quot;EUR&quot;:&quot;€0.010 per hour&quot;,&quot;GBP&quot;:&quot;£0.009 per hour&quot;}">€0.010 per hour</td></tr>
+ <tr><th scope="row">Storage</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.127 per GB per month&quot;,&quot;EUR&quot;:&quot;€0.106 per GB per month&quot;,&quot;GBP&quot;:&quot;£0.095 per GB per month&quot;}">€0.106 per GB per month</td></tr>
+ <tr><th scope="row">Transfer</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.497 per GB&quot;,&quot;EUR&quot;:&quot;€0.414 per GB&quot;,&quot;GBP&quot;:&quot;£0.373 per GB&quot;}">€0.414 per GB</td></tr>
+ <tr><th scope="row">Replication writes</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.028 per 1,000&quot;,&quot;EUR&quot;:&quot;€0.023 per 1,000&quot;,&quot;GBP&quot;:&quot;£0.021 per 1,000&quot;}">€0.023 per 1,000</td></tr>
</tbody>
</table>
- <p>A month under €1 is not billed. We bill only usage we measured completely. If our measurement of a meter is incomplete for a month, that meter is not billed for that month.</p>
- <p>Apps can serve files straight from their storage bucket. For that transfer we count only what Amazon Web Services (AWS) charges us for. If that count is wrong, it is wrong in your favour.</p>
+ <p data-currency-values="{&quot;USD&quot;:&quot;Minimum $1.20 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.&quot;,&quot;EUR&quot;:&quot;Minimum €1 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.&quot;,&quot;GBP&quot;:&quot;Minimum £0.90 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.&quot;}">Minimum €1 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.</p>
+ <p>Carloku is hosting (currently) on AWS. For files served directly from S3, we simply charge the AWS transfer fee.</p>
</div>
</section>
<section class="band">
@@ -175,7 +196,7 @@
<h2><span>Start</span><span class="dot">.</span></h2>
<p>Create an account at console.carloku.com. You sign in with a passkey through Keymail, so there is no password. Every new account starts on Free, and no card is needed. When you want more, open your account's Billing page, choose a plan and pay.</p>
<div class="cta">
- <a class="btn btn-primary" href="https://console.carloku.com">Create an account</a>
+ <a class="btn btn-primary" href="https://console.carloku.com" data-pricing-signup>Create an account</a>
<a class="btn btn-quiet" href="/get-started/">Read the setup guide</a>
</div>
</div>
diff --git a/src/site.css b/src/site.css
index a8c0933..33f1bc3 100644
--- a/src/site.css
+++ b/src/site.css
@@ -410,6 +410,16 @@ article.guide .note {
.plan li::before { content: ""; position: absolute; left: 0; top: 0.95rem; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--accent); opacity: 0.7; }
.plan .btn { justify-content: center; }
.plans-note { margin-top: 1.5rem; color: var(--faint); font-size: 0.95rem; }
+.plan .allowance-heading { margin: 1.1rem 0 0; font-size: 0.92rem; font-weight: 620; }
+.plan .allowances { margin-top: 0.45rem; flex: 0; }
+.plan .plan-features { margin-top: 0; }
+.pricing-controls { padding: 0 0 1.5rem; }
+.currency-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 1.25rem; }
+.currency-form .control label { display: block; font-weight: 560; margin-bottom: 0.4rem; }
+.currency-form select { font: inherit; width: 100%; padding: 0.65rem 0.75rem; border: 1px solid var(--line); border-radius: 0.6rem; background: var(--bg); color: var(--ink); }
+.control-note { margin-top: 0.5rem; color: var(--muted); font-size: 0.9rem; }
+.control-note.fixed { margin-top: 1rem; }
+@media (max-width: 640px) { .currency-form { grid-template-columns: 1fr; } }
.prose h2 { font-size: 1.45rem; margin-bottom: 1rem; }
.prose h2 + h2, .prose p + h2, .prose table + h2 { margin-top: 2.6rem; }
.prose p, .prose ul { color: var(--muted); max-width: 42rem; margin-bottom: 1rem; }
diff --git a/test/pricing-pricebook.test.mjs b/test/pricing-pricebook.test.mjs
new file mode 100644
index 0000000..a9db5ce
--- /dev/null
+++ b/test/pricing-pricebook.test.mjs
@@ -0,0 +1,92 @@
+// 🤖 The pricing page shows no price that is not in the platform's price
+// book. Every amount on the page is generated from copy-review/pricing/
+// pricebook.json; the approved EUR strings are the book's EUR; and the
+// other currencies' texts are the same words with the book's amounts.
+import { readFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+const root = new URL('../', import.meta.url);
+const book = JSON.parse(readFileSync(new URL('copy-review/pricing/pricebook.json', root), 'utf8'));
+const strings = JSON.parse(readFileSync(new URL('copy-review/pricing/strings.json', root), 'utf8'));
+const S = Object.fromEntries(strings.map((s) => [s.id, s.text]));
+execFileSync('node', [new URL('copy-review/pricing/build.mjs', root).pathname], { stdio: 'pipe' });
+const page = readFileSync(new URL('src/pricing/index.html', root), 'utf8');
+const preview = readFileSync(new URL('copy-review/pricing/preview/index.html', root), 'utf8');
+const unesc = (t) => t.replace(/&quot;/g, '"').replace(/&amp;/g, '&');
+const valuesOf = (html) => [...html.matchAll(/data-currency-values="([^"]*)"/g)].map((m) => JSON.parse(unesc(m[1])));
+const money = (sym, minor) => sym + Math.floor(minor / 100) + (minor % 100 ? '.' + String(minor % 100).padStart(2, '0') : '');
+const sym = Object.fromEntries(book.currencies.map((c) => [c.code.toUpperCase(), c.symbol]));
+
+test('the book is the platform export, at the version the console sells from', () => {
+ assert.equal(book.schema, 1);
+ assert.deepEqual(book.currencies.map((c) => c.code), ['usd', 'eur', 'gbp']);
+ assert.ok(/public-pricebook\.json at commit [0-9a-f]{8}/.test(readFileSync(new URL('copy-review/pricing/pricebook.source.txt', root), 'utf8')));
+});
+
+test('every priced string on the page carries all three currencies, and EUR is the approved text', () => {
+ const all = valuesOf(page);
+ assert.ok(all.length >= 3 + 12 + 4 + 1, `found ${all.length} priced strings`);
+ for (const v of all) assert.deepEqual(Object.keys(v).sort(), ['EUR', 'GBP', 'USD']);
+ for (const id of ['plan.personal.price', 'plan.community.price', 'plan.business.price', 'over.rate.awake', 'over.rate.storage', 'over.rate.transfer', 'over.rate.writes', 'over.p2']) {
+ const m = preview.match(new RegExp(`data-copy="${id.replace(/\./g, '\\.')}" data-currency-values="([^"]*)"`));
+ assert.ok(m, id + ' is priced from the book');
+ assert.equal(JSON.parse(unesc(m[1])).EUR, S[id], id + ' EUR is the approved string');
+ }
+});
+
+test('plan prices and the band table are the book, per currency', () => {
+ const planOf = (k) => book.plans.find((p) => p.key === k);
+ for (const [k, id] of [['personal', 'plan.personal.price'], ['community', 'plan.community.price'], ['business', 'plan.business.price']]) {
+ const m = preview.match(new RegExp(`data-copy="${id.replace(/\./g, '\\.')}" data-currency-values="([^"]*)"`));
+ const v = JSON.parse(unesc(m[1]));
+ for (const cur of ['USD', 'EUR', 'GBP']) {
+ assert.ok(v[cur].startsWith(money(sym[cur], planOf(k).annual_unit_minor[cur.toLowerCase()].A)), `${k} ${cur}: ${v[cur]}`);
+ }
+ }
+ const cells = valuesOf(page.slice(page.indexOf('class="price-table"'), page.indexOf('class="price-table rates"')));
+ assert.equal(cells.length, 12);
+ let i = 0;
+ for (const k of ['personal', 'community', 'business']) for (const band of ['A', 'B', 'C', 'D']) {
+ for (const cur of ['USD', 'EUR', 'GBP']) assert.equal(cells[i][cur], money(sym[cur], planOf(k).annual_unit_minor[cur.toLowerCase()][band]), `${k} ${band} ${cur}`);
+ i++;
+ }
+ // The two prices the rounding rule decides.
+ assert.equal(cells[5].GBP, '£3.38');
+ assert.equal(cells[7].GBP, '£1.58');
+});
+
+test('overage rates and the floor are the book, per currency', () => {
+ const rates = valuesOf(page.slice(page.indexOf('class="price-table rates"')));
+ const o = book.overage;
+ assert.equal(rates[0].USD, `$${(o.usd.awake_hour_micro / 1e6).toFixed(3)} per hour`);
+ assert.equal(rates[1].GBP, `£${(o.gbp.storage_gb_month_micro / 1e6).toFixed(3)} per GB per month`);
+ assert.equal(rates[2].EUR, S['over.rate.transfer']);
+ assert.ok(rates[4].USD.startsWith('Minimum ' + money('$', o.usd.invoice_floor_micro / 10000)), rates[4].USD);
+ assert.ok(rates[4].GBP.startsWith('Minimum ' + money('£', o.gbp.invoice_floor_micro / 10000)), rates[4].GBP);
+});
+
+test('the controls carry the five approved strings and the country defaults from the book', () => {
+ const currency = JSON.parse(readFileSync(new URL('copy-review/currency/result.json', root), 'utf8'));
+ assert.equal(currency.action, 'approve');
+ for (const s of currency.strings) assert.ok(page.includes('>' + s.text + '<'), s.id);
+ assert.ok(page.includes('data-pricing-currency'));
+ assert.ok(page.includes('<option value="IE" data-currency="EUR">'));
+ assert.ok(page.includes('<option value="GB" data-currency="GBP">'));
+ assert.ok(page.includes('<option value="US" data-currency="USD">'));
+ assert.ok(page.includes('<option value="CH" data-currency="USD">'));
+ assert.equal((page.match(/data-pricing-signup/g) || []).length, 5);
+});
+
+test('no euro amount on the page is typed in the script: each comes from a string or the book', () => {
+ // Every € on the page is inside an element the book priced, or in a
+ // string the review approved.
+ const stripped = page.replace(/data-currency-values="[^"]*"/g, '');
+ for (const m of stripped.matchAll(/€[0-9][0-9.,]*/g)) {
+ const amount = m[0];
+ const inBook = Object.values(book.plans).some((p) => Object.values(p.annual_unit_minor.eur).some((minor) => money('€', minor) === amount));
+ const inStrings = strings.some((s) => s.text.includes(amount));
+ assert.ok(inBook || inStrings, amount + ' is on the page and in neither the book nor an approved string');
+ }
+});