carlos / carloku Public

Pricing page: the static links forget earlier choices, the draft gate takes exactly "1", the typed figures are matched as tokens

Without the script the signup links now carry both parameters empty, so a
console that remembered an earlier visit's country or currency forgets
them instead of reusing them. PRICEBOOK_ALLOW_DRAFT must be exactly "1"
in the build and in the check; "0", "false", empty and unset all refuse.
The build writes the review preview before it refuses the deployable
page, as its comment said. The figures the approved strings carry are
matched as whole tokens, so a minimum of 1 would not pass inside "10".
The script reads what is sold and the book's default from the currency
control itself rather than from a list of its own.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 4feb1cd7247ab92f17b8fd62ee7226f44f12e9be parent 2fc6388
6 files changed, +52 −28
  • copy-review/pricing/build.mjs +18 −12
  • hack/check-pricebook.mjs +1 −1
  • src/pricing/index.html +6 −6
  • src/site.js +7 −3
  • test/pricing-currency.test.mjs +5 −1
  • test/pricing-pricebook.test.mjs +15 −5
diff --git a/copy-review/pricing/build.mjs b/copy-review/pricing/build.mjs
index 400d095..6e08bca 100644
--- a/copy-review/pricing/build.mjs
+++ b/copy-review/pricing/build.mjs
@@ -23,14 +23,14 @@ for (const c of currencyStrings) S[c.id] = c.text;
// book below and the build stops on a difference.
const book = JSON.parse(fs.readFileSync(path.join(here, "pricebook.json"), "utf8"));
// A draft book is a draft: the console refuses to sell from it in live
-// mode, and this page must not be published from it. The preview is always
-// written for review; the deployable page is written only for an approved
-// book, or when PRICEBOOK_ALLOW_DRAFT=1 says a draft is wanted (the review
-// preview and the tests), and then it carries a marker that
+// mode, and this page must not be published from it. The preview is
+// written for review whatever the status; the deployable page is written
+// only for an approved book, or when PRICEBOOK_ALLOW_DRAFT is exactly "1"
+// (the review preview and the tests), and then it carries a marker that
// hack/check-pricebook.mjs refuses at `npm run check` unless the same
-// variable is set.
+// variable is set the same way.
const DRAFT = book.status !== "approved";
-if (DRAFT && !process.env.PRICEBOOK_ALLOW_DRAFT) throw new Error(`the price book ${book.price_book} is ${book.status}, not approved: src/pricing/index.html is not written from it (set PRICEBOOK_ALLOW_DRAFT=1 for a review build)`);
+const ALLOW_DRAFT = process.env.PRICEBOOK_ALLOW_DRAFT === "1";
const draftMarker = DRAFT ? `<!-- PRICE BOOK ${book.price_book} IS ${book.status.toUpperCase()}: not approved for sale, not for publication -->\n` : "";
const CURRENCIES = book.currencies.map((c) => c.code.toUpperCase());
const symbol = Object.fromEntries(book.currencies.map((c) => [c.code.toUpperCase(), c.symbol]));
@@ -139,7 +139,12 @@ const byBand = { A: [], B: [], C: [], D: [] };
for (const r of recs) byBand[r.band].push(r.name);
// The figures the approved strings carry that are the book's to decide.
-const must = (id, text, what) => { if (!S[id].includes(text)) throw new Error(`${id} (${JSON.stringify(S[id])}) does not carry ${what} ${JSON.stringify(text)} from the book`); };
+// must checks that an approved string carries a figure as a whole token
+// (so "10" is not found inside "100"), not merely as a substring.
+const must = (id, text, what) => {
+ const re = new RegExp("(^|[^0-9.])" + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![0-9.])");
+ if (!re.test(S[id])) throw new Error(`${id} (${JSON.stringify(S[id])}) does not carry ${what} ${JSON.stringify(text)} from the book`);
+};
for (const p of ["community", "business"]) must(`plan.${p}.min`, String(plansByKey[p].min_quantity), "the minimum");
for (const [b, id] of [["B", "bands.b"], ["C", "bands.c"], ["D", "bands.d"]]) must(id, (book.band_factors_bps[b] / 100) + "%", "the band percentage");
if (book.band_factors_bps.A !== 10000) throw new Error("band A is not the full price");
@@ -185,7 +190,7 @@ const card = (p, featured) => {
${t(`plan.${p}.name`, "h3")}
<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>
-${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>
+${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}&amp;currency=&amp;country=" data-pricing-signup data-copy="plan.${p}.cta">${esc(S[`plan.${p}.cta`])}</a>
</article>`;
};
@@ -206,7 +211,7 @@ const controls = ` <section class="pricing-controls">
<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>
+ <select id="pricing-currency" data-pricing-currency data-default-currency="${DEFAULT}">${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">
@@ -338,7 +343,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-pricing-signup data-copy="start.cta.console">${esc(S["start.cta.console"])}</a>
+ <a class="btn btn-primary" href="https://console.carloku.com/?currency=&amp;country=" 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>
@@ -356,8 +361,6 @@ ${bandList}
</body>
</html>
`;
-fs.mkdirSync(path.join(root, "src", "pricing"), { recursive: true });
-fs.writeFileSync(path.join(root, "src", "pricing", "index.html"), page.replace(/ data-copy(?:-title)?="[^"]*"/g, ""));
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));
@@ -370,4 +373,7 @@ let previewHtml = page
.replace(/href="\/favicon\.svg"/, 'href="favicon.svg"')
;
fs.writeFileSync(path.join(prev, "index.html"), previewHtml);
+if (DRAFT && !ALLOW_DRAFT) throw new Error(`the price book ${book.price_book} is ${book.status}, not approved: copy-review/pricing/preview/index.html was written for review, src/pricing/index.html was not (set PRICEBOOK_ALLOW_DRAFT=1 for a review build)`);
+fs.mkdirSync(path.join(root, "src", "pricing"), { recursive: true });
+fs.writeFileSync(path.join(root, "src", "pricing", "index.html"), page.replace(/ data-copy(?:-title)?="[^"]*"/g, ""));
console.log("wrote src/pricing/index.html and copy-review/pricing/preview/index.html", strings.length, "strings");
diff --git a/hack/check-pricebook.mjs b/hack/check-pricebook.mjs
index 676e645..f8ba796 100644
--- a/hack/check-pricebook.mjs
+++ b/hack/check-pricebook.mjs
@@ -5,7 +5,7 @@
import { readFileSync } from "node:fs";
const page = readFileSync(new URL("../src/pricing/index.html", import.meta.url), "utf8");
const m = page.match(/^<!-- PRICE BOOK (\S+) IS (\S+): not approved for sale, not for publication -->/);
-if (m && !process.env.PRICEBOOK_ALLOW_DRAFT) {
+if (m && process.env.PRICEBOOK_ALLOW_DRAFT !== "1") {
console.error(`src/pricing/index.html is built from the ${m[2].toLowerCase()} price book ${m[1]}; it must not be published. Rebuild from an approved export, or set PRICEBOOK_ALLOW_DRAFT=1 for a review build.`);
process.exit(1);
}
diff --git a/src/pricing/index.html b/src/pricing/index.html
index 625f67c..5b8d1de 100644
--- a/src/pricing/index.html
+++ b/src/pricing/index.html
@@ -44,7 +44,7 @@
<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>
+ <select id="pricing-currency" data-pricing-currency data-default-currency="USD"><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">
@@ -74,7 +74,7 @@
<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/?plan=free" data-pricing-signup>Create an account</a>
+ <a class="btn btn-quiet" href="https://console.carloku.com/?plan=free&amp;currency=&amp;country=" data-pricing-signup>Create an account</a>
</article>
<article class="plan featured">
<h3>Personal</h3>
@@ -86,7 +86,7 @@
<li data-band-values="{&quot;A&quot;:&quot;2 GB of storage&quot;,&quot;B&quot;:&quot;1.5 GB of storage&quot;,&quot;C&quot;:&quot;1 GB of storage&quot;,&quot;D&quot;:&quot;0.7 GB of storage&quot;}">2 GB of storage</li>
<li data-band-values="{&quot;A&quot;:&quot;2 GB of transfer a month&quot;,&quot;B&quot;:&quot;1.5 GB of transfer a month&quot;,&quot;C&quot;:&quot;1 GB of transfer a month&quot;,&quot;D&quot;:&quot;0.7 GB of transfer a month&quot;}">2 GB of transfer a month</li>
</ul>
- <a class="btn btn-primary" href="https://console.carloku.com/?plan=personal" data-pricing-signup>Choose Personal</a>
+ <a class="btn btn-primary" href="https://console.carloku.com/?plan=personal&amp;currency=&amp;country=" data-pricing-signup>Choose Personal</a>
</article>
<article class="plan">
<h3>Community</h3>
@@ -99,7 +99,7 @@
<li data-band-values="{&quot;A&quot;:&quot;0.5 GB of storage&quot;,&quot;B&quot;:&quot;0.375 GB of storage&quot;,&quot;C&quot;:&quot;0.25 GB of storage&quot;,&quot;D&quot;:&quot;0.175 GB of storage&quot;}">0.5 GB of storage</li>
<li data-band-values="{&quot;A&quot;:&quot;0.4 GB of transfer a month&quot;,&quot;B&quot;:&quot;0.3 GB of transfer a month&quot;,&quot;C&quot;:&quot;0.2 GB of transfer a month&quot;,&quot;D&quot;:&quot;0.14 GB of transfer a month&quot;}">0.4 GB of transfer a month</li>
</ul>
- <a class="btn btn-quiet" href="https://console.carloku.com/?plan=community" data-pricing-signup>Choose Community</a>
+ <a class="btn btn-quiet" href="https://console.carloku.com/?plan=community&amp;currency=&amp;country=" data-pricing-signup>Choose Community</a>
</article>
<article class="plan">
<h3>Business</h3>
@@ -117,7 +117,7 @@
<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/?plan=business" data-pricing-signup>Choose Business</a>
+ <a class="btn btn-quiet" href="https://console.carloku.com/?plan=business&amp;currency=&amp;country=" data-pricing-signup>Choose Business</a>
</article>
</div>
<p class="plans-note">Paid plans are never archived. CARLOS apps typically wake in under 500ms.</p>
@@ -197,7 +197,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" data-pricing-signup>Create an account</a>
+ <a class="btn btn-primary" href="https://console.carloku.com/?currency=&amp;country=" 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.js b/src/site.js
index 6203bdf..84f0a9e 100644
--- a/src/site.js
+++ b/src/site.js
@@ -26,7 +26,11 @@
// price.
var currencySelect = document.querySelector("[data-pricing-currency]");
if (currencySelect) {
- var supported = ["USD", "EUR", "GBP"];
+ // What is sold, and the book's default, are the control's own
+ // options and its data-default-currency, both from the price book.
+ var supported = Array.prototype.map.call(currencySelect.querySelectorAll("option"), function (o) { return o.value; });
+ var fallback = currencySelect.getAttribute("data-default-currency");
+ if (supported.indexOf(fallback) === -1) fallback = supported[0];
var countrySelect = document.querySelector("[data-pricing-country]");
var explicit = false;
// countryCurrency is a country's default currency as the price book
@@ -36,7 +40,7 @@
function countryCurrency(country) {
var opt = countrySelect && /^[A-Z]{2}$/.test(country || "") && countrySelect.querySelector('option[value="' + country + '"]');
var c = opt && opt.getAttribute("data-currency");
- return supported.indexOf(c) !== -1 ? c : supported[0];
+ return supported.indexOf(c) !== -1 ? c : fallback;
}
// countryOption is the selected country's option, or null when none
// is selected or the value matches no option.
@@ -75,7 +79,7 @@
el.href = url.href;
});
}
- var initial = "USD";
+ var initial = fallback;
try {
var params = new URL(window.location.href).searchParams;
var wanted = params.get("country");
diff --git a/test/pricing-currency.test.mjs b/test/pricing-currency.test.mjs
index e8e404f..531ea95 100644
--- a/test/pricing-currency.test.mjs
+++ b/test/pricing-currency.test.mjs
@@ -10,7 +10,11 @@ const source = readFileSync(new URL('../src/site.js', import.meta.url), 'utf8');
const options = { IE: ['EUR', 'A'], GB: ['GBP', 'A'], US: ['USD', 'A'], IN: ['USD', 'D'], PL: ['EUR', 'C'] };
function page(language, query = '', country = '') {
const listeners = {};
- const selector = { value: 'USD', addEventListener: (name, fn) => listeners.currency = fn };
+ const selector = {
+ value: 'USD', addEventListener: (name, fn) => listeners.currency = fn,
+ getAttribute: (a) => a === 'data-default-currency' ? 'USD' : null,
+ querySelectorAll: () => ['USD', 'EUR', 'GBP'].map((v) => ({ value: v })),
+ };
const countrySelector = {
value: country,
addEventListener: (name, fn) => listeners.country = fn,
diff --git a/test/pricing-pricebook.test.mjs b/test/pricing-pricebook.test.mjs
index 5a0b6c1..6e1ad2f 100644
--- a/test/pricing-pricebook.test.mjs
+++ b/test/pricing-pricebook.test.mjs
@@ -131,17 +131,27 @@ test('the controls carry the five approved strings, and every country option its
assert.ok(page.includes('<option value="IN" data-currency="USD" data-band="D">'));
assert.ok(page.includes('<option value="CH" data-currency="USD" data-band="A">'));
assert.equal((page.match(/data-pricing-signup/g) || []).length, 5);
+ // Without the script the links still carry both parameters, empty, so
+ // the console forgets an earlier visit's choices rather than reusing them.
+ assert.equal((page.match(/href="https:\/\/console\.carloku\.com\/\?(?:plan=[a-z]+&amp;)?currency=&amp;country=" data-pricing-signup/g) || []).length, 5, 'every signup link carries empty currency and country');
+ assert.ok(page.includes('data-pricing-currency data-default-currency="USD"'));
});
test('a page built from a draft book says so on its first line, and the check refuses it unless a review build is asked for', () => {
const marked = page.startsWith(`<!-- PRICE BOOK ${book.price_book} IS ${book.status.toUpperCase()}: not approved for sale, not for publication -->`);
assert.equal(marked, book.status !== 'approved');
const check = new URL('hack/check-pricebook.mjs', root).pathname;
- assert.throws(() => execFileSync('node', [check], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '' } }), /must not be published/);
- execFileSync('node', [check], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '1' } });
- // And a build without the variable refuses outright.
- assert.throws(() => execFileSync('node', [new URL('copy-review/pricing/build.mjs', root).pathname], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '' } }), /not approved/);
- execFileSync('node', [new URL('copy-review/pricing/build.mjs', root).pathname], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '1' } });
+ const build = new URL('copy-review/pricing/build.mjs', root).pathname;
+ const envWith = (v) => { const e = { ...process.env }; if (v === undefined) delete e.PRICEBOOK_ALLOW_DRAFT; else e.PRICEBOOK_ALLOW_DRAFT = v; return e; };
+ // Only exactly "1" allows a draft through: unset, empty, "0" and "false" do not.
+ for (const v of [undefined, '', '0', 'false', 'yes']) {
+ assert.throws(() => execFileSync('node', [check], { stdio: 'pipe', env: envWith(v) }), /must not be published/, `check with ${JSON.stringify(v)}`);
+ assert.throws(() => execFileSync('node', [build], { stdio: 'pipe', env: envWith(v) }), /not approved/, `build with ${JSON.stringify(v)}`);
+ }
+ // A refused build still wrote the preview, for review.
+ assert.ok(readFileSync(new URL('copy-review/pricing/preview/index.html', root), 'utf8').includes('data-pricing-currency'));
+ execFileSync('node', [check], { stdio: 'pipe', env: envWith('1') });
+ execFileSync('node', [build], { stdio: 'pipe', env: envWith('1') });
});
test('no euro amount on the page is typed in the script: each comes from a string or the book', () => {