| 1 | // Pure helpers for the pricing picker's band row and its local-currency ≈ |
| 2 | // display (docs/pricing-bands.md). Framework-free and DOM-free so they're |
| 3 | // unit-testable in Node (home/test/fx.test.mjs); home.js does the wiring. |
| 4 | // |
| 5 | // Billing is EUR everywhere — the ≈ figure is a labelled estimate from the |
| 6 | // ECB reference-rate snapshot home serves at /api/fx, converted client-side. |
| 7 | // Currency choice comes from the BROWSER LOCALE, never IP (privacy-consistent |
| 8 | // with the honor picker: nothing about the buyer is detected or stored). |
| 9 | |
| 10 | // The band vocabulary, in display order, with the picker's short names and |
| 11 | // the one-line explanations (lock-and-key voice; aggregate cross-subsidy |
| 12 | // only — never a per-space claim, never "discount", never "eligible"). |
| 13 | export const BANDS = [ |
| 14 | { key: "supporter", name: "Supporter", |
| 15 | line: "The supporter price. Paying supports free spaces for those who can't — the difference doesn't go to us, it goes to the free pool." }, |
| 16 | { key: "base", name: "Standard", |
| 17 | line: "The standard price. It covers your space's costs with margin for resilience — the books are public." }, |
| 18 | { key: "reduced", name: "Reduced", |
| 19 | line: "The reduced price, carried by supporters. It's yours as long as you need it." }, |
| 20 | { key: "solidarity", name: "Solidarity", |
| 21 | line: "The solidarity price, carried by supporters. It's yours as long as you need it." }, |
| 22 | ]; |
| 23 | |
| 24 | export function bandName(key) { |
| 25 | const b = BANDS.find((b) => b.key === key); |
| 26 | return (b && b.name) || key; |
| 27 | } |
| 28 | |
| 29 | // bandsOffered: which of the four bands this price book can actually sell — |
| 30 | // base always; a non-base band once ANY tier defines it. |
| 31 | export function bandsOffered(tiers) { |
| 32 | return BANDS.filter((b) => b.key === "base" || |
| 33 | (tiers || []).some((t) => t.bands && t.bands[b.key])).map((b) => b.key); |
| 34 | } |
| 35 | |
| 36 | // tierForBand resolves what a tier shows under a selected band: its own |
| 37 | // label/eur for base or when it doesn't offer the band (flagged so the UI can |
| 38 | // say "standard price" instead of implying a reduction), the band's otherwise. |
| 39 | export function tierForBand(tier, band) { |
| 40 | if (band !== "base" && tier.bands && tier.bands[band]) { |
| 41 | const b = tier.bands[band]; |
| 42 | return { label: b.label, eur: b.eur || 0, band, offered: true }; |
| 43 | } |
| 44 | return { label: tier.label, eur: tier.eur || 0, band: "base", offered: band === "base" }; |
| 45 | } |
| 46 | |
| 47 | // regionCurrency maps a BCP-47 locale to the display currency for the ≈ |
| 48 | // figure: "" for the euro area (billed in EUR — nothing to approximate), |
| 49 | // unknown regions, and currencies the ECB feed doesn't quote. |
| 50 | const REGION_CURRENCY = { |
| 51 | US: "USD", GB: "GBP", JP: "JPY", BG: "BGN", CZ: "CZK", DK: "DKK", |
| 52 | HU: "HUF", PL: "PLN", RO: "RON", SE: "SEK", CH: "CHF", LI: "CHF", |
| 53 | IS: "ISK", NO: "NOK", TR: "TRY", AU: "AUD", BR: "BRL", CA: "CAD", |
| 54 | CN: "CNY", HK: "HKD", ID: "IDR", IL: "ILS", IN: "INR", KR: "KRW", |
| 55 | MX: "MXN", MY: "MYR", NZ: "NZD", PH: "PHP", SG: "SGD", TH: "THB", |
| 56 | ZA: "ZAR", |
| 57 | }; |
| 58 | |
| 59 | export function regionCurrency(locale) { |
| 60 | try { |
| 61 | const region = new Intl.Locale(locale).maximize().region; |
| 62 | return REGION_CURRENCY[region] || ""; |
| 63 | } catch { |
| 64 | return ""; |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // approxLocal renders the ≈ string for a EUR amount — "≈ $8.20" — or "" when |
| 69 | // the currency or its rate is unknown (the UI simply shows no figure; an |
| 70 | // absent estimate beats a wrong one). Small amounts keep cents; larger ones |
| 71 | // round to whole units (an estimate pretending cent precision reads wrong). |
| 72 | export function approxLocal(eur, currency, rates, locale) { |
| 73 | if (!eur || !currency || !rates || !rates[currency]) return ""; |
| 74 | const amount = eur * rates[currency]; |
| 75 | try { |
| 76 | // Cents show as a pair or not at all ("$5.50" / "$6", never "$5.5"), and |
| 77 | // only under 20 units — an estimate pretending cent precision reads wrong. |
| 78 | const cents = amount < 20 && Math.round(amount * 100) % 100 !== 0 ? 2 : 0; |
| 79 | const fmt = new Intl.NumberFormat(locale || undefined, { |
| 80 | style: "currency", currency, |
| 81 | minimumFractionDigits: cents, maximumFractionDigits: cents, |
| 82 | }); |
| 83 | return "≈ " + fmt.format(amount); |
| 84 | } catch { |
| 85 | return ""; |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // capNote states the +15% annual-review promise in money, not percent (the |
| 90 | // Ofcom shape: pounds-and-pence at the point of sale): "renewal can never add |
| 91 | // more than €0.75/year without your say-so". Empty when the sticker is |
| 92 | // unknown — the generic sentence beside it still carries the promise. |
| 93 | export function capNote(eur, locale) { |
| 94 | if (!eur) return ""; |
| 95 | const cap = eur * 0.15; |
| 96 | const cents = Math.round(cap * 100) % 100 !== 0 ? 2 : 0; |
| 97 | const fmt = new Intl.NumberFormat(locale || undefined, { |
| 98 | style: "currency", currency: "EUR", |
| 99 | minimumFractionDigits: cents, maximumFractionDigits: cents, |
| 100 | }); |
| 101 | return "never more than " + fmt.format(cap) + " more at renewal without your say-so"; |
| 102 | } |
| 103 | |