Pricing page: the billing country sets the band shown, and the signup links carry it
Each country option carries its band from the price book. Choosing a country prices the plan cards at that band in the chosen currency (a band D country with GBP chosen stays in pounds at band D) and scales the per-member inclusions by the platform's own rule, exact figures only; the overage rates and the band table do not change. The signup links carry ?country= beside ?currency=, and a link back preselects both. Without the script the page is the default currency, USD, at band A, which is what the controls show selected. The approved strings are the band A EUR texts; every other text is the same words with the book's figure, and the build stops if band A EUR differs from the approval.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+256
−72
copy-review/pricing/build.mjs+64 −13src/pricing/index.html+23 −23src/site.js+41 −5test/pricing-currency.test.mjs+66 −9test/pricing-pricebook.test.mjs+62 −22
diff --git a/copy-review/pricing/build.mjs b/copy-review/pricing/build.mjs| index b016868..310a37e 100644 |
| --- a/copy-review/pricing/build.mjs |
| +++ b/copy-review/pricing/build.mjs |
| @@ -42,16 +42,61 @@ 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. |
| +// The static text of every priced element is the book's default currency |
| +// (USD) at band A: what a visitor with no script sees, and what the |
| +// currency control shows selected before the script runs. The approved |
| +// strings are the EUR texts; every other currency's text is the same |
| +// words with the book's amount. |
| +const BANDS = ["A", "B", "C", "D"]; |
| +const DEFAULT = book.default_currency.toUpperCase(); |
| +// priced renders an approved string whose euro amount varies by currency |
| +// alone (an overage rate, the floor, the free plan's price): every |
| +// currency's text 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}>`; |
| + return `<${tag}${cls ? ` class="${cls}"` : ""} data-copy="${id}" data-currency-values="${values(by)}">${esc(by[DEFAULT])}</${tag}>`; |
| +}; |
| +// banded renders a string whose amount varies by band and currency (a |
| +// paid plan's price line): every band's texts, each by currency, in |
| +// data-band-values. The selector picks the band from the billing country. |
| +const banded = (id, textFor, tag = "span", cls = "") => { |
| + if (!(id in S)) throw new Error("missing string " + id); |
| + const by = Object.fromEntries(BANDS.map((band) => [band, Object.fromEntries(CURRENCIES.map((cur) => [cur, textFor(band, cur)]))])); |
| + if (by.A.EUR !== S[id]) throw new Error("the book's band A EUR amount differs from the approved string " + id + ": " + by.A.EUR + " vs " + S[id]); |
| + return `<${tag}${cls ? ` class="${cls}"` : ""} data-copy="${id}" data-band-values="${values(by)}">${esc(by.A[DEFAULT])}</${tag}>`; |
| +}; |
| +const bandCell = (plan, band) => `<td data-currency-values="${values(Object.fromEntries(CURRENCIES.map((cur) => [cur, money(cur, unitMinor(plan, cur, band))])))}">${esc(money(DEFAULT, unitMinor(plan, DEFAULT, band)))}</td>`; |
| +// A band-scaled plan's per-member inclusions at a band are the plan's |
| +// figure times the band factor, divided by 10000 and rounded down: the |
| +// platform's own pooling rule (public-pricebook.json), at one member. The |
| +// approved string is band A's; the other bands' texts are the same words |
| +// with that band's figure, in data-band-values. A figure that is not exact |
| +// to three decimals stops the build rather than rounding on the page. |
| +const allowanceOf = { |
| + personal: { i2: "awake_seconds", i3: "storage_byte_months", i4: "transfer_bytes" }, |
| + community: { i1: "awake_seconds", i2: "storage_byte_months", i3: "transfer_bytes" }, |
| + business: { i1: "awake_seconds", i2: "storage_byte_months", i3: "transfer_bytes", i4: "writes" }, |
| +}; |
| +const figure = (resource, amount) => { |
| + const n = resource === "awake_seconds" ? amount / 3600 : resource === "writes" ? amount : amount / 1e9; |
| + const text = n.toFixed(3).replace(/\.?0+$/, ""); |
| + if (Number(text) !== n) throw new Error("not an exact figure: " + resource + " " + amount); |
| + const [int, frac] = text.split("."); |
| + return int.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + (frac ? "." + frac : ""); |
| +}; |
| +const swapFigure = (text, fig) => { |
| + if (!/^[0-9][0-9,]*(\.[0-9]+)?/.test(text)) throw new Error("no leading figure in " + JSON.stringify(text)); |
| + return text.replace(/^[0-9][0-9,]*(\.[0-9]+)?/, fig); |
| +}; |
| +const allowanceLi = (p, i) => { |
| + const id = `plan.${p}.${i}`, plan = plansByKey[p], resource = (allowanceOf[p] || {})[i]; |
| + if (!resource || !plan.band_scaled) return li(id); |
| + const by = Object.fromEntries(BANDS.map((band) => [band, swapFigure(S[id], figure(resource, Math.floor(plan.resources_per_member[resource] * book.band_factors_bps[band] / 10000)))])); |
| + if (by.A !== S[id]) throw new Error("band A's figure differs from the approved string " + id + ": " + by.A + " vs " + S[id]); |
| + return ` <li data-copy="${id}" data-band-values="${values(by)}">${esc(S[id])}</li>`; |
| }; |
| -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
| @@ -78,13 +123,13 @@ const planCardLists = { |
| 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 id = `plan.${p}.price`; |
| + if (p === "free") return priced(id, (cur) => money(cur, unitMinor(p, cur, "A"))); |
| + return banded(id, (band, cur) => swapAmount(S[id], cur, money(cur, unitMinor(p, cur, band)))); |
| }; |
| const card = (p, featured) => { |
| const lists = planCardLists[p]; |
| - const items = (ids) => ids.map((i) => li(`plan.${p}.${i}`)).join("\n"); |
| + const items = (ids) => ids.map((i) => allowanceLi(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" : ""}"> |
| @@ -97,10 +142,16 @@ ${lists.plain ? ` <ul>\n${items(lists.plain)}\n </ul>\n` : ""}${li |
| // 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(""); |
| +// currency and its band; the selector script (site.js) applies the |
| +// currency to every priced string on the page, the country's band to every |
| +// banded one, and both to the signup links. No price is charged from this |
| +// page: checkout prices from the same book, for the country the buyer |
| +// gives there. |
| +const bandOf = (iso2) => { |
| + if (!BANDS.includes(book.country_band[iso2])) throw new Error("the book has no band for " + iso2); |
| + return book.country_band[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"> |
| <form class="currency-form" onsubmit="return false"> |
diff --git a/src/pricing/index.html b/src/pricing/index.html| index 84cc146..dd34b68 100644 |
| --- a/src/pricing/index.html |
| +++ b/src/pricing/index.html |
| @@ -48,7 +48,7 @@ |
| </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> |
| + <select id="pricing-country" data-pricing-country><option value=""></option><option value="AF" data-currency="USD" data-band="D">Afghanistan</option><option value="AL" data-currency="USD" data-band="C">Albania</option><option value="DZ" data-currency="USD" data-band="D">Algeria</option><option value="AD" data-currency="USD" data-band="B">Andorra</option><option value="AO" data-currency="USD" data-band="D">Angola</option><option value="AG" data-currency="USD" data-band="B">Antigua and Barbuda</option><option value="AR" data-currency="USD" data-band="C">Argentina</option><option value="AM" data-currency="USD" data-band="D">Armenia</option><option value="AW" data-currency="USD" data-band="B">Aruba</option><option value="AU" data-currency="USD" data-band="A">Australia</option><option value="AT" data-currency="EUR" data-band="A">Austria</option><option value="AZ" data-currency="USD" data-band="D">Azerbaijan</option><option value="BS" data-currency="USD" data-band="A">Bahamas, The</option><option value="BH" data-currency="USD" data-band="C">Bahrain</option><option value="BD" data-currency="USD" data-band="D">Bangladesh</option><option value="BB" data-currency="USD" data-band="A">Barbados</option><option value="BY" data-currency="USD" data-band="D">Belarus</option><option value="BE" data-currency="EUR" data-band="A">Belgium</option><option value="BZ" data-currency="USD" data-band="C">Belize</option><option value="BJ" data-currency="USD" data-band="D">Benin</option><option value="BM" data-currency="USD" data-band="A">Bermuda</option><option value="BT" data-currency="USD" data-band="D">Bhutan</option><option value="BO" data-currency="USD" data-band="D">Bolivia</option><option value="BA" data-currency="USD" data-band="D">Bosnia and Herzegovina</option><option value="BW" data-currency="USD" data-band="D">Botswana</option><option value="BR" data-currency="USD" data-band="C">Brazil</option><option value="BN" data-currency="USD" data-band="D">Brunei Darussalam</option><option value="BG" data-currency="EUR" data-band="D">Bulgaria</option><option value="BF" data-currency="USD" data-band="D">Burkina Faso</option><option value="BI" data-currency="USD" data-band="D">Burundi</option><option value="CV" data-currency="USD" data-band="C">Cabo Verde</option><option value="KH" data-currency="USD" data-band="D">Cambodia</option><option value="CM" data-currency="USD" data-band="D">Cameroon</option><option value="CA" data-currency="USD" data-band="A">Canada</option><option value="KY" data-currency="USD" data-band="A">Cayman Islands</option><option value="CF" data-currency="USD" data-band="D">Central African Republic</option><option value="TD" data-currency="USD" data-band="D">Chad</option><option value="CL" data-currency="USD" data-band="C">Chile</option><option value="CN" data-currency="USD" data-band="C">China</option><option value="CO" data-currency="USD" data-band="D">Colombia</option><option value="KM" data-currency="USD" data-band="C">Comoros</option><option value="CD" data-currency="USD" data-band="D">Congo, Dem. Rep.</option><option value="CG" data-currency="USD" data-band="D">Congo, Rep.</option><option value="CR" data-currency="USD" data-band="C">Costa Rica</option><option value="CI" data-currency="USD" data-band="D">Cote d'Ivoire</option><option value="HR" data-currency="EUR" data-band="C">Croatia</option><option value="CW" data-currency="USD" data-band="B">Curacao</option><option value="CY" data-currency="EUR" data-band="C">Cyprus</option><option value="CZ" data-currency="EUR" data-band="C">Czechia</option><option value="DK" data-currency="EUR" data-band="A">Denmark</option><option value="DJ" data-currency="USD" data-band="C">Djibouti</option><option value="DM" data-currency="USD" data-band="C">Dominica</option><option value="DO" data-currency="USD" data-band="D">Dominican Republic</option><option value="EC" data-currency="USD" data-band="C">Ecuador</option><option value="EG" data-currency="USD" data-band="D">Egypt, Arab Rep.</option><option value="SV" data-currency="USD" data-band="D">El Salvador</option><option value="GQ" data-currency="USD" data-band="D">Equatorial Guinea</option><option value="EE" data-currency="EUR" data-band="C">Estonia</option><option value="SZ" data-currency="USD" data-band="D">Eswatini</option><option value="ET" data-currency="USD" data-band="D">Ethiopia</option><option value="FO" data-currency="USD" data-band="A">Faroe Islands</option><option value="FJ" data-currency="USD" data-band="D">Fiji</option><option value="FI" data-currency="EUR" data-band="A">Finland</option><option value="FR" data-currency="EUR" data-band="A">France</option><option value="GA" data-currency="USD" data-band="D">Gabon</option><option value="GM" data-currency="USD" data-band="D">Gambia, The</option><option value="GE" data-currency="USD" data-band="D">Georgia</option><option value="DE" data-currency="EUR" data-band="A">Germany</option><option value="GH" data-currency="USD" data-band="D">Ghana</option><option value="GR" data-currency="EUR" data-band="A">Greece</option><option value="GL" data-currency="USD" data-band="B">Greenland</option><option value="GD" data-currency="USD" data-band="C">Grenada</option><option value="GT" data-currency="USD" data-band="C">Guatemala</option><option value="GN" data-currency="USD" data-band="D">Guinea</option><option value="GW" data-currency="USD" data-band="D">Guinea-Bissau</option><option value="GY" data-currency="USD" data-band="D">Guyana</option><option value="HT" data-currency="USD" data-band="B">Haiti</option><option value="HN" data-currency="USD" data-band="C">Honduras</option><option value="HK" data-currency="USD" data-band="B">Hong Kong SAR, China</option><option value="HU" data-currency="EUR" data-band="C">Hungary</option><option value="IS" data-currency="USD" data-band="A">Iceland</option><option value="IN" data-currency="USD" data-band="D">India</option><option value="ID" data-currency="USD" data-band="D">Indonesia</option><option value="IR" data-currency="USD" data-band="D">Iran, Islamic Rep.</option><option value="IQ" data-currency="USD" data-band="D">Iraq</option><option value="IE" data-currency="EUR" data-band="A">Ireland</option><option value="IL" data-currency="USD" data-band="A">Israel</option><option value="IT" data-currency="EUR" data-band="A">Italy</option><option value="JM" data-currency="USD" data-band="C">Jamaica</option><option value="JP" data-currency="USD" data-band="C">Japan</option><option value="JO" data-currency="USD" data-band="C">Jordan</option><option value="KZ" data-currency="USD" data-band="D">Kazakhstan</option><option value="KE" data-currency="USD" data-band="D">Kenya</option><option value="KI" data-currency="USD" data-band="B">Kiribati</option><option value="KR" data-currency="USD" data-band="C">Korea, Rep.</option><option value="XK" data-currency="USD" data-band="D">Kosovo</option><option value="KW" data-currency="USD" data-band="C">Kuwait</option><option value="KG" data-currency="USD" data-band="D">Kyrgyz Republic</option><option value="LA" data-currency="USD" data-band="D">Lao PDR</option><option value="LV" data-currency="EUR" data-band="C">Latvia</option><option value="LB" data-currency="USD" data-band="D">Lebanon</option><option value="LS" data-currency="USD" data-band="D">Lesotho</option><option value="LR" data-currency="USD" data-band="C">Liberia</option><option value="LY" data-currency="USD" data-band="C">Libya</option><option value="LT" data-currency="EUR" data-band="C">Lithuania</option><option value="LU" data-currency="EUR" data-band="A">Luxembourg</option><option value="MO" data-currency="USD" data-band="C">Macao SAR, China</option><option value="MG" data-currency="USD" data-band="D">Madagascar</option><option value="MW" data-currency="USD" data-band="D">Malawi</option><option value="MY" data-currency="USD" data-band="D">Malaysia</option><option value="MV" data-currency="USD" data-band="C">Maldives</option><option value="ML" data-currency="USD" data-band="D">Mali</option><option value="MT" data-currency="EUR" data-band="B">Malta</option><option value="MH" data-currency="USD" data-band="A">Marshall Islands</option><option value="MR" data-currency="USD" data-band="D">Mauritania</option><option value="MU" data-currency="USD" data-band="D">Mauritius</option><option value="MX" data-currency="USD" data-band="C">Mexico</option><option value="FM" data-currency="USD" data-band="A">Micronesia, Fed. Sts.</option><option value="MD" data-currency="USD" data-band="D">Moldova</option><option value="MN" data-currency="USD" data-band="D">Mongolia</option><option value="ME" data-currency="USD" data-band="D">Montenegro</option><option value="MA" data-currency="USD" data-band="D">Morocco</option><option value="MZ" data-currency="USD" data-band="D">Mozambique</option><option value="MM" data-currency="USD" data-band="D">Myanmar</option><option value="NA" data-currency="USD" data-band="D">Namibia</option><option value="NR" data-currency="USD" data-band="A">Naoero</option><option value="NP" data-currency="USD" data-band="D">Nepal</option><option value="NL" data-currency="EUR" data-band="A">Netherlands</option><option value="NZ" data-currency="USD" data-band="A">New Zealand</option><option value="NI" data-currency="USD" data-band="D">Nicaragua</option><option value="NE" data-currency="USD" data-band="D">Niger</option><option value="NG" data-currency="USD" data-band="D">Nigeria</option><option value="MK" data-currency="USD" data-band="D">North Macedonia</option><option value="NO" data-currency="USD" data-band="B">Norway</option><option value="OM" data-currency="USD" data-band="C">Oman</option><option value="PK" data-currency="USD" data-band="D">Pakistan</option><option value="PW" data-currency="USD" data-band="B">Palau</option><option value="PA" data-currency="USD" data-band="C">Panama</option><option value="PG" data-currency="USD" data-band="C">Papua New Guinea</option><option value="PY" data-currency="USD" data-band="D">Paraguay</option><option value="PE" data-currency="USD" data-band="C">Peru</option><option value="PH" data-currency="USD" data-band="D">Philippines</option><option value="PL" data-currency="EUR" data-band="C">Poland</option><option value="PT" data-currency="EUR" data-band="A">Portugal</option><option value="PR" data-currency="USD" data-band="B">Puerto Rico (US)</option><option value="QA" data-currency="USD" data-band="C">Qatar</option><option value="RO" data-currency="EUR" data-band="D">Romania</option><option value="RU" data-currency="USD" data-band="D">Russian Federation</option><option value="RW" data-currency="USD" data-band="D">Rwanda</option><option value="WS" data-currency="USD" data-band="C">Samoa</option><option value="SM" data-currency="USD" data-band="B">San Marino</option><option value="ST" data-currency="USD" data-band="C">Sao Tome and Principe</option><option value="SA" data-currency="USD" data-band="C">Saudi Arabia</option><option value="SN" data-currency="USD" data-band="D">Senegal</option><option value="RS" data-currency="USD" data-band="D">Serbia</option><option value="SC" data-currency="USD" data-band="C">Seychelles</option><option value="SL" data-currency="USD" data-band="D">Sierra Leone</option><option value="SG" data-currency="USD" data-band="C">Singapore</option><option value="SX" data-currency="USD" data-band="B">Sint Maarten (Dutch part)</option><option value="SK" data-currency="EUR" data-band="C">Slovak Republic</option><option value="SI" data-currency="EUR" data-band="C">Slovenia</option><option value="SB" data-currency="USD" data-band="B">Solomon Islands</option><option value="SO" data-currency="USD" data-band="D">Somalia, Fed. Rep.</option><option value="ZA" data-currency="USD" data-band="D">South Africa</option><option value="ES" data-currency="EUR" data-band="A">Spain</option><option value="LK" data-currency="USD" data-band="D">Sri Lanka</option><option value="KN" data-currency="USD" data-band="B">St. Kitts and Nevis</option><option value="LC" data-currency="USD" data-band="C">St. Lucia</option><option value="VC" data-currency="USD" data-band="C">St. Vincent and the Grenadines</option><option value="SD" data-currency="USD" data-band="C">Sudan</option><option value="SR" data-currency="USD" data-band="D">Suriname</option><option value="SE" data-currency="EUR" data-band="A">Sweden</option><option value="CH" data-currency="USD" data-band="A">Switzerland</option><option value="SY" data-currency="USD" data-band="D">Syrian Arab Republic</option><option value="TJ" data-currency="USD" data-band="D">Tajikistan</option><option value="TZ" data-currency="USD" data-band="D">Tanzania</option><option value="TH" data-currency="USD" data-band="D">Thailand</option><option value="TL" data-currency="USD" data-band="D">Timor-Leste</option><option value="TG" data-currency="USD" data-band="D">Togo</option><option value="TO" data-currency="USD" data-band="B">Tonga</option><option value="TT" data-currency="USD" data-band="C">Trinidad and Tobago</option><option value="TN" data-currency="USD" data-band="D">Tunisia</option><option value="TR" data-currency="USD" data-band="D">Turkiye</option><option value="TM" data-currency="USD" data-band="D">Turkmenistan</option><option value="TC" data-currency="USD" data-band="A">Turks and Caicos Islands</option><option value="TV" data-currency="USD" data-band="A">Tuvalu</option><option value="UG" data-currency="USD" data-band="D">Uganda</option><option value="UA" data-currency="USD" data-band="D">Ukraine</option><option value="AE" data-currency="USD" data-band="B">United Arab Emirates</option><option value="GB" data-currency="GBP" data-band="A">United Kingdom</option><option value="US" data-currency="USD" data-band="A">United States</option><option value="UY" data-currency="USD" data-band="B">Uruguay</option><option value="UZ" data-currency="USD" data-band="D">Uzbekistan</option><option value="VU" data-currency="USD" data-band="A">Vanuatu</option><option value="VN" data-currency="USD" data-band="D">Viet Nam</option><option value="VI" data-currency="USD" data-band="A">Virgin Islands (U.S.)</option><option value="PS" data-currency="USD" data-band="B">West Bank and Gaza</option><option value="ZM" data-currency="USD" data-band="D">Zambia</option><option value="ZW" data-currency="USD" data-band="D">Zimbabwe</option></select> |
| <p class="control-note">Your billing country sets your pricing band. Changing currency does not change your inclusions.</p> |
| </div> |
| </form> |
| @@ -61,7 +61,7 @@ |
| <div class="plans"> |
| <article class="plan"> |
| <h3>Free</h3> |
| - <p class="price"><span>€0</span></p> |
| + <p class="price"><span data-currency-values="{"USD":"$0","EUR":"€0","GBP":"£0"}">$0</span></p> |
| <p class="for"><span>Free to try, great for tiny apps.</span></p> |
| <ul> |
| <li>One member</li> |
| @@ -77,40 +77,40 @@ |
| </article> |
| <article class="plan featured"> |
| <h3>Personal</h3> |
| - <p class="price"><span data-currency-values="{"USD":"$28/y","EUR":"€24/y","GBP":"£22/y"}">€24/y</span></p> |
| + <p class="price"><span data-band-values="{"A":{"USD":"$28/y","EUR":"€24/y","GBP":"£22/y"},"B":{"USD":"$21/y","EUR":"€18/y","GBP":"£16.50/y"},"C":{"USD":"$14/y","EUR":"€12/y","GBP":"£11/y"},"D":{"USD":"$9.80/y","EUR":"€8.40/y","GBP":"£7.70/y"}}">$28/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> |
| + <li data-band-values="{"A":"85 awake hours a month","B":"63.75 awake hours a month","C":"42.5 awake hours a month","D":"29.75 awake hours a month"}">85 awake hours a month</li> |
| + <li data-band-values="{"A":"2 GB of storage","B":"1.5 GB of storage","C":"1 GB of storage","D":"0.7 GB of storage"}">2 GB of storage</li> |
| + <li data-band-values="{"A":"2 GB of transfer a month","B":"1.5 GB of transfer a month","C":"1 GB of transfer a month","D":"0.7 GB of transfer a month"}">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> |
| </article> |
| <article class="plan"> |
| <h3>Community</h3> |
| - <p class="price"><span data-currency-values="{"USD":"$6/y per member","EUR":"€5/y per member","GBP":"£4.50/y per member"}">€5/y per member</span></p> |
| + <p class="price"><span data-band-values="{"A":{"USD":"$6/y per member","EUR":"€5/y per member","GBP":"£4.50/y per member"},"B":{"USD":"$4.50/y per member","EUR":"€3.75/y per member","GBP":"£3.38/y per member"},"C":{"USD":"$3/y per member","EUR":"€2.50/y per member","GBP":"£2.25/y per member"},"D":{"USD":"$2.10/y per member","EUR":"€1.75/y per member","GBP":"£1.58/y per member"}}">$6/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> |
| + <li data-band-values="{"A":"17 awake hours a month","B":"12.75 awake hours a month","C":"8.5 awake hours a month","D":"5.95 awake hours a month"}">17 awake hours a month</li> |
| + <li data-band-values="{"A":"0.5 GB of storage","B":"0.375 GB of storage","C":"0.25 GB of storage","D":"0.175 GB of storage"}">0.5 GB of storage</li> |
| + <li data-band-values="{"A":"0.4 GB of transfer a month","B":"0.3 GB of transfer a month","C":"0.2 GB of transfer a month","D":"0.14 GB of transfer a month"}">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> |
| </article> |
| <article class="plan"> |
| <h3>Business</h3> |
| - <p class="price"><span data-currency-values="{"USD":"$60/y per member","EUR":"€50/y per member","GBP":"£45/y per member"}">€50/y per member</span></p> |
| + <p class="price"><span data-band-values="{"A":{"USD":"$60/y per member","EUR":"€50/y per member","GBP":"£45/y per member"},"B":{"USD":"$45/y per member","EUR":"€37.50/y per member","GBP":"£33.75/y per member"},"C":{"USD":"$30/y per member","EUR":"€25/y per member","GBP":"£22.50/y per member"},"D":{"USD":"$21/y per member","EUR":"€17.50/y per member","GBP":"£15.75/y per member"}}">$60/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> |
| <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> |
| + <li data-band-values="{"A":"170 extra awake hours/m","B":"127.5 extra awake hours/m","C":"85 extra awake hours/m","D":"59.5 extra awake hours/m"}">170 extra awake hours/m</li> |
| + <li data-band-values="{"A":"5 GB/m extra of storage","B":"3.75 GB/m extra of storage","C":"2.5 GB/m extra of storage","D":"1.75 GB/m extra of storage"}">5 GB/m extra of storage</li> |
| + <li data-band-values="{"A":"3 GB of transfer a month","B":"2.25 GB of transfer a month","C":"1.5 GB of transfer a month","D":"1.05 GB of transfer a month"}">3 GB of transfer a month</li> |
| + <li data-band-values="{"A":"30,000 replication writes a month","B":"22,500 replication writes a month","C":"15,000 replication writes a month","D":"10,500 replication writes a month"}">30,000 replication writes a month</li> |
| </ul> |
| <ul class="plan-features"> |
| <li>Continuous replication, with restore to an earlier moment</li> |
| @@ -142,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 data-currency-values="{"USD":"$28","EUR":"€24","GBP":"£22"}">€24</td><td data-currency-values="{"USD":"$21","EUR":"€18","GBP":"£16.50"}">€18</td><td data-currency-values="{"USD":"$14","EUR":"€12","GBP":"£11"}">€12</td><td data-currency-values="{"USD":"$9.80","EUR":"€8.40","GBP":"£7.70"}">€8.40</td></tr> |
| - <tr><th scope="row">Community, per member a year</th><td data-currency-values="{"USD":"$6","EUR":"€5","GBP":"£4.50"}">€5</td><td data-currency-values="{"USD":"$4.50","EUR":"€3.75","GBP":"£3.38"}">€3.75</td><td data-currency-values="{"USD":"$3","EUR":"€2.50","GBP":"£2.25"}">€2.50</td><td data-currency-values="{"USD":"$2.10","EUR":"€1.75","GBP":"£1.58"}">€1.75</td></tr> |
| - <tr><th scope="row">Business, per member a year</th><td data-currency-values="{"USD":"$60","EUR":"€50","GBP":"£45"}">€50</td><td data-currency-values="{"USD":"$45","EUR":"€37.50","GBP":"£33.75"}">€37.50</td><td data-currency-values="{"USD":"$30","EUR":"€25","GBP":"£22.50"}">€25</td><td data-currency-values="{"USD":"$21","EUR":"€17.50","GBP":"£15.75"}">€17.50</td></tr> |
| + <tr><th scope="row">Personal, a year</th><td data-currency-values="{"USD":"$28","EUR":"€24","GBP":"£22"}">$28</td><td data-currency-values="{"USD":"$21","EUR":"€18","GBP":"£16.50"}">$21</td><td data-currency-values="{"USD":"$14","EUR":"€12","GBP":"£11"}">$14</td><td data-currency-values="{"USD":"$9.80","EUR":"€8.40","GBP":"£7.70"}">$9.80</td></tr> |
| + <tr><th scope="row">Community, per member a year</th><td data-currency-values="{"USD":"$6","EUR":"€5","GBP":"£4.50"}">$6</td><td data-currency-values="{"USD":"$4.50","EUR":"€3.75","GBP":"£3.38"}">$4.50</td><td data-currency-values="{"USD":"$3","EUR":"€2.50","GBP":"£2.25"}">$3</td><td data-currency-values="{"USD":"$2.10","EUR":"€1.75","GBP":"£1.58"}">$2.10</td></tr> |
| + <tr><th scope="row">Business, per member a year</th><td data-currency-values="{"USD":"$60","EUR":"€50","GBP":"£45"}">$60</td><td data-currency-values="{"USD":"$45","EUR":"€37.50","GBP":"£33.75"}">$45</td><td data-currency-values="{"USD":"$30","EUR":"€25","GBP":"£22.50"}">$30</td><td data-currency-values="{"USD":"$21","EUR":"€17.50","GBP":"£15.75"}">$21</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> |
| @@ -164,13 +164,13 @@ |
| <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 data-currency-values="{"USD":"$0.012 per hour","EUR":"€0.010 per hour","GBP":"£0.009 per hour"}">€0.010 per hour</td></tr> |
| - <tr><th scope="row">Storage</th><td data-currency-values="{"USD":"$0.127 per GB per month","EUR":"€0.106 per GB per month","GBP":"£0.095 per GB per month"}">€0.106 per GB per month</td></tr> |
| - <tr><th scope="row">Transfer</th><td data-currency-values="{"USD":"$0.497 per GB","EUR":"€0.414 per GB","GBP":"£0.373 per GB"}">€0.414 per GB</td></tr> |
| - <tr><th scope="row">Replication writes</th><td data-currency-values="{"USD":"$0.028 per 1,000","EUR":"€0.023 per 1,000","GBP":"£0.021 per 1,000"}">€0.023 per 1,000</td></tr> |
| + <tr><th scope="row">Awake time</th><td data-currency-values="{"USD":"$0.012 per hour","EUR":"€0.010 per hour","GBP":"£0.009 per hour"}">$0.012 per hour</td></tr> |
| + <tr><th scope="row">Storage</th><td data-currency-values="{"USD":"$0.127 per GB per month","EUR":"€0.106 per GB per month","GBP":"£0.095 per GB per month"}">$0.127 per GB per month</td></tr> |
| + <tr><th scope="row">Transfer</th><td data-currency-values="{"USD":"$0.497 per GB","EUR":"€0.414 per GB","GBP":"£0.373 per GB"}">$0.497 per GB</td></tr> |
| + <tr><th scope="row">Replication writes</th><td data-currency-values="{"USD":"$0.028 per 1,000","EUR":"€0.023 per 1,000","GBP":"£0.021 per 1,000"}">$0.028 per 1,000</td></tr> |
| </tbody> |
| </table> |
| - <p data-currency-values="{"USD":"Minimum $1.20 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.","EUR":"Minimum €1 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.","GBP":"Minimum £0.90 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month."}">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 data-currency-values="{"USD":"Minimum $1.20 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.","EUR":"Minimum €1 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.","GBP":"Minimum £0.90 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month."}">Minimum $1.20 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> |
diff --git a/src/site.js b/src/site.js| index 01d230d..29d12ef 100644 |
| --- a/src/site.js |
| +++ b/src/site.js |
| @@ -11,6 +11,14 @@ |
| onScroll(); |
| } |
| + // The pricing page's currency and billing-country controls. The currency |
| + // picks the text of every element carrying data-currency-values; the |
| + // country picks the band (its option's data-band) for every element |
| + // carrying data-band-values, whose texts are by band and then, where the |
| + // amount is money, by currency. Both are forwarded to the console on |
| + // the signup links (?currency=, ?country=), where they are suggestions: |
| + // checkout prices from the country the buyer gives there, in the |
| + // currency the buyer confirms there. Nothing here is a price. |
| var currencySelect = document.querySelector("[data-pricing-currency]"); |
| if (currencySelect) { |
| var supported = ["USD", "EUR", "GBP"]; |
| @@ -20,24 +28,52 @@ |
| function countryCurrency(country) { |
| return country === "GB" ? "GBP" : eu.indexOf(country) !== -1 ? "EUR" : "USD"; |
| } |
| - function renderCurrency(currency) { |
| + // countryOption is the selected country's option, or null when none |
| + // is selected or the value matches no option. |
| + function countryOption() { |
| + var value = countrySelect && countrySelect.value; |
| + if (!value || !/^[A-Z]{2}$/.test(value)) return null; |
| + return countrySelect.querySelector('option[value="' + value + '"]'); |
| + } |
| + function selectedBand() { |
| + var opt = countryOption(); |
| + var band = opt && opt.getAttribute("data-band"); |
| + return band === "B" || band === "C" || band === "D" ? band : "A"; |
| + } |
| + function render(currency) { |
| if (supported.indexOf(currency) === -1) return; |
| currencySelect.value = currency; |
| + var band = selectedBand(); |
| document.querySelectorAll("[data-currency-values]").forEach(function (el) { |
| try { |
| var values = JSON.parse(el.getAttribute("data-currency-values")); |
| if (typeof values[currency] === "string") el.textContent = values[currency]; |
| } catch (_) {} |
| }); |
| + document.querySelectorAll("[data-band-values]").forEach(function (el) { |
| + try { |
| + var text = JSON.parse(el.getAttribute("data-band-values"))[band]; |
| + if (text && typeof text === "object") text = text[currency]; |
| + if (typeof text === "string") el.textContent = text; |
| + } catch (_) {} |
| + }); |
| + var country = countryOption() ? countrySelect.value : ""; |
| document.querySelectorAll("[data-pricing-signup]").forEach(function (el) { |
| var url = new URL(el.href, window.location.href); |
| url.searchParams.set("currency", currency.toLowerCase()); |
| + if (country) url.searchParams.set("country", country); |
| + else url.searchParams.delete("country"); |
| el.href = url.href; |
| }); |
| } |
| var initial = "USD"; |
| try { |
| - var requested = new URL(window.location.href).searchParams.get("currency"); |
| + 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(); |
| + } |
| + var requested = params.get("currency"); |
| requested = requested && requested.toUpperCase(); |
| if (supported.indexOf(requested) !== -1) { |
| initial = requested; |
| @@ -49,13 +85,13 @@ |
| initial = countryCurrency(region); |
| } |
| } catch (_) {} |
| - renderCurrency(initial); |
| + render(initial); |
| currencySelect.addEventListener("change", function () { |
| explicit = true; |
| - renderCurrency(currencySelect.value); |
| + render(currencySelect.value); |
| }); |
| if (countrySelect) countrySelect.addEventListener("change", function () { |
| - if (!explicit) renderCurrency(countryCurrency(countrySelect.value)); |
| + render(explicit ? currencySelect.value : countryCurrency(countrySelect.value)); |
| }); |
| } |
diff --git a/test/pricing-currency.test.mjs b/test/pricing-currency.test.mjs| index 92b7af9..9da7d5c 100644 |
| --- a/test/pricing-currency.test.mjs |
| +++ b/test/pricing-currency.test.mjs |
| @@ -3,21 +3,38 @@ import { runInNewContext } from 'node:vm'; |
| import assert from 'node:assert/strict'; |
| import { test } from 'node:test'; |
| const source = readFileSync(new URL('../src/site.js', import.meta.url), 'utf8'); |
| +// A page with the two controls, one currency-priced element (an overage |
| +// rate), one band-and-currency-priced element (a plan price), one |
| +// 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 = '') { |
| const listeners = {}; |
| const selector = { value: 'USD', addEventListener: (name, fn) => listeners.currency = fn }; |
| - const countrySelector = { value: country, addEventListener: (name, fn) => listeners.country = fn }; |
| - const amount = { textContent: '$28', getAttribute: () => JSON.stringify({ USD: '$28', EUR: '€24', GBP: '£22' }) }; |
| + const countrySelector = { |
| + value: 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; |
| + }, |
| + }; |
| + 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' }) }; |
| + const price = { textContent: '$28/y', getAttribute: () => JSON.stringify({ |
| + A: { USD: '$28/y', EUR: '€24/y', GBP: '£22/y' }, B: { USD: '$21/y', EUR: '€18/y', GBP: '£16.50/y' }, |
| + C: { USD: '$14/y', EUR: '€12/y', GBP: '£11/y' }, D: { USD: '$9.80/y', EUR: '€8.40/y', GBP: '£7.70/y' } }) }; |
| + const hours = { textContent: '85 awake hours a month', getAttribute: () => JSON.stringify({ A: '85 awake hours a month', B: '63.75 awake hours a month', C: '42.5 awake hours a month', D: '29.75 awake hours a month' }) }; |
| 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, |
| - querySelectorAll: key => key === '[data-currency-values]' ? [amount] : key === '[data-pricing-signup]' ? [signup] : [] |
| + 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 } }, |
| navigator: { language }, Intl, URL |
| }); |
| - return { selector, countrySelector, amount, signup, listeners }; |
| + const link = () => new URL(signup.href).searchParams; |
| + return { selector, countrySelector, rate, price, hours, signup, link, listeners }; |
| } |
| 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']]) { |
| @@ -26,13 +43,16 @@ test('currency defaults from explicit locale region; language alone is not a cou |
| }); |
| test('explicit choice overrides region and persists when billing country changes', () => { |
| const p = page('en-IE', '?currency=gbp', 'IE'); |
| - assert.equal(p.amount.textContent, '£22'); |
| - assert.equal(new URL(p.signup.href).searchParams.get('plan'), 'personal'); |
| - assert.equal(new URL(p.signup.href).searchParams.get('currency'), 'gbp'); |
| + assert.equal(p.price.textContent, '£22/y'); |
| + assert.equal(p.rate.textContent, '£0.009 per hour'); |
| + assert.equal(p.link().get('plan'), 'personal'); |
| + assert.equal(p.link().get('currency'), 'gbp'); |
| + assert.equal(p.link().get('country'), 'IE'); |
| p.countrySelector.value = 'US'; p.listeners.country(); |
| assert.equal(p.selector.value, 'GBP'); |
| + assert.equal(p.link().get('country'), 'US'); |
| p.selector.value = 'USD'; p.listeners.currency(); |
| - assert.equal(p.amount.textContent, '$28'); |
| + assert.equal(p.price.textContent, '$28/y'); |
| }); |
| test('country sets default until a currency is explicitly chosen', () => { |
| const p = page('en-US', '', 'IE'); |
| @@ -40,8 +60,45 @@ test('country sets default until a currency is explicitly chosen', () => { |
| p.countrySelector.value = 'GB'; p.listeners.country(); |
| assert.equal(p.selector.value, 'GBP'); |
| }); |
| +test('a band D country with an explicit currency keeps the currency and changes the band: price, inclusions and the signup link', () => { |
| + const p = page('en-US', '?currency=gbp', ''); |
| + assert.equal(p.price.textContent, '£22/y'); |
| + assert.equal(p.hours.textContent, '85 awake hours a month'); |
| + assert.equal(p.link().get('country'), null); |
| + p.countrySelector.value = 'IN'; p.listeners.country(); |
| + assert.equal(p.selector.value, 'GBP', 'the explicit currency stays'); |
| + assert.equal(p.price.textContent, '£7.70/y', 'the price is band D in pounds'); |
| + assert.equal(p.hours.textContent, '29.75 awake hours a month', 'the inclusions are band D'); |
| + assert.equal(p.rate.textContent, '£0.009 per hour', 'overage rates do not change with the band'); |
| + assert.deepEqual([p.link().get('currency'), p.link().get('country'), p.link().get('plan')], ['gbp', 'IN', 'personal']); |
| + // Back to a band A country: full price, full inclusions, same currency. |
| + p.countrySelector.value = 'US'; p.listeners.country(); |
| + assert.equal(p.price.textContent, '£22/y'); |
| + assert.equal(p.hours.textContent, '85 awake hours a month'); |
| + // No country: band A, and the link carries no country. |
| + p.countrySelector.value = ''; p.listeners.country(); |
| + assert.equal(p.price.textContent, '£22/y'); |
| + assert.equal(p.link().get('country'), null); |
| +}); |
| +test('a band C country without an explicit currency takes its default currency and its band', () => { |
| + const p = page('en-US', '', ''); |
| + p.countrySelector.value = 'PL'; p.listeners.country(); |
| + assert.equal(p.selector.value, 'EUR'); |
| + assert.equal(p.price.textContent, '€12/y'); |
| + assert.equal(p.hours.textContent, '42.5 awake hours a month'); |
| + assert.deepEqual([p.link().get('currency'), p.link().get('country')], ['eur', 'PL']); |
| +}); |
| +test('the link back from the console preselects the country and currency it carries', () => { |
| + const p = page('en-US', '?country=in¤cy=gbp', ''); |
| + assert.equal(p.countrySelector.value, 'IN'); |
| + 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.link().get('country'), null); |
| +}); |
| test('unsupported URL currency is ignored and never forwarded', () => { |
| const p = page('en-US', '?currency=xyz'); |
| assert.equal(p.selector.value, 'USD'); |
| - assert.equal(new URL(p.signup.href).searchParams.get('currency'), 'usd'); |
| + assert.equal(p.link().get('currency'), 'usd'); |
| }); |
diff --git a/test/pricing-pricebook.test.mjs b/test/pricing-pricebook.test.mjs| index a9db5ce..7795de3 100644 |
| --- a/test/pricing-pricebook.test.mjs |
| +++ b/test/pricing-pricebook.test.mjs |
| @@ -1,7 +1,8 @@ |
| // 🤖 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. |
| +// pricebook.json; the approved EUR strings are the book's EUR at band A; |
| +// the other currencies' and bands' 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'; |
| @@ -16,8 +17,17 @@ 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(/"/g, '"').replace(/&/g, '&'); |
| const valuesOf = (html) => [...html.matchAll(/data-currency-values="([^"]*)"/g)].map((m) => JSON.parse(unesc(m[1]))); |
| +const bandValuesOf = (html) => [...html.matchAll(/data-band-values="([^"]*)"/g)].map((m) => JSON.parse(unesc(m[1]))); |
| +const re = (id) => id.replace(/\./g, '\\.'); |
| +const attr = (id, name) => { |
| + const m = preview.match(new RegExp(`data-copy="${re(id)}" ${name}="([^"]*)"`)); |
| + assert.ok(m, id + ' carries ' + name); |
| + return JSON.parse(unesc(m[1])); |
| +}; |
| +const text = (id) => preview.match(new RegExp(`data-copy="${re(id)}"[^>]*>([^<]*)<`))[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])); |
| +const planOf = (k) => book.plans.find((p) => p.key === k); |
| test('the book is the platform export, at the version the console sells from', () => { |
| assert.equal(book.schema, 1); |
| @@ -25,26 +35,44 @@ test('the book is the platform export, at the version the console sells from', ( |
| 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', () => { |
| +test('every priced string carries all three currencies, every banded one all four bands, and band A EUR is the approved text', () => { |
| const all = valuesOf(page); |
| - assert.ok(all.length >= 3 + 12 + 4 + 1, `found ${all.length} priced strings`); |
| + assert.equal(all.length, 1 + 12 + 4 + 1, `found ${all.length} currency-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'); |
| + for (const id of ['plan.free.price', 'over.rate.awake', 'over.rate.storage', 'over.rate.transfer', 'over.rate.writes', 'over.p2']) { |
| + assert.equal(attr(id, 'data-currency-values').EUR, S[id], id + ' EUR is the approved string'); |
| } |
| + const banded = bandValuesOf(page); |
| + assert.equal(banded.length, 3 + 3 + 3 + 4, `found ${banded.length} banded strings`); |
| + for (const v of banded) assert.deepEqual(Object.keys(v), ['A', 'B', 'C', 'D']); |
| + for (const id of ['plan.personal.price', 'plan.community.price', 'plan.business.price']) { |
| + const v = attr(id, 'data-band-values'); |
| + for (const band of ['A', 'B', 'C', 'D']) assert.deepEqual(Object.keys(v[band]).sort(), ['EUR', 'GBP', 'USD']); |
| + assert.equal(v.A.EUR, S[id], id + ' band A EUR is the approved string'); |
| + } |
| +}); |
| + |
| +test('the static page, before any script runs, is the default currency at band A: what the controls show selected', () => { |
| + assert.equal(book.default_currency, 'usd'); |
| + assert.ok(page.includes('<option value="USD">USD</option><option value="EUR">EUR</option><option value="GBP">GBP</option>')); |
| + for (const id of ['plan.personal.price', 'plan.community.price', 'plan.business.price']) assert.equal(text(id), attr(id, 'data-band-values').A.USD, id); |
| + for (const id of ['plan.free.price', 'over.rate.awake', 'over.p2']) assert.equal(text(id), attr(id, 'data-currency-values').USD, id); |
| + for (const id of ['plan.personal.i2', 'plan.business.i4']) assert.equal(text(id), attr(id, 'data-band-values').A, id); |
| + assert.equal(text('plan.personal.price'), '$28/y'); |
| + assert.equal(text('plan.free.price'), '$0'); |
| + const stripped = page.replace(/data-(currency|band)-values="[^"]*"/g, ''); |
| + assert.equal((stripped.match(/€/g) || []).length, 0, 'no euro amount is in the static text'); |
| }); |
| -test('plan prices and the band table are the book, per currency', () => { |
| - const planOf = (k) => book.plans.find((p) => p.key === k); |
| +test('plan prices and the band table are the book, per band and currency', () => { |
| 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 v = attr(id, 'data-band-values'); |
| + for (const band of ['A', 'B', 'C', 'D']) for (const cur of ['USD', 'EUR', 'GBP']) { |
| + assert.ok(v[band][cur].startsWith(money(sym[cur], planOf(k).annual_unit_minor[cur.toLowerCase()][band])), `${k} ${band} ${cur}: ${v[band][cur]}`); |
| } |
| } |
| + assert.equal(attr('plan.personal.price', 'data-band-values').D.GBP, '£7.70/y'); |
| + assert.equal(attr('plan.business.price', 'data-band-values').D.GBP, '£15.75/y per member'); |
| const cells = valuesOf(page.slice(page.indexOf('class="price-table"'), page.indexOf('class="price-table rates"'))); |
| assert.equal(cells.length, 12); |
| let i = 0; |
| @@ -57,6 +85,17 @@ test('plan prices and the band table are the book, per currency', () => { |
| assert.equal(cells[7].GBP, '£1.58'); |
| }); |
| +test('a band-scaled inclusion carries each band\'s figure by the platform\'s rule, and band A is the approved string', () => { |
| + const per = (k, resource, band) => Math.floor(planOf(k).resources_per_member[resource] * book.band_factors_bps[band] / 10000); |
| + assert.deepEqual(attr('plan.personal.i2', 'data-band-values'), { A: S['plan.personal.i2'], B: '63.75 awake hours a month', C: '42.5 awake hours a month', D: '29.75 awake hours a month' }); |
| + assert.equal(per('personal', 'awake_seconds', 'D') / 3600, 29.75); |
| + assert.deepEqual(attr('plan.community.i2', 'data-band-values'), { A: S['plan.community.i2'], B: '0.375 GB of storage', C: '0.25 GB of storage', D: '0.175 GB of storage' }); |
| + assert.deepEqual(attr('plan.business.i4', 'data-band-values'), { A: S['plan.business.i4'], B: '22,500 replication writes a month', C: '15,000 replication writes a month', D: '10,500 replication writes a month' }); |
| + assert.equal(per('business', 'writes', 'D'), 10500); |
| + // Strings with no figure, and the free plan's, are not banded. |
| + for (const id of ['plan.personal.i1', 'plan.free.i3', 'plan.business.i5']) assert.ok(!preview.match(new RegExp(`data-copy="${re(id)}" data-band-values`)), id); |
| +}); |
| + |
| 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; |
| @@ -67,22 +106,23 @@ test('overage rates and the floor are the book, per currency', () => { |
| 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', () => { |
| +test('the controls carry the five approved strings, and every country option its default currency and band 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">')); |
| + for (const [iso, band] of Object.entries(book.country_band)) { |
| + assert.ok(page.includes(`<option value="${iso}" data-currency="${(book.country_currency[iso] || book.default_currency).toUpperCase()}" data-band="${band}">`), iso); |
| + } |
| + assert.ok(page.includes('<option value="IE" data-currency="EUR" data-band="A">')); |
| + assert.ok(page.includes('<option value="GB" data-currency="GBP" data-band="A">')); |
| + 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); |
| }); |
| 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, ''); |
| + const stripped = page.replace(/data-(currency|band)-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)); |