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>
10 files changed,
+558
−12
home/fx.go+108 −0home/fx_test.go+90 −0home/main.go+10 −0home/provision.go+4 −0home/provision_test.go+5 −1home/test/fx.test.mjs+73 −0home/web/pages/index.html+28 −1home/web/static/fx.js+102 −0home/web/static/home.js+137 −9package.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" |
| } |
| } |