Make pricing location controls a compact disclosure and remember choices
5 files changed,
+84
−13
copy-review/pricing/build.mjs+3 −0src/pricing/index.html+3 −0src/site.css+8 −0src/site.js+36 −7test/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¤cy=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'); |
| + } |
| +}); |