carlos / carloku Public

Make pricing location controls a compact disclosure and remember choices

Paul Campbell pushed by claude agent, operated by paul@keymail.dev f26a9ad6c1cf385cba112ff405784ffdff6d3cb2 parent 01b62f7
5 files changed, +84 −13
  • copy-review/pricing/build.mjs +3 −0
  • src/pricing/index.html +3 −0
  • src/site.css +8 −0
  • src/site.js +36 −7
  • test/pricing-currency.test.mjs +34 −6
diff --git a/copy-review/pricing/build.mjs b/copy-review/pricing/build.mjs
index 81d63ec..e9f0658 100644
--- a/copy-review/pricing/build.mjs
+++ b/copy-review/pricing/build.mjs
@@ -208,6 +208,8 @@ const bandOf = (iso2) => {
const countryOptions = recs.map((r) => `<option value="${r.iso2}" data-currency="${defaultCurrencyFor(r.iso2)}" data-band="${bandOf(r.iso2)}">${esc(r.name)}</option>`).join("");
const controls = ` <section class="pricing-controls">
<div class="wrap narrow">
+ <details class="pricing-location" open data-pricing-location>
+ <summary><span data-pricing-summary>${esc(S["country.label"])} · ${DEFAULT}</span><span class="location-change">change</span></summary>
<form class="currency-form" onsubmit="return false">
<div class="control">
<label for="pricing-currency" data-copy="currency.label">${esc(S["currency.label"])}</label>
@@ -221,6 +223,7 @@ const controls = ` <section class="pricing-controls">
</div>
</form>
<p class="control-note fixed" data-copy="currency.fixed">${esc(S["currency.fixed"])}</p>
+ </details>
</div>
</section>`;
diff --git a/src/pricing/index.html b/src/pricing/index.html
index 5d96648..832ba06 100644
--- a/src/pricing/index.html
+++ b/src/pricing/index.html
@@ -41,6 +41,8 @@
</div>
<section class="pricing-controls">
<div class="wrap narrow">
+ <details class="pricing-location" open data-pricing-location>
+ <summary><span data-pricing-summary>Billing country · USD</span><span class="location-change">change</span></summary>
<form class="currency-form" onsubmit="return false">
<div class="control">
<label for="pricing-currency">Currency</label>
@@ -54,6 +56,7 @@
</div>
</form>
<p class="control-note fixed">Your selected currency stays with your subscription and usage bills.</p>
+ </details>
</div>
</section>
<section class="plans-section">
diff --git a/src/site.css b/src/site.css
index 33f1bc3..986fd16 100644
--- a/src/site.css
+++ b/src/site.css
@@ -446,3 +446,11 @@ article.guide .note {
.plans { grid-template-columns: 1fr; }
.plan .for { min-height: 0; }
}
+
+.pricing-location { max-width: 54rem; margin-inline: auto; }
+.pricing-location > summary { display: flex; align-items: baseline; justify-content: center; gap: 0.8rem; cursor: pointer; list-style: none; font-size: 0.95rem; color: var(--muted); }
+.pricing-location > summary::-webkit-details-marker { display: none; }
+.pricing-location > summary:focus-visible { outline: 2px solid currentColor; outline-offset: 6px; border-radius: 3px; }
+.location-change { text-decoration: underline; text-underline-offset: 0.2em; }
+.pricing-location[open] > summary { margin-bottom: 1.5rem; }
+.pricing-location .currency-form { margin-top: 1rem; }
diff --git a/src/site.js b/src/site.js
index 84f0a9e..74d68a4 100644
--- a/src/site.js
+++ b/src/site.js
@@ -33,6 +33,22 @@
if (supported.indexOf(fallback) === -1) fallback = supported[0];
var countrySelect = document.querySelector("[data-pricing-country]");
var explicit = false;
+ var countryExplicit = !!(countrySelect && countrySelect.value);
+ var locationDetails = document.querySelector("[data-pricing-location]");
+ var locationSummary = document.querySelector("[data-pricing-summary]");
+ var summaryFallback = locationSummary && locationSummary.textContent.split(" · ")[0];
+ var saved = {};
+ try { saved = JSON.parse(window.localStorage.getItem("carloku-pricing-location") || "{}"); } catch (_) {}
+ if (!saved || typeof saved !== "object") saved = {};
+ function validCountry(value) {
+ return countrySelect && /^[A-Z]{2}$/.test(value || "") && countrySelect.querySelector('option[value="' + value + '"]');
+ }
+ function remember() {
+ try { window.localStorage.setItem("carloku-pricing-location", JSON.stringify({
+ country: countryExplicit ? countrySelect.value : "",
+ currency: explicit ? currencySelect.value : ""
+ })); } catch (_) {}
+ }
// countryCurrency is a country's default currency as the price book
// put it on the country's option (data-currency); a country that is
// not an option (a locale's region the book does not sell to, say)
@@ -71,7 +87,8 @@
if (typeof text === "string") el.textContent = text;
} catch (_) {}
});
- var country = countryOption() ? countrySelect.value : "";
+ var country = countryExplicit && countryOption() ? countrySelect.value : "";
+ if (locationSummary) locationSummary.textContent = (countryOption() ? countryOption().textContent : summaryFallback) + " · " + currency;
document.querySelectorAll("[data-pricing-signup]").forEach(function (el) {
var url = new URL(el.href, window.location.href);
url.searchParams.set("currency", explicit ? currency.toLowerCase() : "");
@@ -83,28 +100,40 @@
try {
var params = new URL(window.location.href).searchParams;
var wanted = params.get("country");
- if (countrySelect && wanted && /^[A-Za-z]{2}$/.test(wanted) && countrySelect.querySelector('option[value="' + wanted.toUpperCase() + '"]')) {
- countrySelect.value = wanted.toUpperCase();
+ wanted = wanted && wanted.toUpperCase();
+ if (validCountry(wanted)) {
+ countrySelect.value = wanted;
+ countryExplicit = true;
+ } else if (!params.has("country") && validCountry(saved.country)) {
+ countrySelect.value = saved.country;
+ countryExplicit = true;
+ }
+ if (!countryExplicit) {
+ var region;
+ try { region = new Intl.Locale(navigator.language).region; } catch (_) {}
+ if (validCountry(region)) countrySelect.value = region;
}
var requested = params.get("currency");
requested = requested && requested.toUpperCase();
+ if (!params.has("currency") && supported.indexOf(saved.currency) !== -1) requested = saved.currency;
if (supported.indexOf(requested) !== -1) {
initial = requested;
explicit = true;
- } else if (countrySelect && countrySelect.value) {
- initial = countryCurrency(countrySelect.value);
} else {
- var region = new Intl.Locale(navigator.language).region;
- initial = countryCurrency(region);
+ initial = countryCurrency(countrySelect && countrySelect.value);
}
} catch (_) {}
+ if (locationDetails) locationDetails.open = false;
render(initial);
currencySelect.addEventListener("change", function () {
explicit = true;
render(currencySelect.value);
+ remember();
});
if (countrySelect) countrySelect.addEventListener("change", function () {
+ countryExplicit = true;
render(explicit ? currencySelect.value : countryCurrency(countrySelect.value));
+ remember();
});
}
diff --git a/test/pricing-currency.test.mjs b/test/pricing-currency.test.mjs
index 531ea95..946969b 100644
--- a/test/pricing-currency.test.mjs
+++ b/test/pricing-currency.test.mjs
@@ -8,8 +8,10 @@ const source = readFileSync(new URL('../src/site.js', import.meta.url), 'utf8');
// band-scaled inclusion and one signup link. The country options are
// the four the cases need, each with its default currency and its band.
const options = { IE: ['EUR', 'A'], GB: ['GBP', 'A'], US: ['USD', 'A'], IN: ['USD', 'D'], PL: ['EUR', 'C'] };
-function page(language, query = '', country = '') {
+function page(language, query = '', country = '', storage = {}) {
const listeners = {};
+ const details = { open: true };
+ const summary = { textContent: 'Billing country · USD' };
const selector = {
value: 'USD', addEventListener: (name, fn) => listeners.currency = fn,
getAttribute: (a) => a === 'data-default-currency' ? 'USD' : null,
@@ -20,7 +22,7 @@ function page(language, query = '', country = '') {
addEventListener: (name, fn) => listeners.country = fn,
querySelector: (sel) => {
const iso = sel.match(/^option\[value="([A-Z]{2})"\]$/)[1];
- return options[iso] ? { getAttribute: (a) => a === 'data-band' ? options[iso][1] : a === 'data-currency' ? options[iso][0] : null } : null;
+ return options[iso] ? { textContent: iso, getAttribute: (a) => a === 'data-band' ? options[iso][1] : a === 'data-currency' ? options[iso][0] : null } : null;
},
};
const rate = { textContent: '$0.012 per hour', getAttribute: () => JSON.stringify({ USD: '$0.012 per hour', EUR: '€0.010 per hour', GBP: '£0.009 per hour' }) };
@@ -31,14 +33,14 @@ function page(language, query = '', country = '') {
const signup = { href: 'https://console.carloku.com/?plan=personal' };
runInNewContext(source, {
document: {
- querySelector: key => key === '[data-pricing-currency]' ? selector : key === '[data-pricing-country]' ? countrySelector : null,
+ querySelector: key => key === '[data-pricing-currency]' ? selector : key === '[data-pricing-country]' ? countrySelector : key === '[data-pricing-location]' ? details : key === '[data-pricing-summary]' ? summary : null,
querySelectorAll: key => key === '[data-currency-values]' ? [rate] : key === '[data-band-values]' ? [price, hours] : key === '[data-pricing-signup]' ? [signup] : []
},
- window: { location: { href: 'https://carloku.com/pricing/' + query } },
+ window: { location: { href: 'https://carloku.com/pricing/' + query }, localStorage: { getItem: (key) => storage[key], setItem: (key, value) => { storage[key] = value; } } },
navigator: { language }, Intl, URL
});
const link = () => new URL(signup.href).searchParams;
- return { selector, countrySelector, rate, price, hours, signup, link, listeners };
+ return { selector, countrySelector, rate, price, hours, signup, link, listeners, details, summary };
}
test('currency defaults from explicit locale region; language alone is not a country', () => {
for (const [locale, expected] of [['en-US','USD'],['en-GB','GBP'],['en-IE','EUR'],['pl-PL','EUR'],['fr-CA','USD'],['en','USD'],['bad_locale','USD']]) {
@@ -113,7 +115,7 @@ test('the link back from the console preselects the country and currency it carr
assert.equal(p.selector.value, 'GBP');
assert.equal(p.price.textContent, '£7.70/y');
const q = page('en-US', '?country=XX', '');
- assert.equal(q.countrySelector.value, '', 'an unknown country is ignored');
+ assert.equal(q.countrySelector.value, 'US', 'an unknown URL country falls back to the browser suggestion');
assert.equal(q.link().get('country'), '');
});
test('unsupported URL currency is ignored and never forwarded', () => {
@@ -130,3 +132,29 @@ test('a country\'s default currency comes from its option, not from a list in th
p.countrySelector.value = 'PL'; p.listeners.country();
assert.equal(p.selector.value, 'EUR');
});
+
+test('detected country and currency appear in collapsed summary but are not billing choices', () => {
+ const p = page('pl-PL');
+ assert.equal(p.summary.textContent, 'PL · EUR');
+ assert.equal(p.details.open, false);
+ assert.equal(p.price.textContent, '€12/y');
+ assert.equal(p.link().get('country'), '');
+ assert.equal(p.link().get('currency'), '');
+ assert.equal(page('en').summary.textContent, 'Billing country · USD');
+});
+test('explicit choices survive reload and URL overrides saved choices', () => {
+ const storage = {};
+ const p = page('en-GB', '', '', storage);
+ p.countrySelector.value = 'IE'; p.listeners.country();
+ p.selector.value = 'USD'; p.listeners.currency();
+ const q = page('en-GB', '', '', storage);
+ assert.equal(q.summary.textContent, 'IE · USD');
+ assert.deepEqual([q.link().get('country'), q.link().get('currency')], ['IE', 'usd']);
+ const r = page('en-GB', '?country=PL&currency=GBP', '', storage);
+ assert.equal(r.summary.textContent, 'PL · GBP');
+});
+test('corrupt saved data does not prevent browser detection', () => {
+ for (const saved of ['broken', 'null', '{"country":"XX","currency":"XYZ"}']) {
+ assert.equal(page('en-IE', '', '', { 'carloku-pricing-location': saved }).summary.textContent, 'IE · EUR');
+ }
+});