server+web: plan choice moves into Eleven's conversation; create is never pay-gated (#1374) (#1379)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
14 files changed,
+760
−40
changelog/2026-07-28-plan-in-conversation.md+9 −0cloud.go+15 −0cloud_test.go+53 −0docs/cloud-provisioner.md+9 −0docs/conversational-onboarding.md+8 −2home/main.go+6 −0home/pricebook.go+74 −0home/pricebook_test.go+78 −0home/web/pages/index.html+8 −8home/web/static/home.js+56 −18ios/LChatCore/Sources/LChatCore/Flows/setup.json+162 −1test/flow.test.mjs+99 −2web/static/apps/setup.js+68 −8web/static/setup.flow.js+115 −1
diff --git a/changelog/2026-07-28-plan-in-conversation.md b/changelog/2026-07-28-plan-in-conversation.md| new file mode 100644 |
| index 0000000..bfc605a |
| --- /dev/null |
| +++ b/changelog/2026-07-28-plan-in-conversation.md |
| @@ -0,0 +1,9 @@ |
| +๐ค Creating a space is back to two beats: name it, slide โ and it exists, |
| +immediately, every time. Choosing how it's paid for now happens where it |
| +belongs: in your first conversation with Eleven, which asks "who is this |
| +space for?" and shows the real prices for a team, a community, or family. |
| +Pick one and the payment waits as a tap on the finish line โ your space runs |
| +free until you take it. Keep it free and that's a first-class answer too. |
| +The create screen only ever shows plans when paying is genuinely the way |
| +through (you've used your free space, or the free pool is full for the |
| +moment). |
diff --git a/cloud.go b/cloud.go| index e495ef3..b7e8e8a 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -151,6 +151,9 @@ func (s *cloudServer) handler() http.Handler { |
| // The provisioning surface: bearer caller token (a home server). |
| mux.HandleFunc("POST /api/instances", s.callerAuth(s.createSpaceHandler)) |
| mux.HandleFunc("GET /api/instances", s.callerAuth(s.listSpacesHandler)) |
| + // The price book alone, without a requester's ledger โ home republishes it |
| + // unauthenticated for the setup conversation's plan step (#1374). |
| + mux.HandleFunc("GET /api/pricebook", s.callerAuth(s.pricebookHandler)) |
| mux.HandleFunc("DELETE /api/instances/{host}", s.callerAuth(s.deleteSpaceHandler)) |
| // Billing (optional โ 501 unless Stripe is configured; see billing.go). The |
| // webhook is signature-gated rather than token-gated: Stripe calls it. |
| @@ -532,6 +535,18 @@ func (s *cloudServer) listSpacesHandler(w http.ResponseWriter, r *http.Request) |
| writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces, "billing": billing}) |
| } |
| +// pricebookHandler: GET /api/pricebook โ the public price book, same shape as |
| +// the listing's billing key ({tiers} or false when billing is off). It exists |
| +// so home can serve the book WITHOUT a signed-in requester: the conversational |
| +// plan step (#1374) runs on the space origin, which holds no home session. |
| +func (s *cloudServer) pricebookHandler(w http.ResponseWriter, r *http.Request) { |
| + billing := any(false) |
| + if s.billing != nil { |
| + billing = map[string]any{"tiers": s.billing.publicTiers()} |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"billing": billing}) |
| +} |
| + |
| // ---- admin: caller tokens --------------------------------------------------- |
| func (s *cloudServer) mintTokenHandler(w http.ResponseWriter, r *http.Request) { |
diff --git a/cloud_test.go b/cloud_test.go| index d0b3148..44c3ca4 100644 |
| --- a/cloud_test.go |
| +++ b/cloud_test.go |
| @@ -702,3 +702,56 @@ func TestCloudCreatePassesName(t *testing.T) { |
| t.Errorf("61-char name: %d, want 400", r.StatusCode) |
| } |
| } |
| + |
| +// The public price book endpoint (#1374): the book alone, caller-token gated, |
| +// billing:false when Stripe isn't configured. |
| +func TestPricebookHandler(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) |
| + tok := mintCaller(t, srv, "home") |
| + |
| + get := func() (int, map[string]any) { |
| + req, _ := http.NewRequest("GET", srv.URL+"/api/pricebook", nil) |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var m map[string]any |
| + json.NewDecoder(resp.Body).Decode(&m) |
| + return resp.StatusCode, m |
| + } |
| + |
| + // Billing off: the same "no Upgrade UI" signal the listing sends. |
| + status, body := get() |
| + if status != http.StatusOK { |
| + t.Fatalf("status %d", status) |
| + } |
| + if b, ok := body["billing"].(bool); !ok || b { |
| + t.Fatalf("billing = %v, want false with Stripe unconfigured", body["billing"]) |
| + } |
| + |
| + // Billing on: the tiers ride out in publicTiers shape. |
| + s.billing = &stripeBilling{tiers: []billingTier{{Key: "family", Name: "Family", Label: "โฌ5.50 a year", Price: "price_f"}}} |
| + _, body = get() |
| + b, ok := body["billing"].(map[string]any) |
| + if !ok { |
| + t.Fatalf("billing = %v, want the tier map", body["billing"]) |
| + } |
| + tiers, _ := b["tiers"].([]any) |
| + if len(tiers) != 1 || tiers[0].(map[string]any)["key"] != "family" { |
| + t.Fatalf("tiers = %v, want the configured family tier", tiers) |
| + } |
| + |
| + // No caller token โ the gate holds (this is not a public endpoint here; |
| + // HOME republishes it publicly). |
| + req, _ := http.NewRequest("GET", srv.URL+"/api/pricebook", nil) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("no-token status %d, want 401", resp.StatusCode) |
| + } |
| +} |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index ea1742a..4a7b744 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -540,6 +540,15 @@ deletion drops the space back to `free`). The row's numeric ledger id rides in |
| the session's `client_reference_id` and the subscription's metadata, the tier |
| key in both metadata blobs โ that's how events route back to a space. |
| +Creating a space itself is **never pay-gated** (#1374): the create step is |
| +just the name, and "who is this space for?" is asked by Eleven inside the |
| +space, priced from the cloud's `GET /api/pricebook` (caller-token'd; home |
| +republishes it unauthenticated and CORS-open at its own `/api/pricebook`, |
| +cached, for space origins to read). The create screen's tier cards appear |
| +only when a create bounces off the free quota or the pool gate โ the two |
| +errors paying genuinely fixes; picking one there runs the pay-first |
| +checkout-create path below. |
| + |
| **What paying does today:** |
| - a paid space **doesn't count toward the creation quota** (`allocateSpace` |
diff --git a/docs/conversational-onboarding.md b/docs/conversational-onboarding.md| index 7d6cbe4..65ce046 100644 |
| --- a/docs/conversational-onboarding.md |
| +++ b/docs/conversational-onboarding.md |
| @@ -144,8 +144,14 @@ role-based auth): |
| hosted instances it takes effect; on self-hosted it's a preference the |
| sidecar respects when one exists. |
| 6. **Can people invite other people?** โ `user_invites` (exists). |
| -7. **The key ceremony** (next section) โ deliberately last, so it's the note |
| - the setup ends on. |
| +7. **Who is this space for?** (#1374) โ the plan question, moved here from the |
| + create flow so creating is never pay-gated. Web-only, and only when home's |
| + public `/api/pricebook` answers with tiers (native wrappers and self-hosted |
| + spaces never see it); a paid answer becomes a "Set up your plan" chip on |
| + the finish line that hands off to home's upgrade picker. The space runs |
| + free until that tap. |
| +8. **The key ceremony** (next section) โ deliberately near-last, so it's the |
| + note the setup ends on. |
| On completion the script sets `setup_complete` and hands over: "you can change |
| any of this later โ I've put an **Admin** app in your sidebar." |
diff --git a/home/main.go b/home/main.go| index 6f90a9c..44a2adc 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -89,6 +89,9 @@ type Server struct { |
| // 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 |
| + // pricebook caches the provisioner's public price book for the |
| + // unauthenticated /api/pricebook (pricebook.go) โ zero value ready. |
| + pricebook pricebookCache |
| // demoStatus caches the countdown banner's live status reads (demo.go) โ |
| // zero value ready. |
| demoStatus demoStatusCache |
| @@ -456,6 +459,9 @@ func (s *Server) handler() http.Handler { |
| // budget with anyone (the write side is rate-limited like the begins). |
| mux.HandleFunc("POST /api/enroll-done", s.rateLimited(s.enrollDoneHandler)) |
| mux.HandleFunc("GET /api/enroll-status", s.enrollStatusHandler) |
| + // The public price book (pricebook.go): read by SPACE origins' browser JS |
| + // (the setup conversation's plan step, #1374) โ no auth, CORS-open. |
| + mux.HandleFunc("GET /api/pricebook", s.pricebookHandler) |
| // The blob: read/write with a link token; delete only with a fresh ceremony. |
| mux.HandleFunc("GET /api/blob", s.getBlobHandler) |
diff --git a/home/pricebook.go b/home/pricebook.go| new file mode 100644 |
| index 0000000..488069d |
| --- /dev/null |
| +++ b/home/pricebook.go |
| @@ -0,0 +1,74 @@ |
| +package main |
| + |
| +// The public price book (#1374). The setup conversation's plan step runs on |
| +// the SPACE origin, which holds no home session โ so the book must be |
| +// readable without auth. Prices are public information (the same figures |
| +// render on this page's own pickers); the payload carries nothing else, and |
| +// an empty book simply means the step doesn't happen. Cached so a burst of |
| +// setups never fans out into the provisioner, and served stale on refresh |
| +// errors so a provisioner blip can't blank a conversation mid-flight. |
| + |
| +import ( |
| + "net/http" |
| + "sync" |
| + "time" |
| +) |
| + |
| +const pricebookTTL = 10 * time.Minute |
| + |
| +type pricebookCache struct { |
| + mu sync.Mutex |
| + tiers []any |
| + fetchedAt time.Time |
| +} |
| + |
| +// pricebookTiers returns the cached book, refreshing from the default |
| +// region's provisioner (any region as fallback) when stale. Never errors: |
| +// no cloud, billing off, or an unreachable provisioner with no prior copy |
| +// all come back as an empty book. |
| +func (s *Server) pricebookTiers() []any { |
| + c := &s.pricebook |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if time.Since(c.fetchedAt) < pricebookTTL { |
| + return c.tiers |
| + } |
| + // Default region first โ it's the book's source of truth (the same |
| + // preference the spaces listing applies to its billing key). |
| + clouds := s.allClouds() |
| + if s.cloud != nil { |
| + ordered := []*cloudProvisioner{s.cloud} |
| + for _, cl := range clouds { |
| + if cl != s.cloud { |
| + ordered = append(ordered, cl) |
| + } |
| + } |
| + clouds = ordered |
| + } |
| + for _, cl := range clouds { |
| + status, body, err := cl.do("GET", "/api/pricebook", nil) |
| + if err != nil || status != http.StatusOK { |
| + continue |
| + } |
| + tiers := []any{} |
| + if b, ok := body["billing"].(map[string]any); ok { |
| + if arr, ok := b["tiers"].([]any); ok { |
| + tiers = arr |
| + } |
| + } |
| + c.tiers, c.fetchedAt = tiers, time.Now() |
| + return c.tiers |
| + } |
| + if c.fetchedAt.IsZero() { |
| + return []any{} // nothing cached and nothing reachable โ an empty book, not an error |
| + } |
| + return c.tiers // stale copy beats a blanked conversation step |
| +} |
| + |
| +// pricebookHandler: GET /api/pricebook โ public and CORS-open (the caller is |
| +// a space origin's browser JS). {tiers:[โฆ]}; empty = billing off. |
| +func (s *Server) pricebookHandler(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Access-Control-Allow-Origin", "*") |
| + w.Header().Set("Cache-Control", "public, max-age=300") |
| + writeJSON(w, http.StatusOK, map[string]any{"tiers": s.pricebookTiers()}) |
| +} |
diff --git a/home/pricebook_test.go b/home/pricebook_test.go| new file mode 100644 |
| index 0000000..79b6bb8 |
| --- /dev/null |
| +++ b/home/pricebook_test.go |
| @@ -0,0 +1,78 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| +) |
| + |
| +// The public price book (#1374): CORS-open pass-through of the provisioner's |
| +// tiers, cached, empty when no provisioner is configured. |
| +func TestPricebookServesTiersCORSOpenAndCached(t *testing.T) { |
| + tiers := []map[string]any{{"key": "family", "name": "Family", "label": "โฌ5.50 a year"}} |
| + cloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ |
| + "billing": map[string]any{"tiers": tiers}, |
| + }) |
| + ts, s := newTestServer(t) |
| + s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| + |
| + resp, err := http.Get(ts.URL + "/api/pricebook") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status %d", resp.StatusCode) |
| + } |
| + // The caller is a SPACE origin's browser JS โ the endpoint must be open. |
| + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { |
| + t.Errorf("Access-Control-Allow-Origin = %q, want *", got) |
| + } |
| + var body struct { |
| + Tiers []map[string]any `json:"tiers"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(body.Tiers) != 1 || body.Tiers[0]["key"] != "family" { |
| + t.Fatalf("tiers = %v, want the fake cloud's family tier", body.Tiers) |
| + } |
| + |
| + // Cached: kill the upstream; the answer must not change within the TTL. |
| + cloud.Close() |
| + resp2, err := http.Get(ts.URL + "/api/pricebook") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp2.Body.Close() |
| + var body2 struct { |
| + Tiers []map[string]any `json:"tiers"` |
| + } |
| + if err := json.NewDecoder(resp2.Body).Decode(&body2); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(body2.Tiers) != 1 { |
| + t.Fatalf("cached tiers = %v, want the earlier copy", body2.Tiers) |
| + } |
| +} |
| + |
| +func TestPricebookEmptyWithoutProvisioner(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + resp, err := http.Get(ts.URL + "/api/pricebook") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status %d", resp.StatusCode) |
| + } |
| + var body struct { |
| + Tiers []map[string]any `json:"tiers"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(body.Tiers) != 0 { |
| + t.Fatalf("tiers = %v, want empty (no provisioner)", body.Tiers) |
| + } |
| +} |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index f91e5a9..3c4b9c8 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -1071,20 +1071,20 @@ |
| <p class="status" id="combine-status"></p> |
| </div> |
| - <!-- Plan a space (create, step 1 of 2): name it and pick who pays. The |
| - slider screen (step 2) then creates a free space immediately, or hands |
| - off to Stripe's checkout and the space is created once the payment |
| - confirms (the claim on return). Tier cards render from the provisioner's |
| - price book; with billing off the picker vanishes and this is just the |
| - name step. --> |
| + <!-- Name a space (create, step 1 of 2). The slider screen (step 2) then |
| + creates the space immediately โ always free, never pay-gated: the plan |
| + question is Eleven's, inside the space (#1374). The tier cards below |
| + appear only when a create bounces off a gate paying genuinely fixes |
| + (the free quota, or the pool under budget pressure); picking one hands |
| + off to Stripe and the space is created the moment it's paid. --> |
| <div id="create-plan" class="hidden screen"> |
| <button id="plan-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button> |
| <h2 class="create-title">Create a space</h2> |
| <label class="create-sub" for="plan-name">What's your space called?</label> |
| <input id="plan-name" type="text" maxlength="60" autocomplete="off" placeholder="e.g. The Campbells"> |
| <div id="plan-tiers-wrap" class="hidden"> |
| - <p class="create-sub" id="plan-tiers-lead">And how is it paid for?</p> |
| - <div id="plan-tiers" role="group" aria-label="How your space is paid for"></div> |
| + <p class="create-sub" id="plan-tiers-lead">Pick a plan to create it now:</p> |
| + <div id="plan-tiers" role="group" aria-label="Pick a plan"></div> |
| <p class="fx-note hidden" id="plan-fx"></p> |
| </div> |
| <div class="actions"> |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 0a08c8b..ed2ea8d 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -149,9 +149,12 @@ let paidCreateRegion = new URLSearchParams(location.search).get("pcregion") || " |
| if (paidCreateSession) 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). |
| -// 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(); |
| +// Optional &band=<band> and &tier=<key> ride along (the space menu doesn't |
| +// send them, but shared links and the setup conversation's plan chip do). |
| +let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)((?:&(?:band=[a-z]+|tier=[a-z0-9_-]+))*)$/) || [])[1] || "").toLowerCase(); |
| +// The conversation's chosen tier (#1374): highlights that card in the picker โ |
| +// a nudge, never a commitment; every card stays one tap from checkout. |
| +let tierLink = ((location.hash.match(/[#&]tier=([a-z0-9_-]+)/) || [])[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 |
| @@ -2599,7 +2602,7 @@ function consumeUpgradeLink() { |
| try { return new URL(sv.url).host.toLowerCase() === target; } catch { return false; } |
| }); |
| const ledger = provisioned && provisioned[target]; |
| - if (s && ledger && ledger.entitlement !== "paid") openUpgrade(s); |
| + if (s && ledger && ledger.entitlement !== "paid") openUpgrade(s, true); |
| } |
| // ---- archive: undo banner + entry point -------------------------------------- |
| @@ -2996,7 +2999,9 @@ 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) { |
| +// fromLink keeps the deep link's &tier= highlight; a manual open drops it. |
| +function openUpgrade(s, fromLink) { |
| + if (!fromLink) tierLink = ""; |
| hideVault(); |
| $("upgrade").classList.remove("hidden"); |
| $("upgrade-host").textContent = hostOf(s.url); |
| @@ -3095,6 +3100,12 @@ function renderUpgrade(s) { |
| std.textContent = "standard price"; |
| btn.append(std); |
| } |
| + // The deep link's chosen tier (the setup conversation's plan chip): |
| + // highlighted, not auto-bought โ checkout stays the user's tap. |
| + if (tierLink && tierLink === t.key) { |
| + btn.classList.add("selected"); |
| + btn.setAttribute("aria-pressed", "true"); |
| + } |
| btn.addEventListener("click", () => startUpgrade(hostOf(s.url), t.key, view.band, box)); |
| box.append(btn); |
| } |
| @@ -3286,7 +3297,11 @@ async function requestSpace() { |
| const reply = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| // Structured errors carry a member-facing message (quota, at-capacity). |
| - throw new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`); |
| + // The code rides along so the fader's error path can tell "paying would |
| + // fix this" (quota, pool pressure) from a plain failure (#1374). |
| + const err = new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`); |
| + err.code = reply.error || ""; |
| + throw err; |
| } |
| const url = reply.url; |
| const host = new URL(url).host; |
| @@ -3542,6 +3557,20 @@ function ensureFader() { |
| }, |
| onReady: (r) => showNewSpaceResult(r.host, r.setupLink), |
| onError: (err) => { |
| + // The two errors paying genuinely fixes โ the free quota and the free |
| + // pool's budget gate โ reopen the plan screen with the paid tiers |
| + // showing (#1374). Everything else stays a plain inline error. |
| + const code = (err && err.code) || ""; |
| + if ((code === "quota_exceeded" || code === "free_pool_full") && billingTiers.length) { |
| + fader?.stop(); |
| + $("create").classList.add("hidden"); |
| + planPaidRequired = true; |
| + planTier = billingTiers[0].key; |
| + renderPlanTiers(); |
| + $("create-plan").classList.remove("hidden"); |
| + planStatus((err && err.message) || "", "error"); |
| + return; |
| + } |
| $("create-back").classList.remove("hidden"); |
| spaceStatus((err && err.message) || String(err), "error"); |
| }, |
| @@ -3553,18 +3582,25 @@ function ensureFader() { |
| // free create or the Stripe handoff. tier "" = free. |
| let planChoice = null; |
| let planTier = "free"; |
| - |
| -// openCreate opens step 1: name the space, pick who pays. The slider (step 2) |
| -// stays the actual commit either way. |
| +// planPaidRequired: the create bounced off a gate paying genuinely fixes |
| +// (the free quota, or the pool under budget pressure) โ the plan screen |
| +// re-opens showing the paid tiers. The HAPPY path never shows them: every |
| +// space is born free, the slider creates it immediately, and "who is this |
| +// space for?" is Eleven's question inside the space (#1374). |
| +let planPaidRequired = false; |
| + |
| +// openCreate opens step 1: name the space. The slider (step 2) stays the |
| +// actual commit. |
| function openCreate() { |
| clearUndo(); |
| planChoice = null; |
| planTier = "free"; |
| + planPaidRequired = false; |
| $("plan-name").value = ""; |
| $("plan-continue").disabled = true; |
| planStatus(""); |
| - // The picker needs the price book; kick the lazy ledger read if it hasn't |
| - // happened yet (renderPlanTiers fills in when it answers). |
| + // The ledger read still warms the price book โ the paid-required error |
| + // path needs it in hand (renderPlanTiers fills in when it answers). |
| if (spacesEnabled && provisioned === null && state.token) refreshProvisioned(); |
| renderPlanTiers(); |
| hideVault(); |
| @@ -3584,14 +3620,17 @@ function planStatus(msg, kind) { |
| } |
| // renderPlanTiers paints the who-pays picker from the provisioner's price |
| -// book: Free first (subsidised โ the honest framing), then each tier at its |
| -// base price with the โ local figure the upgrade picker also shows. Billing |
| -// off (self-hosters, or the book briefly unreachable) โ no picker at all; |
| -// the step is just the name. |
| +// book โ but ONLY in paid-required mode (#1374): the happy path is just the |
| +// name, and the plan question lives in Eleven's setup conversation. Here the |
| +// create bounced off the quota or the pool gate, so the paid tiers (no Free |
| +// card โ free is exactly what bounced) are the way through, at base price |
| +// with the โ local figure the upgrade picker also shows. |
| function renderPlanTiers() { |
| const wrap = $("plan-tiers-wrap"); |
| - wrap.classList.toggle("hidden", !billingTiers.length); |
| - if (!billingTiers.length) return; |
| + const showing = planPaidRequired && billingTiers.length; |
| + wrap.classList.toggle("hidden", !showing); |
| + if (!showing) return; |
| + $("plan-tiers-lead").textContent = "Pick a plan to create it now:"; |
| // The โ rates ride the same one-fetch-per-page-life snapshot as the |
| // upgrade picker; render now, again when they land. |
| if (!fxSnapshot) { |
| @@ -3637,7 +3676,6 @@ function renderPlanTiers() { |
| }); |
| box.append(btn); |
| }; |
| - card("free", "Free", "โฌ0", "Kept free by the members who pay for theirs."); |
| for (const t of billingTiers) { |
| const view = tierForBand(t, "base"); |
| const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language); |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index 5740c80..21591b2 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -476,7 +476,7 @@ |
| "label": "invited Eleven's AI in", |
| "value": "on" |
| }, |
| - "goto": "keys" |
| + "goto": "plan" |
| }, |
| { |
| "label": "Not now", |
| @@ -492,6 +492,155 @@ |
| "label": "kept AI off", |
| "value": "off" |
| }, |
| + "goto": "plan" |
| + } |
| + ] |
| + }, |
| + { |
| + "id": "plan", |
| + "skipIf": [ |
| + "done", |
| + "native" |
| + ], |
| + "skipTo": "keys", |
| + "entries": [ |
| + { |
| + "if": "!bridge.planPrices", |
| + "goto": "keys" |
| + } |
| + ], |
| + "bubbles": [ |
| + { |
| + "text": "One more question โ who is this space for?\n\nEvery space starts free, and free is a real home โ kept that way by the spaces that pay. If this one's for a team, a community, or family, there's a plan that backs it (and the free spaces too).", |
| + "key": "q-plan" |
| + } |
| + ], |
| + "chips": [ |
| + { |
| + "if": "bridge.planTeam", |
| + "label": "A team", |
| + "goto": "plan-team" |
| + }, |
| + { |
| + "if": "bridge.planCommunity", |
| + "label": "A community", |
| + "goto": "plan-community" |
| + }, |
| + { |
| + "if": "bridge.planFamily", |
| + "label": "Family & friends", |
| + "goto": "plan-family" |
| + }, |
| + { |
| + "label": "Keep it free", |
| + "spent": "Free", |
| + "trailing": true, |
| + "record": { |
| + "label": "kept this space free", |
| + "value": "free" |
| + }, |
| + "goto": "keys" |
| + } |
| + ] |
| + }, |
| + { |
| + "id": "plan-team", |
| + "bubbles": [ |
| + { |
| + "text": "For a team that's {bridge.planNameTeam}: {bridge.planPriceTeam} โ it scales with the team, and bots are free.\n\nA paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", |
| + "key": "q-plan-team" |
| + } |
| + ], |
| + "chips": [ |
| + { |
| + "label": "Choose {bridge.planNameTeam}", |
| + "spent": "Chosen", |
| + "set": { |
| + "vars.planTier": "team" |
| + }, |
| + "record": { |
| + "step": "plan", |
| + "label": "chose the {bridge.planNameTeam} plan โ payment waits at the finish line", |
| + "value": "team" |
| + }, |
| + "goto": "keys" |
| + }, |
| + { |
| + "label": "Keep it free", |
| + "spent": "Free", |
| + "record": { |
| + "step": "plan", |
| + "label": "kept this space free", |
| + "value": "free" |
| + }, |
| + "goto": "keys" |
| + } |
| + ] |
| + }, |
| + { |
| + "id": "plan-community", |
| + "bubbles": [ |
| + { |
| + "text": "For a community that's {bridge.planNameCommunity}: {bridge.planPriceCommunity}.\n\nA paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", |
| + "key": "q-plan-community" |
| + } |
| + ], |
| + "chips": [ |
| + { |
| + "label": "Choose {bridge.planNameCommunity}", |
| + "spent": "Chosen", |
| + "set": { |
| + "vars.planTier": "community" |
| + }, |
| + "record": { |
| + "step": "plan", |
| + "label": "chose the {bridge.planNameCommunity} plan โ payment waits at the finish line", |
| + "value": "community" |
| + }, |
| + "goto": "keys" |
| + }, |
| + { |
| + "label": "Keep it free", |
| + "spent": "Free", |
| + "record": { |
| + "step": "plan", |
| + "label": "kept this space free", |
| + "value": "free" |
| + }, |
| + "goto": "keys" |
| + } |
| + ] |
| + }, |
| + { |
| + "id": "plan-family", |
| + "bubbles": [ |
| + { |
| + "text": "For family and friends that's {bridge.planNameFamily}: {bridge.planPriceFamily}.\n\nA paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", |
| + "key": "q-plan-family" |
| + } |
| + ], |
| + "chips": [ |
| + { |
| + "label": "Choose {bridge.planNameFamily}", |
| + "spent": "Chosen", |
| + "set": { |
| + "vars.planTier": "family" |
| + }, |
| + "record": { |
| + "step": "plan", |
| + "label": "chose the {bridge.planNameFamily} plan โ payment waits at the finish line", |
| + "value": "family" |
| + }, |
| + "goto": "keys" |
| + }, |
| + { |
| + "label": "Keep it free", |
| + "spent": "Free", |
| + "record": { |
| + "step": "plan", |
| + "label": "kept this space free", |
| + "value": "free" |
| + }, |
| "goto": "keys" |
| } |
| ] |
| @@ -710,6 +859,10 @@ |
| { |
| "if": "!vars.backedUp", |
| "text": "\n\nOne more reminder before you go: you haven't signed in, so this browser holds the only key to this space. If it's lost or cleared, the space is gone with it โ for everyone. Sign in any time and it's safe." |
| + }, |
| + { |
| + "if": "vars.planTier", |
| + "text": "\n\nAnd the plan you picked โ tap below whenever you're ready to set up the payment. Your space runs free until then." |
| } |
| ] |
| }, |
| @@ -726,6 +879,14 @@ |
| "verb": "savePasskey" |
| } |
| }, |
| + { |
| + "if": "vars.planTier", |
| + "label": "Set up your plan", |
| + "advancing": false, |
| + "effect": { |
| + "verb": "openPlanCheckout" |
| + } |
| + }, |
| { |
| "label": "Open admin", |
| "advancing": false, |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index 838b72a..8eba4c5 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -13,7 +13,7 @@ const tick = () => new Promise((r) => setImmediate(r)); |
| // A fake io that mirrors the real engine surface: say dedupes by key||text |
| // (like eleven.js) but always re-arms chips; verbs return scripted results. |
| -function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, myName = "", envExtra = {} } = {}) { |
| +function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, myName = "", planBook = null, envExtra = {} } = {}) { |
| const h = { |
| transcript: [], // { text, chips: [labels], extra } |
| verbLog: [], |
| @@ -32,7 +32,23 @@ function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, my |
| respond: () => {}, |
| typing: null, |
| sender: { name: "Eleven", kind: "bot" }, |
| - bridge: { myName: () => h.myName, enrolled: () => h.enrolled, fromHome: () => h.fromHome }, |
| + bridge: { |
| + myName: () => h.myName, |
| + enrolled: () => h.enrolled, |
| + fromHome: () => h.fromHome, |
| + // The plan step's price book (#1374) โ mirrors apps/setup.js's getters; |
| + // absent (the default) skips the step, like a self-hosted space. |
| + planPrices: () => !!planBook, |
| + planTeam: () => !!(planBook && planBook.team), |
| + planCommunity: () => !!(planBook && planBook.community), |
| + planFamily: () => !!(planBook && planBook.family), |
| + planNameTeam: () => (planBook && planBook.team && planBook.team.name) || "", |
| + planPriceTeam: () => (planBook && planBook.team && planBook.team.price) || "", |
| + planNameCommunity: () => (planBook && planBook.community && planBook.community.name) || "", |
| + planPriceCommunity: () => (planBook && planBook.community && planBook.community.price) || "", |
| + planNameFamily: () => (planBook && planBook.family && planBook.family.name) || "", |
| + planPriceFamily: () => (planBook && planBook.family && planBook.family.price) || "", |
| + }, |
| space: { name: "Old Name", nameSet: false }, |
| onEnd: () => (h.ended = true), |
| }; |
| @@ -434,6 +450,87 @@ await test("setup: native creator โ greeting shows, name pre-set, passkey neve |
| assert.ok(chipLabels.includes("Open admin"), "native hops to Admin"); |
| }); |
| +await test("setup: the plan step โ asked with a price book, priced from it, recorded on 'plan'", async () => { |
| + // #1374: "who is this space for?" lives in the conversation; the create |
| + // slider is never pay-gated. Names + prices interpolate from the book. |
| + const book = { |
| + team: { key: "business", name: "Business", price: "โฌ11 per person per year" }, |
| + community: { key: "community", name: "Community & Friends", price: "โฌ0.11 per member per year (โฌ11 minimum)" }, |
| + family: { key: "family", name: "Family", price: "โฌ5.50 a year" }, |
| + }; |
| + const h = harness({ |
| + myName: "Paul", |
| + planBook: book, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true }, |
| + }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + |
| + const q = h.transcript.find((t) => t.extra.key === "q-plan"); |
| + assert.ok(q, "the plan question is asked"); |
| + assert.match(q.text, /who is this space for\?/); |
| + assert.deepEqual(q.chips, ["A team", "A community", "Family & friends", "Keep it free"]); |
| + |
| + await h.tap("A team"); |
| + const price = h.transcript.find((t) => t.extra.key === "q-plan-team"); |
| + assert.match(price.text, /Business: โฌ11 per person per year/, "name + price come from the book, not the script"); |
| + await h.tap("Choose Business"); |
| + assert.deepEqual(h.records.find((r) => r.step === "plan"), { |
| + step: "plan", |
| + label: "chose the Business plan โ payment waits at the finish line", |
| + value: "team", |
| + }); |
| + |
| + // The keys pitch follows; decline through to finish โ which carries the |
| + // plan note and the payment chip (an action chip, never a side effect). |
| + await h.tap("Not now"); |
| + await h.tap("I understand the risk"); |
| + assert.ok(h.transcript.find((t) => /the plan you picked โ tap below/.test(t.text))); |
| + await h.tap("Set up your plan"); |
| + assert.ok(h.verbLog.find((v) => v[0] === "openPlanCheckout"), "the chip fires the checkout verb"); |
| + assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"), "the flow still completes"); |
| +}); |
| + |
| +await test("setup: the plan step โ 'Keep it free' records free; a partial book trims its chips", async () => { |
| + const h = harness({ |
| + myName: "Paul", |
| + planBook: { family: { key: "family", name: "Family", price: "โฌ5.50 a year" } }, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true }, |
| + }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + const q = h.transcript.find((t) => t.extra.key === "q-plan"); |
| + assert.deepEqual(q.chips, ["Family & friends", "Keep it free"], "only tiers the book offers get chips"); |
| + await h.tap("Family & friends"); |
| + await h.tap("Keep it free"); |
| + assert.deepEqual(h.records.find((r) => r.step === "plan"), { step: "plan", label: "kept this space free", value: "free" }); |
| + // No plan chosen โ no payment note and no payment chip at the finish. |
| + await h.tap("Not now"); |
| + await h.tap("I understand the risk"); |
| + assert.equal(h.transcript.some((t) => /the plan you picked/.test(t.text)), false); |
| + assert.equal(h.transcript.flatMap((t) => t.chips || []).includes("Set up your plan"), false); |
| +}); |
| + |
| +await test("setup: the plan step vanishes without a price book, on native, and once done", async () => { |
| + // No book (self-hosted, billing off, unreachable): straight to keys. |
| + const h = harness({ myName: "Paul", done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true } }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + assert.equal(h.transcript.some((t) => t.extra.key === "q-plan"), false); |
| + assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text))); |
| + |
| + // Done (answered on another client): never re-asked, even with a book. |
| + const book = { family: { key: "family", name: "Family", price: "โฌ5.50 a year" } }; |
| + const h2 = harness({ |
| + myName: "Paul", |
| + planBook: book, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true, plan: true }, |
| + }); |
| + runFlow(SETUP_FLOW, h2.env, h2.io); |
| + await tick(); |
| + assert.equal(h2.transcript.some((t) => t.extra.key === "q-plan"), false); |
| +}); |
| + |
| await test("engine: template + condition mini-language", () => { |
| const env = { vars: { x: "hi" }, space: { name: "" }, __ack: "A" }; |
| assert.equal(renderText(env, "{vars.x} {space.name|fallback}"), "hi fallback"); |
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js| index 3734ccd..9a8d244 100644 |
| --- a/web/static/apps/setup.js |
| +++ b/web/static/apps/setup.js |
| @@ -81,6 +81,37 @@ const runningEnvs = new Map(); // threadId โ env |
| const ownRecords = new Map(); // threadId โ Set(step) |
| const say = makeSay("setup", N.ev("say")); |
| +// ---- the plan step's price data (#1374) ------------------------------------- |
| + |
| +// The price book, fetched from home's public /api/pricebook before the flow |
| +// runs (startSetup awaits it, so bridge.plan* are plain sync getters like |
| +// every other bridge field). null = the plan step doesn't happen: a native |
| +// wrapper (no external purchases โ App Store rules), a self-hosted or |
| +// already-paid space, billing off, or an unreachable book. Keyed by AUDIENCE; |
| +// the book's tier keys are the ledger's routing keys, mapped here. |
| +let planBook = null; // { team|community|family: { key, name, price } } |
| + |
| +async function fetchPlanBook() { |
| + planBook = null; |
| + if (window.__entitlement !== "free" || !window.__homeOrigin) return; |
| + if (document.documentElement.classList.contains("native")) return; |
| + try { |
| + // Capped so a slow book never delays Eleven's hello โ no book, no step. |
| + const r = await fetch(window.__homeOrigin + "/api/pricebook", { signal: AbortSignal.timeout(4000) }); |
| + if (!r.ok) return; |
| + const body = await r.json(); |
| + const audience = { business: "team", community: "community", family: "family" }; |
| + const book = {}; |
| + for (const t of body.tiers || []) { |
| + const kind = audience[t.key]; |
| + if (kind && t.name && t.label) book[kind] = { key: t.key, name: t.name, price: t.label }; |
| + } |
| + if (Object.keys(book).length) planBook = book; |
| + } catch { |
| + // Unreachable book โ the conversation simply skips the plan step. |
| + } |
| +} |
| + |
| // The bridge-verb table: the CLOSED set of side effects the setup flow may |
| // name (docs/conversation-scripts.md ยง interpreter contract). A flow is data โ |
| // it can never do anything this table doesn't offer. |
| @@ -108,6 +139,20 @@ function verb(name, args, env) { |
| return bridge.deferBackupReminders && bridge.deferBackupReminders(); |
| case "openAdmin": |
| return openAppThread("admin"); |
| + case "openPlanCheckout": { |
| + // The finish line's payment chip (#1374): an upgrade of the already- |
| + // created (free) space on the home page โ this server has no billing |
| + // surface. The recorded plan step holds the audience; the fetched book |
| + // maps it back to the tier key. Same-tab: setup is complete, and home |
| + // returns you to your list once the payment settles. |
| + const st = appStateFor(env.threadId, "setup"); |
| + const kind = st && st.steps && st.steps.plan && st.steps.plan.value; |
| + const tier = planBook && kind && planBook[kind]; |
| + if (!tier || !window.__homeOrigin) return; |
| + location.href = |
| + window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host) + "&tier=" + encodeURIComponent(tier.key); |
| + return; |
| + } |
| case "openQuickForm": |
| // The name step's fast path โ the host owns the form sheet (web: |
| // openSetupQuickForm in shell.js; native: the quick-form sheet). |
| @@ -139,6 +184,17 @@ export function startSetup(env) { |
| // than pitching passkeys from scratch. Must be forwarded here โ the flow's |
| // bridge.* only sees what env.bridge exposes. |
| fromHome: () => setupBridge().fromHome(), |
| + // The plan step's price book (#1374) โ absent means the step is skipped. |
| + planPrices: () => !!planBook, |
| + planTeam: () => !!(planBook && planBook.team), |
| + planCommunity: () => !!(planBook && planBook.community), |
| + planFamily: () => !!(planBook && planBook.family), |
| + planNameTeam: () => (planBook && planBook.team && planBook.team.name) || "", |
| + planPriceTeam: () => (planBook && planBook.team && planBook.team.price) || "", |
| + planNameCommunity: () => (planBook && planBook.community && planBook.community.name) || "", |
| + planPriceCommunity: () => (planBook && planBook.community && planBook.community.price) || "", |
| + planNameFamily: () => (planBook && planBook.family && planBook.family.name) || "", |
| + planPriceFamily: () => (planBook && planBook.family && planBook.family.price) || "", |
| }; |
| env.onEnd = () => { |
| running.delete(env.threadId); |
| @@ -167,12 +223,16 @@ export function startSetup(env) { |
| imgSource: (token) => (token === "$serverAvatar" ? "/api/server-avatar?t=" + Date.now() : token), |
| delay: (ms, fn) => setTimeout(fn, ms), |
| }; |
| - spaceInfo() |
| - .then((info) => { |
| - env.space = info || {}; |
| - }) |
| - .catch(() => { |
| - env.space = {}; |
| - }) |
| - .finally(() => runFlow(SETUP_FLOW, env, io)); |
| + // The plan step's price book rides alongside the space info โ both settle |
| + // before the flow starts, so every bridge getter stays synchronous. |
| + Promise.all([ |
| + spaceInfo() |
| + .then((info) => { |
| + env.space = info || {}; |
| + }) |
| + .catch(() => { |
| + env.space = {}; |
| + }), |
| + fetchPlanBook(), |
| + ]).finally(() => runFlow(SETUP_FLOW, env, io)); |
| } |
diff --git a/web/static/setup.flow.js b/web/static/setup.flow.js| index dbb9d83..e9bb2ab 100644 |
| --- a/web/static/setup.flow.js |
| +++ b/web/static/setup.flow.js |
| @@ -322,15 +322,118 @@ export default { |
| spent: "On", |
| effect: { verb: "setFeature", args: ["ai", true] }, |
| record: { label: "invited Eleven's AI in", value: "on" }, |
| - goto: "keys", |
| + goto: "plan", |
| }, |
| { |
| label: "Not now", |
| spent: "Off", |
| effect: { verb: "setFeature", args: ["ai", false] }, |
| record: { label: "kept AI off", value: "off" }, |
| + goto: "plan", |
| + }, |
| + ], |
| + }, |
| + { |
| + // The plan question, moved HERE from the create flow (#1374): creating |
| + // a space is never pay-gated โ the conversation is where "who is this |
| + // space for?" gets asked, with real prices from the price book, and a |
| + // paid answer becomes an upgrade chip on the finish line (the space |
| + // already exists, free, and stays free until that tap). Web-only, like |
| + // the photo: native wrappers must not surface an external purchase |
| + // (App Store rules โ the same gate as the โฎ Upgrade item), and with no |
| + // reachable price book (self-hosted, billing off, already paid) the |
| + // step vanishes. Whichever client asks, the record lands on THIS step. |
| + id: "plan", |
| + skipIf: ["done", "native"], |
| + skipTo: "keys", |
| + entries: [{ if: "!bridge.planPrices", goto: "keys" }], |
| + bubbles: [ |
| + { |
| + text: |
| + "One more question โ who is this space for?\n\n" + |
| + "Every space starts free, and free is a real home โ kept that way by the spaces that pay. " + |
| + "If this one's for a team, a community, or family, there's a plan that backs it (and the free spaces too).", |
| + key: "q-plan", |
| + }, |
| + ], |
| + chips: [ |
| + { if: "bridge.planTeam", label: "A team", goto: "plan-team" }, |
| + { if: "bridge.planCommunity", label: "A community", goto: "plan-community" }, |
| + { if: "bridge.planFamily", label: "Family & friends", goto: "plan-family" }, |
| + { |
| + label: "Keep it free", |
| + spent: "Free", |
| + trailing: true, |
| + record: { label: "kept this space free", value: "free" }, |
| + goto: "keys", |
| + }, |
| + ], |
| + }, |
| + { |
| + // The three per-audience price cards. Names and prices interpolate from |
| + // the price book (config, never code โ docs/tier-economics.md); the |
| + // record's value is the AUDIENCE, which openPlanCheckout maps back to |
| + // the book's tier key at tap time. |
| + id: "plan-team", |
| + bubbles: [ |
| + { |
| + text: |
| + "For a team that's {bridge.planNameTeam}: {bridge.planPriceTeam} โ it scales with the team, and bots are free.\n\n" + |
| + "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", |
| + key: "q-plan-team", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Choose {bridge.planNameTeam}", |
| + spent: "Chosen", |
| + set: { "vars.planTier": "team" }, |
| + record: { step: "plan", label: "chose the {bridge.planNameTeam} plan โ payment waits at the finish line", value: "team" }, |
| goto: "keys", |
| }, |
| + { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" }, |
| + ], |
| + }, |
| + { |
| + id: "plan-community", |
| + bubbles: [ |
| + { |
| + text: |
| + "For a community that's {bridge.planNameCommunity}: {bridge.planPriceCommunity}.\n\n" + |
| + "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", |
| + key: "q-plan-community", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Choose {bridge.planNameCommunity}", |
| + spent: "Chosen", |
| + set: { "vars.planTier": "community" }, |
| + record: { step: "plan", label: "chose the {bridge.planNameCommunity} plan โ payment waits at the finish line", value: "community" }, |
| + goto: "keys", |
| + }, |
| + { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" }, |
| + ], |
| + }, |
| + { |
| + id: "plan-family", |
| + bubbles: [ |
| + { |
| + text: |
| + "For family and friends that's {bridge.planNameFamily}: {bridge.planPriceFamily}.\n\n" + |
| + "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", |
| + key: "q-plan-family", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Choose {bridge.planNameFamily}", |
| + spent: "Chosen", |
| + set: { "vars.planTier": "family" }, |
| + record: { step: "plan", label: "chose the {bridge.planNameFamily} plan โ payment waits at the finish line", value: "family" }, |
| + goto: "keys", |
| + }, |
| + { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" }, |
| ], |
| }, |
| { |
| @@ -493,6 +596,13 @@ export default { |
| text: |
| "\n\nOne more reminder before you go: you haven't signed in, so this browser holds the only key to this space. If it's lost or cleared, the space is gone with it โ for everyone. Sign in any time and it's safe.", |
| }, |
| + // The plan they chose mid-conversation (#1374): the payment is a |
| + // chip below, never a side effect โ and the space stays free (and |
| + // fully working) until that tap. |
| + { |
| + if: "vars.planTier", |
| + text: "\n\nAnd the plan you picked โ tap below whenever you're ready to set up the payment. Your space runs free until then.", |
| + }, |
| ], |
| }, |
| { text: "Where would you like to start?" }, |
| @@ -501,6 +611,10 @@ export default { |
| // The parting reminder's action โ finish-line chips stay live, and the |
| // completion poll picks the sign-in up even after the flow has ended. |
| { if: "!vars.backedUp", label: "Sign in", advancing: false, effect: { verb: "savePasskey" } }, |
| + // The chosen plan's payment (#1374): hands off to the home page's |
| + // upgrade picker with the tier pre-highlighted. Same tab โ setup is |
| + // complete, and home brings you back to your list. |
| + { if: "vars.planTier", label: "Set up your plan", advancing: false, effect: { verb: "openPlanCheckout" } }, |
| // Native hops threads too (the conversation model's openAdmin verb). |
| { label: "Open admin", advancing: false, effect: { verb: "openAdmin" } }, |
| // The tour is the web's guided overlay โ web-only for now. |