eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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").
13export 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
24export 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.
31export 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.
39export 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.
50const 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
59export 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).
72export 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.
93export 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