eleven / ocho Public

home: the honor-band picker, band deep links, and the local-currency ≈ display (Phase 0) (#1076)

The home half of docs/pricing-bands.md Phase 0. Renders exactly as today
until the provisioner's price book carries bands:

- Picker: a band row above the tiers (Supporter/Standard/Reduced/Solidarity,
  only the bands the book offers, Standard pre-selected), each tier repriced
  to the selected band; tiers without the band sell at their standard price
  and say so plainly. The selected band's one-liner + "no questions asked",
  Business stickers marked excl. VAT, and the +15% annual-review promise in
  euros (never a bare percent).
- Deep links: #band=reduced / ?band=reduced (and riding an #upgrade fragment)
  pre-select a band — "click a link, get the lower price, instantly". The
  server honours whatever band arrives; the link only pre-selects.
- ≈ display: home fetches the ECB daily reference rates (fx.go — the browser
  never talks to a third party; last-good snapshot survives feed failures;
  GET /api/fx serves the cache) and the picker shows "≈ $5.50" beside each
  price, currency from the browser locale (never IP) with a session-only
  override. Billing stays EUR; the figure is a labelled estimate.
- Checkout relay forwards the band verbatim; the provisioner's book validates.

Pure helpers live in fx.js (band vocabulary + messaging lines, tier-at-band
resolution, locale→currency, formatting) — node-tested in home/test/fx.test.mjs.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1a6d1683b6455e6daea602c5c147c92cbe5ea6b0 parent e8b5f76
10 files changed, +558 −12
  • home/fx.go +108 −0
  • home/fx_test.go +90 −0
  • home/main.go +10 −0
  • home/provision.go +4 −0
  • home/provision_test.go +5 −1
  • home/test/fx.test.mjs +73 −0
  • home/web/pages/index.html +28 −1
  • home/web/static/fx.js +102 −0
  • home/web/static/home.js +137 −9
  • package.json +1 −1
diff --git a/home/fx.go b/home/fx.go
new file mode 100644
index 0000000..464e7e2
--- /dev/null
+++ b/home/fx.go
@@ -0,0 +1,108 @@
+package main
+
+// ECB reference rates for the pricing picker's local-currency ≈ display
+// (docs/pricing-bands.md § Local-currency display). Billing stays EUR; the
+// local figure is a labelled estimate. This file is the whole third-party
+// surface: home fetches the ECB's daily XML (free, no key, ~30 currencies)
+// on a timer and serves a tiny cached JSON — the BROWSER never talks to a
+// third party (its CSP stays pinned to this origin).
+//
+// Availability beats freshness: a fetch failure keeps the last-good snapshot
+// (reference rates move ~daily; a stale ≈ is still honest, and the label
+// says "at today's rate" of the snapshot's own date). Before the first
+// successful fetch the endpoint serves an empty rate set and the client
+// simply shows no ≈ figure.
+
+import (
+ "encoding/xml"
+ "io"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+)
+
+const ecbRatesURL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
+
+// fxCache is the last-good snapshot. Zero value ready (empty until fetched).
+type fxCache struct {
+ mu sync.Mutex
+ date string // the ECB's own quote date, e.g. "2026-07-17"
+ rates map[string]float64 // currency code → units per EUR
+}
+
+// ecbEnvelope is just enough of the ECB feed's XML to read the rate cubes.
+type ecbEnvelope struct {
+ Cube struct {
+ Day struct {
+ Time string `xml:"time,attr"`
+ Rates []struct {
+ Currency string `xml:"currency,attr"`
+ Rate float64 `xml:"rate,attr"`
+ } `xml:"Cube"`
+ } `xml:"Cube"`
+ } `xml:"Cube"`
+}
+
+// refresh fetches the feed once, replacing the snapshot only on success.
+func (c *fxCache) refresh(client *http.Client, feedURL string) error {
+ resp, err := client.Get(feedURL)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return errHTTP(resp.StatusCode)
+ }
+ var env ecbEnvelope
+ if err := xml.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&env); err != nil {
+ return err
+ }
+ rates := map[string]float64{}
+ for _, r := range env.Cube.Day.Rates {
+ if r.Currency != "" && r.Rate > 0 {
+ rates[r.Currency] = r.Rate
+ }
+ }
+ if len(rates) == 0 {
+ return errEmptyFeed
+ }
+ c.mu.Lock()
+ c.date, c.rates = env.Cube.Day.Time, rates
+ c.mu.Unlock()
+ return nil
+}
+
+type fxErr string
+
+func (e fxErr) Error() string { return string(e) }
+
+const errEmptyFeed = fxErr("fx: feed parsed but held no rates")
+
+func errHTTP(code int) error { return fxErr("fx: feed answered HTTP " + http.StatusText(code)) }
+
+// fxLoop refreshes the snapshot at boot and then twice a day (the feed
+// updates once each business day; a missed pass self-heals on the next).
+func (s *Server) fxLoop(feedURL string) {
+ client := &http.Client{Timeout: 30 * time.Second}
+ for {
+ if err := s.fx.refresh(client, feedURL); err != nil {
+ log.Printf("fx: refresh: %v (serving the last-good snapshot)", err)
+ }
+ time.Sleep(12 * time.Hour)
+ }
+}
+
+// fxHandler: GET /api/fx — the cached snapshot, verbatim. Public: reference
+// rates are public data and the response identifies nobody.
+func (s *Server) fxHandler(w http.ResponseWriter, r *http.Request) {
+ s.fx.mu.Lock()
+ date, rates := s.fx.date, s.fx.rates
+ s.fx.mu.Unlock()
+ if rates == nil {
+ rates = map[string]float64{}
+ }
+ // A day's cache: clients re-check on load; the snapshot moves ~daily.
+ w.Header().Set("Cache-Control", "public, max-age=21600")
+ writeJSON(w, http.StatusOK, map[string]any{"base": "EUR", "date": date, "rates": rates})
+}
diff --git a/home/fx_test.go b/home/fx_test.go
new file mode 100644
index 0000000..a3df76a
--- /dev/null
+++ b/home/fx_test.go
@@ -0,0 +1,90 @@
+package main
+
+// The ≈ display's rate snapshot (fx.go): fetch, parse, last-good fallback,
+// and the empty-until-fetched public shape.
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+const ecbSample = `<?xml version="1.0" encoding="UTF-8"?>
+<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
+ <gesmes:subject>Reference rates</gesmes:subject>
+ <Cube>
+ <Cube time="2026-07-17">
+ <Cube currency="USD" rate="1.0876"/>
+ <Cube currency="GBP" rate="0.8433"/>
+ <Cube currency="JPY" rate="170.61"/>
+ </Cube>
+ </Cube>
+</gesmes:Envelope>`
+
+func TestFXRefreshAndServe(t *testing.T) {
+ feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(ecbSample))
+ }))
+ defer feed.Close()
+ ts, s := newTestServer(t)
+
+ get := func() (string, map[string]float64) {
+ t.Helper()
+ resp, err := http.Get(ts.URL + "/api/fx")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("fx: HTTP %d", resp.StatusCode)
+ }
+ var body struct {
+ Base string `json:"base"`
+ Date string `json:"date"`
+ Rates map[string]float64 `json:"rates"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Base != "EUR" {
+ t.Fatalf("base = %q", body.Base)
+ }
+ return body.Date, body.Rates
+ }
+
+ // Before any fetch: an empty snapshot, still a clean 200 (the client just
+ // shows no ≈ figure).
+ if date, rates := get(); date != "" || len(rates) != 0 {
+ t.Fatalf("pre-fetch snapshot = %q %v, want empty", date, rates)
+ }
+
+ if err := s.fx.refresh(http.DefaultClient, feed.URL); err != nil {
+ t.Fatal(err)
+ }
+ date, rates := get()
+ if date != "2026-07-17" || rates["USD"] != 1.0876 || rates["GBP"] != 0.8433 || len(rates) != 3 {
+ t.Fatalf("snapshot = %q %v", date, rates)
+ }
+
+ // A failing feed keeps the last-good snapshot.
+ feed.Close()
+ if err := s.fx.refresh(http.DefaultClient, feed.URL); err == nil {
+ t.Fatal("refresh against a dead feed reported success")
+ }
+ if date, rates := get(); date != "2026-07-17" || len(rates) != 3 {
+ t.Fatalf("post-failure snapshot = %q %v, want last-good kept", date, rates)
+ }
+
+ // So does a feed that answers but parses to nothing.
+ empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(`<Envelope><Cube><Cube time="2026-07-18"></Cube></Cube></Envelope>`))
+ }))
+ defer empty.Close()
+ if err := s.fx.refresh(http.DefaultClient, empty.URL); err == nil {
+ t.Fatal("empty feed reported success")
+ }
+ if date, _ := get(); date != "2026-07-17" {
+ t.Fatalf("empty feed replaced the snapshot (date %q)", date)
+ }
+}
diff --git a/home/main.go b/home/main.go
index 81f48b6..617302d 100644
--- a/home/main.go
+++ b/home/main.go
@@ -58,6 +58,9 @@ type Server struct {
// demo is the account-free demo-space surface and its abuse gates
// (demo.go); nil = demos not offered here (LCHAT_HOME_DEMO unset).
demo *demoGate
+ // fx caches the ECB reference rates behind the picker's ≈ display
+ // (fx.go) — zero value ready (empty until the loop's first fetch).
+ fx fxCache
// demoStatus caches the countdown banner's live status reads (demo.go) —
// zero value ready.
demoStatus demoStatusCache
@@ -129,6 +132,11 @@ func main() {
if s.infrastructureDir = os.Getenv("LCHAT_HOME_INFRASTRUCTURE_DIR"); s.infrastructureDir != "" {
log.Printf("planet-scale guide served from %s at /infrastructure/", s.infrastructureDir)
}
+ // The ≈ local-currency display's rate snapshot (fx.go) — only worth
+ // fetching where a price picker exists, i.e. where provisioning is wired.
+ if s.cloud != nil {
+ go s.fxLoop(envOr("LCHAT_HOME_FX_URL", ecbRatesURL))
+ }
log.Printf("home listening on %s (store: %s, origin: %s)", *addr, storeDesc, *origin)
if err := serve(*addr, s.handler()); err != nil {
log.Fatal(err)
@@ -327,6 +335,8 @@ func (s *Server) handler() http.Handler {
// Upgrading a space (a Stripe Checkout round trip) is additive, so it takes
// the link token; rate-limited because each call mints a checkout session.
mux.HandleFunc("POST /api/spaces/{host}/checkout", s.rateLimited(s.checkoutSpaceHandler))
+ // The picker's ≈ local-currency rates (fx.go) — public, cached, tiny.
+ mux.HandleFunc("GET /api/fx", s.fxHandler)
// Managing an existing subscription (Stripe's hosted Billing Portal).
mux.HandleFunc("POST /api/spaces/{host}/portal", s.rateLimited(s.portalSpaceHandler))
diff --git a/home/provision.go b/home/provision.go
index 063f6fc..f5ceb40 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -227,6 +227,7 @@ func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) {
}
var req struct {
Tier string `json:"tier"`
+ Band string `json:"band"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil || req.Tier == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{
@@ -235,9 +236,12 @@ func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) {
return
}
host := r.PathValue("host")
+ // The honor band (docs/pricing-bands.md) rides through verbatim; the
+ // provisioner's price book is the validator. Empty = base, downstream.
payload, _ := json.Marshal(map[string]string{
"requester": uid,
"tier": req.Tier,
+ "band": req.Band,
"success_url": s.origin + "/?upgraded=" + url.QueryEscape(host),
"cancel_url": s.origin + "/",
})
diff --git a/home/provision_test.go b/home/provision_test.go
index 7624150..d65080f 100644
--- a/home/provision_test.go
+++ b/home/provision_test.go
@@ -223,7 +223,7 @@ func TestCheckoutSpaceProxies(t *testing.T) {
post := func(tok string) *http.Response {
req, _ := http.NewRequest("POST", ts.URL+"/api/spaces/a4f.example.com/checkout",
- strings.NewReader(`{"tier":"friends"}`))
+ strings.NewReader(`{"tier":"friends","band":"reduced"}`))
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
@@ -271,6 +271,10 @@ func TestCheckoutSpaceProxies(t *testing.T) {
if sent["tier"] != "friends" {
t.Errorf("tier = %q, want friends", sent["tier"])
}
+ // The honor band rides through verbatim — the provisioner's book validates.
+ if sent["band"] != "reduced" {
+ t.Errorf("band = %q, want reduced", sent["band"])
+ }
// A missing tier is rejected before the provisioner is ever called.
noTier, _ := http.NewRequest("POST", ts.URL+"/api/spaces/a4f.example.com/checkout", nil)
noTier.Header.Set("Authorization", "Bearer "+token)
diff --git a/home/test/fx.test.mjs b/home/test/fx.test.mjs
new file mode 100644
index 0000000..dc3f623
--- /dev/null
+++ b/home/test/fx.test.mjs
@@ -0,0 +1,73 @@
+// Tests for the pricing picker's pure helpers. node home/test/fx.test.mjs
+import assert from "node:assert/strict";
+import { BANDS, bandName, bandsOffered, tierForBand, regionCurrency, approxLocal, capNote } from "../web/static/fx.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const tiers = [
+ { key: "family", name: "Family", label: "€5/year", eur: 5 },
+ { key: "friends", name: "Friends", label: "€4/month", eur: 4,
+ bands: { reduced: { label: "€2/month", eur: 2 }, supporter: { label: "€5.50/month", eur: 5.5 } } },
+];
+
+await test("band vocabulary is the doc's four, in display order", () => {
+ assert.deepEqual(BANDS.map((b) => b.key), ["supporter", "base", "reduced", "solidarity"]);
+ assert.equal(bandName("base"), "Standard");
+ // The messaging rules: aggregate cross-subsidy only, never "discount"/"eligible".
+ for (const b of BANDS) {
+ assert.ok(!/discount|eligible/i.test(b.line), b.key + " line breaks the messaging rules");
+ }
+});
+
+await test("bandsOffered: base always; others once any tier defines them", () => {
+ assert.deepEqual(bandsOffered([tiers[0]]), ["base"]);
+ assert.deepEqual(bandsOffered(tiers), ["supporter", "base", "reduced"]);
+ assert.deepEqual(bandsOffered([]), ["base"]);
+});
+
+await test("tierForBand: band label when offered, base flagged when not", () => {
+ assert.deepEqual(tierForBand(tiers[1], "reduced"), { label: "€2/month", eur: 2, band: "reduced", offered: true });
+ assert.deepEqual(tierForBand(tiers[1], "base"), { label: "€4/month", eur: 4, band: "base", offered: true });
+ // family has no bands: a reduced selection falls back to base, UNOFFERED —
+ // the UI says "standard price" instead of implying a reduction.
+ assert.deepEqual(tierForBand(tiers[0], "reduced"), { label: "€5/year", eur: 5, band: "base", offered: false });
+});
+
+await test("regionCurrency: locale region → ECB currency; euro area and unknown → none", () => {
+ assert.equal(regionCurrency("en-US"), "USD");
+ assert.equal(regionCurrency("en-GB"), "GBP");
+ assert.equal(regionCurrency("ja"), "JPY"); // maximize() infers the region
+ assert.equal(regionCurrency("de-DE"), ""); // euro area: billed in EUR already
+ assert.equal(regionCurrency("fr"), "");
+ assert.equal(regionCurrency(""), "");
+ assert.equal(regionCurrency("not a locale"), "");
+});
+
+await test("approxLocal: converts and formats; silent when it can't be honest", () => {
+ const rates = { USD: 1.1, JPY: 170 };
+ assert.equal(approxLocal(5, "USD", rates, "en"), "≈ $5.50");
+ // Larger amounts drop the cents (an estimate pretending precision reads wrong).
+ assert.equal(approxLocal(999, "USD", rates, "en"), "≈ $1,099");
+ // Unknown currency / missing rate / zero amount → no figure at all.
+ assert.equal(approxLocal(5, "CHF", rates, "en"), "");
+ assert.equal(approxLocal(0, "USD", rates, "en"), "");
+ assert.equal(approxLocal(5, "", rates, "en"), "");
+ assert.equal(approxLocal(5, "USD", null, "en"), "");
+});
+
+await test("capNote: the 15% promise in euros, not percent", () => {
+ assert.equal(capNote(5, "en"), "never more than €0.75 more at renewal without your say-so");
+ assert.equal(capNote(0, "en"), "");
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 9a04361..6b27dc9 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -239,7 +239,27 @@
.tier-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tier-btn:disabled { opacity: .5; cursor: default; }
.tier-btn .tier-name { font-weight: 700; }
- .tier-btn .tier-price { color: var(--muted); font-size: .9rem; }
+ .tier-btn .tier-price { color: var(--muted); font-size: .9rem; font-variant-numeric: tabular-nums; }
+ .tier-btn .tier-approx { color: var(--muted); font-size: .78rem; font-variant-numeric: tabular-nums; }
+ .tier-btn .tier-band-std {
+ font-size: .62rem; font-weight: 600; color: var(--muted);
+ padding: 1px 8px; border: 1px solid var(--border); border-radius: 999px;
+ }
+ /* The honor-band row: four open prices, one selected — selection is shown
+ by weight + border, never colour alone. */
+ .band-row { display: flex; justify-content: center; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
+ .band-chip {
+ font: inherit; font-size: .82rem; cursor: pointer; padding: 5px 12px;
+ min-height: 28px; border-radius: 999px;
+ color: var(--ink); background: none; border: 1px solid var(--border);
+ }
+ .band-chip.selected { border-color: var(--accent); border-width: 2px; font-weight: 700; padding: 4px 11px; }
+ .band-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+ .band-note, .fx-note, .cap-note { color: var(--muted); font-size: .8rem; margin: 8px 0 0; }
+ .fx-note .fx-currency {
+ font: inherit; font-size: .8rem; color: var(--ink);
+ background: var(--quiet); border: 1px solid var(--border); border-radius: 6px; padding: 1px 4px;
+ }
/* The tier badge on an upgraded space's row, with Manage beside it. */
.badge-row { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; margin-top: 2px; }
.row-manage {
@@ -515,7 +535,14 @@
<h2 class="create-title">Upgrade <span id="upgrade-host"></span></h2>
<p class="create-sub">An upgraded space doesn't count toward your space limit.
You'll pay on Stripe's secure page — we never see your card, name, or email.</p>
+ <!-- The honor-band row (docs/pricing-bands.md): four open prices, any
+ selectable, no questions asked. Hidden while the price book has no
+ bands; home.js fills it. -->
+ <div id="upgrade-bands" class="band-row hidden" role="group" aria-label="Choose your price"></div>
+ <p id="upgrade-band-note" class="band-note"></p>
<div id="upgrade-tiers" role="group" aria-label="Choose a plan"></div>
+ <p id="upgrade-fx" class="fx-note hidden"></p>
+ <p id="upgrade-cap" class="cap-note"></p>
<p class="status" id="upgrade-status"></p>
</div>
diff --git a/home/web/static/fx.js b/home/web/static/fx.js
new file mode 100644
index 0000000..1ccd303
--- /dev/null
+++ b/home/web/static/fx.js
@@ -0,0 +1,102 @@
+// 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";
+}
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 6fae43e..7179b7e 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -29,6 +29,7 @@ import {
} from "/static/serverlist.js";
import { mountFader } from "/static/fader.js";
import { startHomeLoader, finishHomeLoader } from "/static/bootscene.js";
+import { BANDS, bandName, bandsOffered, tierForBand, regionCurrency, approxLocal, capNote } from "/static/fx.js";
// ---- session state ----------------------------------------------------------
// Session-only on purpose (the design doc's web default): closing the tab
@@ -99,7 +100,18 @@ let upgradedHost = new URLSearchParams(location.search).get("upgraded") || "";
if (upgradedHost) history.replaceState(null, "", location.pathname + location.hash);
// A space's own menu deep-links here as #upgrade=<host>; consumed once the
// provisioner ledger is in hand (before sign-in it just waits in the fragment).
-let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase();
+// An optional &band=<band> rides along (the space menu doesn't send one, but
+// shared links may).
+let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)(?:&band=[a-z]+)?$/) || [])[1] || "").toLowerCase();
+// A shared link pre-selects an honor band (docs/pricing-bands.md § The
+// picker — "click a link, get the lower price, instantly"): #band=reduced,
+// ?band=reduced, or riding an #upgrade fragment. The server honours whatever
+// band arrives; this only pre-selects the picker. Unknown names are ignored.
+let bandLink = (() => {
+ const m = location.hash.match(/[#&]band=([a-z]+)/) || location.search.match(/[?&]band=([a-z]+)/);
+ const b = m ? m[1] : "";
+ return BANDS.some((x) => x.key === b) ? b : "";
+})();
// A demo space's countdown banner deep-links here as #claim=<host>; waits in
// the fragment through sign-in — a demo needs no account, so its claimant
// often has to create their sign-in first, and the claim resumes after.
@@ -724,6 +736,13 @@ function tierName(key) {
return (t && t.name) || "Supporter";
}
+// The picker's band + ≈ state. The band resets to base (or a deep link) per
+// open; the currency override is session-only — nothing about the buyer is
+// stored, matching the honor design (docs/pricing-bands.md).
+let selectedBand = "base";
+let approxCurrency = ""; // "" = follow the browser locale; "EUR" = hide ≈
+let fxSnapshot = null; // {date, rates} once /api/fx answers; null before
+
// openUpgrade shows the plan picker for one space. The payment itself is
// Stripe's hosted page; this screen only chooses the plan and hands off.
function openUpgrade(s) {
@@ -731,9 +750,62 @@ function openUpgrade(s) {
$("upgrade").classList.remove("hidden");
$("upgrade-host").textContent = hostOf(s.url);
upgradeStatus("");
+ if (bandLink) {
+ selectedBand = bandLink;
+ bandLink = "";
+ history.replaceState(null, "", location.pathname + location.search);
+ }
+ // The ≈ rates: one fetch per page life, rendered in when they land. The
+ // picker is complete without them — an absent estimate beats a wrong one.
+ if (!fxSnapshot) {
+ fxSnapshot = { date: "", rates: null };
+ apiJSON("/api/fx")
+ .then((b) => {
+ fxSnapshot = { date: b.date || "", rates: b.rates || null };
+ if (!$("upgrade").classList.contains("hidden")) renderUpgrade(s);
+ })
+ .catch(() => { /* no rates — the EUR stickers stand alone */ });
+ }
+ renderUpgrade(s);
+}
+
+// renderUpgrade paints the whole picker for the selected band: the band row
+// (only when the price book actually offers bands), each tier at that band's
+// price with its ≈ local figure, the honor line, and the +15% review promise.
+function renderUpgrade(s) {
+ const offered = bandsOffered(billingTiers);
+ const banded = offered.length > 1;
+ if (!offered.includes(selectedBand)) selectedBand = "base";
+
+ const row = $("upgrade-bands");
+ row.textContent = "";
+ row.classList.toggle("hidden", !banded);
+ if (banded) {
+ for (const key of offered) {
+ const chip = document.createElement("button");
+ chip.type = "button";
+ chip.className = "band-chip" + (key === selectedBand ? " selected" : "");
+ chip.setAttribute("aria-pressed", key === selectedBand ? "true" : "false");
+ chip.textContent = bandName(key);
+ chip.addEventListener("click", () => {
+ selectedBand = key;
+ renderUpgrade(s);
+ });
+ row.append(chip);
+ }
+ }
+ const note = $("upgrade-band-note");
+ note.textContent = banded
+ ? BANDS.find((b) => b.key === selectedBand).line + " Pick the price that's right for you — no questions asked."
+ : "";
+
+ const currency = approxCurrency || regionCurrency(navigator.language);
+ const rates = fxSnapshot && fxSnapshot.rates;
const box = $("upgrade-tiers");
box.textContent = "";
+ let anyApprox = false;
for (const t of billingTiers) {
+ const view = tierForBand(t, selectedBand);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tier-btn";
@@ -742,11 +814,67 @@ function openUpgrade(s) {
name.textContent = t.name;
const price = document.createElement("span");
price.className = "tier-price";
- price.textContent = t.label;
+ // Business stickers are ex-VAT wherever they appear (docs/pricing-bands.md § VAT).
+ price.textContent = view.label + (t.business ? " excl. VAT" : "");
btn.append(name, price);
- btn.addEventListener("click", () => startUpgrade(hostOf(s.url), t.key, box));
+ const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language);
+ if (approx) {
+ anyApprox = true;
+ const a = document.createElement("span");
+ a.className = "tier-approx";
+ a.textContent = approx;
+ btn.append(a);
+ }
+ // A tier without the selected band sells at its standard price — said
+ // plainly, never implied to be reduced.
+ if (banded && !view.offered) {
+ const std = document.createElement("span");
+ std.className = "tier-band-std";
+ std.textContent = "standard price";
+ btn.append(std);
+ }
+ btn.addEventListener("click", () => startUpgrade(hostOf(s.url), t.key, view.band, box));
box.append(btn);
}
+
+ // The ≈ attribution + currency override. Shown whenever rates exist and a
+ // display currency is in play — including "EUR" (≈ hidden), or picking EUR
+ // would make the override itself vanish with no way back. Euro-area locales
+ // with no override see none of this: they're billed in their own currency.
+ const fxNote = $("upgrade-fx");
+ fxNote.textContent = "";
+ const showFx = !!rates && !!currency;
+ fxNote.classList.toggle("hidden", !showFx);
+ if (showFx) {
+ const span = document.createElement("span");
+ span.textContent = anyApprox ? "Billed in euros — ≈ is an estimate in " : "Billed in euros — show ≈ in ";
+ const sel = document.createElement("select");
+ sel.className = "fx-currency";
+ sel.setAttribute("aria-label", "Currency for the approximate figures");
+ for (const c of [...Object.keys(rates), "EUR"].sort()) {
+ const opt = document.createElement("option");
+ opt.value = c;
+ opt.textContent = c;
+ if (c === currency) opt.selected = true;
+ sel.append(opt);
+ }
+ sel.addEventListener("change", () => {
+ approxCurrency = sel.value;
+ renderUpgrade(s);
+ });
+ const tail = document.createElement("span");
+ tail.textContent = anyApprox && fxSnapshot.date ? " at the ECB rate of " + fxSnapshot.date + "." : ".";
+ fxNote.append(span, sel, tail);
+ }
+
+ // The +15% annual-review promise, in money (never a bare percent): the
+ // first tier at the selected band is the concrete example.
+ const cap = $("upgrade-cap");
+ const first = billingTiers[0] && tierForBand(billingTiers[0], selectedBand);
+ const example = first && first.eur ? capNote(first.eur, navigator.language) : "";
+ cap.textContent = "Every price is reviewed once a year" +
+ (example ? " — " + billingTiers[0].name + ": " + example : "") +
+ ". Rises above 15% only ever happen with your explicit yes; decreases happen by themselves.";
}
function closeUpgrade() {
@@ -754,18 +882,18 @@ function closeUpgrade() {
showVault();
}
-// startUpgrade begins the checkout round trip for the chosen plan: home mints a
-// Stripe Checkout link (ownership enforced by the provisioner's ledger) and we
-// follow it in the same tab — Stripe brings you back here with ?upgraded=<host>
-// when it's done, and Cancel comes straight back too.
-async function startUpgrade(host, tier, box) {
+// startUpgrade begins the checkout round trip for the chosen plan + band:
+// home mints a Stripe Checkout link (ownership enforced by the provisioner's
+// ledger) and we follow it in the same tab — Stripe brings you back here with
+// ?upgraded=<host> when it's done, and Cancel comes straight back too.
+async function startUpgrade(host, tier, band, box) {
for (const b of box.querySelectorAll("button")) b.disabled = true;
upgradeStatus("Opening checkout…", "busy");
try {
const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/checkout", {
method: "POST",
headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
- body: JSON.stringify({ tier }),
+ body: JSON.stringify({ tier, band }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.message || body.error || `Couldn't start the upgrade (HTTP ${res.status}).`);
diff --git a/package.json b/package.json
index 36bd724..6fd89da 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs"
}
}