// Pure helpers for the pricing picker's band row and its local-currency ≈ // display (docs/pricing-bands.md). Framework-free and DOM-free so they're // unit-testable in Node (home/test/fx.test.mjs); home.js does the wiring. // // Billing is EUR everywhere — the ≈ figure is a labelled estimate from the // ECB reference-rate snapshot home serves at /api/fx, converted client-side. // Currency choice comes from the BROWSER LOCALE, never IP (privacy-consistent // with the honor picker: nothing about the buyer is detected or stored). // The band vocabulary, in display order, with the picker's short names and // the one-line explanations (lock-and-key voice; aggregate cross-subsidy // only — never a per-space claim, never "discount", never "eligible"). export const BANDS = [ { key: "supporter", name: "Supporter", 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." }, { key: "base", name: "Standard", line: "The standard price. It covers your space's costs with margin for resilience — the books are public." }, { key: "reduced", name: "Reduced", line: "The reduced price, carried by supporters. It's yours as long as you need it." }, { key: "solidarity", name: "Solidarity", line: "The solidarity price, carried by supporters. It's yours as long as you need it." }, ]; export function bandName(key) { const b = BANDS.find((b) => b.key === key); return (b && b.name) || key; } // bandsOffered: which of the four bands this price book can actually sell — // base always; a non-base band once ANY tier defines it. export function bandsOffered(tiers) { return BANDS.filter((b) => b.key === "base" || (tiers || []).some((t) => t.bands && t.bands[b.key])).map((b) => b.key); } // tierForBand resolves what a tier shows under a selected band: its own // label/eur for base or when it doesn't offer the band (flagged so the UI can // say "standard price" instead of implying a reduction), the band's otherwise. export function tierForBand(tier, band) { if (band !== "base" && tier.bands && tier.bands[band]) { const b = tier.bands[band]; return { label: b.label, eur: b.eur || 0, band, offered: true }; } return { label: tier.label, eur: tier.eur || 0, band: "base", offered: band === "base" }; } // regionCurrency maps a BCP-47 locale to the display currency for the ≈ // figure: "" for the euro area (billed in EUR — nothing to approximate), // unknown regions, and currencies the ECB feed doesn't quote. const REGION_CURRENCY = { US: "USD", GB: "GBP", JP: "JPY", BG: "BGN", CZ: "CZK", DK: "DKK", HU: "HUF", PL: "PLN", RO: "RON", SE: "SEK", CH: "CHF", LI: "CHF", IS: "ISK", NO: "NOK", TR: "TRY", AU: "AUD", BR: "BRL", CA: "CAD", CN: "CNY", HK: "HKD", ID: "IDR", IL: "ILS", IN: "INR", KR: "KRW", MX: "MXN", MY: "MYR", NZ: "NZD", PH: "PHP", SG: "SGD", TH: "THB", ZA: "ZAR", }; export function regionCurrency(locale) { try { const region = new Intl.Locale(locale).maximize().region; return REGION_CURRENCY[region] || ""; } catch { return ""; } } // approxLocal renders the ≈ string for a EUR amount — "≈ $8.20" — or "" when // the currency or its rate is unknown (the UI simply shows no figure; an // absent estimate beats a wrong one). Small amounts keep cents; larger ones // round to whole units (an estimate pretending cent precision reads wrong). export function approxLocal(eur, currency, rates, locale) { if (!eur || !currency || !rates || !rates[currency]) return ""; const amount = eur * rates[currency]; try { // Cents show as a pair or not at all ("$5.50" / "$6", never "$5.5"), and // only under 20 units — an estimate pretending cent precision reads wrong. const cents = amount < 20 && Math.round(amount * 100) % 100 !== 0 ? 2 : 0; const fmt = new Intl.NumberFormat(locale || undefined, { style: "currency", currency, minimumFractionDigits: cents, maximumFractionDigits: cents, }); return "≈ " + fmt.format(amount); } catch { return ""; } } // capNote states the +15% annual-review promise in money, not percent (the // Ofcom shape: pounds-and-pence at the point of sale): "renewal can never add // more than €0.75/year without your say-so". Empty when the sticker is // unknown — the generic sentence beside it still carries the promise. export function capNote(eur, locale) { if (!eur) return ""; const cap = eur * 0.15; const cents = Math.round(cap * 100) % 100 !== 0 ? 2 : 0; const fmt = new Intl.NumberFormat(locale || undefined, { style: "currency", currency: "EUR", minimumFractionDigits: cents, maximumFractionDigits: cents, }); return "never more than " + fmt.format(cap) + " more at renewal without your say-so"; }