| index 811aa7d..0a08c8b 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -140,6 +140,13 @@ let billingTiers = []; // the provisioner's price book: [{key, name, label}] |
| // by the first showVault after return. |
| let upgradedHost = new URLSearchParams(location.search).get("upgraded") || ""; |
| if (upgradedHost) history.replaceState(null, "", location.pathname + location.hash); |
| +// A pay-first create lands back with ?paidcreate=<session>&pcregion=<region> |
| +// (Stripe fills the session id into the template). Consumed by the first |
| +// vault render after sign-in, which claims the space into existence; a |
| +// localStorage marker backs it up so a closed tab can still finish later. |
| +let paidCreateSession = new URLSearchParams(location.search).get("paidcreate") || ""; |
| +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 |
| @@ -1111,6 +1118,7 @@ function signOut() { |
| provisioned = null; // the ledger read is per-sign-in |
| hideVault(); |
| $("create").classList.add("hidden"); |
| + $("create-plan").classList.add("hidden"); |
| $("upgrade").classList.add("hidden"); |
| $("claim").classList.add("hidden"); |
| $("archive").classList.add("hidden"); |
| @@ -2528,6 +2536,23 @@ function showVault() { |
| // The ledger read behind the Upgrade affordance — lazy, once per sign-in. |
| if (spacesEnabled && provisioned === null && state.token) refreshProvisioned(); |
| |
| + // Back from a pay-first checkout — or a marker parked by a tab that closed |
| + // on Stripe's page: claim the space into existence. Once per session id per |
| + // page life (the error pane's Try again drives retries); the claim itself |
| + // is idempotent downstream, so a duplicate is harmless. |
| + { |
| + const marker = pendingCreateMarker(); |
| + const session = paidCreateSession || (marker && marker.session) || ""; |
| + const region = paidCreateRegion || (marker && marker.region) || ""; |
| + paidCreateSession = ""; |
| + paidCreateRegion = ""; |
| + if (state.token && session && !claimedSessions.has(session)) { |
| + claimedSessions.add(session); |
| + claimPaidCreate(session, region); |
| + return; // the claim owns the screen now |
| + } |
| + } |
| + |
| // Back from a completed checkout: thank inline, then re-read the ledger a few |
| // seconds later — the webhook that flips the badge can lag the redirect. |
| if (upgradedHost) { |
| @@ -2553,6 +2578,9 @@ function refreshProvisioned() { |
| provisioned = {}; |
| for (const sp of r.spaces || []) provisioned[sp.host] = sp; |
| if (vault && !$("vault").classList.contains("hidden")) showVault(); |
| + // The create plan step renders its picker from this book — fill it in |
| + // if the screen opened before the ledger answered. |
| + if (!$("create-plan").classList.contains("hidden")) renderPlanTiers(); |
| consumeUpgradeLink(); |
| }) |
| .catch(() => { /* ledger unreachable — render without the affordance */ }); |
| @@ -3248,17 +3276,19 @@ async function requestSpace() { |
| // and grind it out right here, during the fader's build phase — the CPU |
| // burn IS the "Hold on…" moment. bits 0 = no gate, nothing to solve. |
| const proof = await solveCreateChallenge(); |
| + const body = { ...(proof || {}) }; |
| + if (planChoice && planChoice.name) body.name = planChoice.name; |
| const res = await fetch("/api/spaces", { |
| method: "POST", |
| headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| - body: JSON.stringify(proof || {}), |
| + body: JSON.stringify(body), |
| }); |
| - const body = await res.json().catch(() => ({})); |
| + const reply = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| // Structured errors carry a member-facing message (quota, at-capacity). |
| - throw new Error(body.message || body.error || `Couldn't create a space (HTTP ${res.status}).`); |
| + throw new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`); |
| } |
| - const url = body.url; |
| + const url = reply.url; |
| const host = new URL(url).host; |
| vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| vault.obj.servers.push({ url, name: host }); |
| @@ -3269,7 +3299,105 @@ async function requestSpace() { |
| // The space exists even if the vault save raced — still confirm it; the |
| // entry lands on the next successful save. |
| } |
| - return { host, setupLink: body.setup_link }; |
| + return { host, setupLink: reply.setup_link }; |
| +} |
| + |
| +// requestPaidSpace is the paid branch behind the fader: mint the Checkout for |
| +// the chosen plan and hand the tab to Stripe. The space is only created on |
| +// return (claimPaidCreate); a localStorage marker means even a closed tab can |
| +// finish next visit. The returned promise never resolves — navigation is the |
| +// resolution. |
| +async function requestPaidSpace() { |
| + if (!vault) throw new Error("Sign in first — your new space is saved into your account."); |
| + const proof = await solveCreateChallenge(); |
| + const res = await fetch("/api/spaces/checkout-create", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify({ ...(proof || {}), name: planChoice.name, tier: planChoice.tier }), |
| + }); |
| + const reply = await res.json().catch(() => ({})); |
| + if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't start the checkout (HTTP ${res.status}).`); |
| + if (!reply.url) throw new Error("The checkout link didn't come back — try again."); |
| + if (reply.session_id) { |
| + rememberPendingCreate({ session: reply.session_id, name: planChoice.name, at: Date.now() }); |
| + } |
| + location.href = reply.url; |
| + return new Promise(() => {}); |
| +} |
| + |
| +// ---- the pay-first marker + claim --------------------------------------------- |
| + |
| +// The localStorage marker for a checkout in flight: {session, name, at}. It |
| +// exists so a tab closed on Stripe's page still finishes the create on the |
| +// next visit here — the claim is idempotent, so over-firing is safe. |
| +const PENDING_CREATE_KEY = "lchat-pending-create"; |
| +const claimedSessions = new Set(); // auto-claim once per page life; Try again drives retries |
| + |
| +function rememberPendingCreate(m) { |
| + try { localStorage.setItem(PENDING_CREATE_KEY, JSON.stringify(m)); } catch { /* private mode — the redirect still carries the session */ } |
| +} |
| +function pendingCreateMarker() { |
| + try { return JSON.parse(localStorage.getItem(PENDING_CREATE_KEY) || "null"); } catch { return null; } |
| +} |
| +function clearPendingCreate() { |
| + try { localStorage.removeItem(PENDING_CREATE_KEY); } catch { /* nothing to clear */ } |
| +} |
| + |
| +// claimPaidCreate finishes a pay-first create: the provisioner verifies the |
| +// session with Stripe and provisions on the spot, so the create screen's |
| +// building beat is the honest wait. 402/404 mean the checkout never completed |
| +// (cancelled, expired, or another account's) — drop the marker quietly and |
| +// return to the list; the pending allocation prunes itself server-side. |
| +async function claimPaidCreate(session, region) { |
| + hideVault(); |
| + $("create-plan").classList.add("hidden"); |
| + $("create").classList.remove("hidden"); |
| + $("create-back").classList.add("hidden"); |
| + $("create-pitch").classList.add("hidden"); |
| + $("new-space-result").classList.add("hidden"); |
| + $("create-building").classList.remove("hidden"); |
| + try { |
| + const res = await fetch("/api/spaces/claim-create", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify({ session_id: session, region: region || "" }), |
| + }); |
| + const reply = await res.json().catch(() => ({})); |
| + if (res.status === 402 || res.status === 404) { |
| + clearPendingCreate(); |
| + $("create-building").classList.add("hidden"); |
| + $("create").classList.add("hidden"); |
| + showVault(); |
| + return; |
| + } |
| + if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't finish the setup (HTTP ${res.status}).`); |
| + const url = reply.url; |
| + const host = new URL(url).host; |
| + vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== url); |
| + vault.obj.servers.push({ url, name: host }); |
| + vault.obj.removed = clearTombstone(vault.obj.removed, url); |
| + try { await saveVault(); } catch { /* the space exists; the entry lands on the next save */ } |
| + clearPendingCreate(); |
| + $("create-building").classList.add("hidden"); |
| + showNewSpaceResult(host, reply.setup_link); |
| + } catch (e) { |
| + // Retriable (the provisioner said the payment is safe): keep the marker |
| + // and offer Try again right here. |
| + $("create-building").classList.add("hidden"); |
| + const body = $("new-space-body"); |
| + body.textContent = ""; |
| + const msg = document.createElement("p"); |
| + msg.className = "create-sub"; |
| + msg.textContent = e.message || "The space couldn't be set up just now — your payment is safe."; |
| + const retry = document.createElement("button"); |
| + retry.type = "button"; |
| + retry.className = "secondary"; |
| + retry.textContent = "Try again"; |
| + retry.addEventListener("click", () => claimPaidCreate(session, region)); |
| + body.append(msg, retry); |
| + $("create-back").classList.add("hidden"); |
| + $("new-space-result").classList.remove("hidden"); |
| + } |
| } |
| |
| // solveCreateChallenge grinds the home server's hashcash challenge: find a |
| @@ -3410,7 +3538,7 @@ function ensureFader() { |
| // and can't be cancelled; error/ready each restore a way out. |
| onEleven: () => { |
| $("create-back").classList.add("hidden"); |
| - return requestSpace(); |
| + return planChoice && planChoice.tier ? requestPaidSpace() : requestSpace(); |
| }, |
| onReady: (r) => showNewSpaceResult(r.host, r.setupLink), |
| onError: (err) => { |
| @@ -3421,12 +3549,128 @@ function ensureFader() { |
| return fader; |
| } |
| |
| +// The plan chosen on step 1 — consumed by the fader's onEleven to pick the |
| +// 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. |
| function openCreate() { |
| clearUndo(); |
| + planChoice = null; |
| + planTier = "free"; |
| + $("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). |
| + if (spacesEnabled && provisioned === null && state.token) refreshProvisioned(); |
| + renderPlanTiers(); |
| + hideVault(); |
| + $("create-plan").classList.remove("hidden"); |
| + $("plan-name").focus(); |
| +} |
| + |
| +function closePlan() { |
| + $("create-plan").classList.add("hidden"); |
| + showVault(); |
| +} |
| + |
| +function planStatus(msg, kind) { |
| + const el = $("plan-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + 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. |
| +function renderPlanTiers() { |
| + const wrap = $("plan-tiers-wrap"); |
| + wrap.classList.toggle("hidden", !billingTiers.length); |
| + if (!billingTiers.length) return; |
| + // The ≈ rates ride the same one-fetch-per-page-life snapshot as the |
| + // upgrade picker; render now, again when they land. |
| + if (!fxSnapshot) { |
| + fxSnapshot = { date: "", rates: null }; |
| + apiJSON("/api/fx") |
| + .then((b) => { |
| + fxSnapshot = { date: b.date || "", rates: b.rates || null }; |
| + if (!$("create-plan").classList.contains("hidden")) renderPlanTiers(); |
| + }) |
| + .catch(() => { /* no rates — the EUR stickers stand alone */ }); |
| + } |
| + const currency = approxCurrency || regionCurrency(navigator.language); |
| + const rates = fxSnapshot && fxSnapshot.rates; |
| + const box = $("plan-tiers"); |
| + box.textContent = ""; |
| + const card = (key, title, priceLine, subLine, approx) => { |
| + const btn = document.createElement("button"); |
| + btn.type = "button"; |
| + btn.className = "tier-btn wrap" + (key === planTier ? " selected" : ""); |
| + btn.setAttribute("aria-pressed", key === planTier ? "true" : "false"); |
| + const name = document.createElement("span"); |
| + name.className = "tier-name"; |
| + name.textContent = title; |
| + const price = document.createElement("span"); |
| + price.className = "tier-price"; |
| + price.textContent = priceLine; |
| + btn.append(name, price); |
| + if (approx) { |
| + const a = document.createElement("span"); |
| + a.className = "tier-approx"; |
| + a.textContent = approx; |
| + btn.append(a); |
| + } |
| + if (subLine) { |
| + const sub = document.createElement("span"); |
| + sub.className = "tier-sub"; |
| + sub.textContent = subLine; |
| + btn.append(sub); |
| + } |
| + btn.addEventListener("click", () => { |
| + planTier = key; |
| + 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); |
| + card(t.key, t.name, view.label + (t.business ? " excl. VAT" : ""), "", approx); |
| + } |
| + const fxNote = $("plan-fx"); |
| + fxNote.classList.toggle("hidden", !(rates && currency && currency !== "EUR")); |
| + fxNote.textContent = rates && fxSnapshot.date ? "Billed in euros — ≈ at the ECB rate of " + fxSnapshot.date + "." : ""; |
| +} |
| + |
| +// planContinue commits step 1 and opens the slider. The name is required — |
| +// asking and then accepting silence would make the field noise. |
| +function planContinue() { |
| + const name = $("plan-name").value.trim(); |
| + if (!name) { |
| + planStatus("Give your space a name first.", "error"); |
| + $("plan-name").focus(); |
| + return; |
| + } |
| + planChoice = { name, tier: planTier === "free" ? "" : planTier }; |
| + $("create-plan").classList.add("hidden"); |
| + openCreateFader(); |
| +} |
| + |
| +// openCreateFader opens step 2, the goes-to-eleven slider — the commit for |
| +// both branches; only the promise behind it differs. |
| +function openCreateFader() { |
| $("new-space-result").classList.add("hidden"); |
| $("create-back").classList.remove("hidden"); |
| + $("create-sub-line").textContent = planChoice && planChoice.tier |
| + ? "Slide, and we'll take you to the payment — your space is created the moment it's paid." |
| + : "Your new space will be created immediately."; |
| spaceStatus(""); |
| - hideVault(); |
| $("create").classList.remove("hidden"); |
| const f = ensureFader(); |
| f.reset(); |
| @@ -4009,7 +4253,21 @@ $("combine-skip").addEventListener("click", combineSkip); |
| $("combine-skip-yes").addEventListener("click", combineSkipYes); |
| $("combine-skip-no").addEventListener("click", combineVaultStage); |
| $("open-create").addEventListener("click", openCreate); |
| -$("create-back").addEventListener("click", closeCreate); |
| +$("plan-back").addEventListener("click", closePlan); |
| +$("plan-continue").addEventListener("click", planContinue); |
| +$("plan-name").addEventListener("input", () => { |
| + $("plan-continue").disabled = !$("plan-name").value.trim(); |
| + planStatus(""); |
| +}); |
| +$("plan-name").addEventListener("keydown", (e) => { |
| + if (e.key === "Enter" && !$("plan-continue").disabled) planContinue(); |
| +}); |
| +// Step 2's back returns to step 1 (the choices survive), not the list. |
| +$("create-back").addEventListener("click", () => { |
| + fader?.stop(); |
| + $("create").classList.add("hidden"); |
| + $("create-plan").classList.remove("hidden"); |
| +}); |
| $("create-done").addEventListener("click", closeCreate); |
| // (Creating a space is the fader now — mounted in openCreate; see fader.js.) |
| $("archive-back").addEventListener("click", closeArchive); |