From 6d2df35e4178af580f113d9e4644fae89034c030 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 05:42:00 -0700 Subject: [PATCH 1/3] Website: the checkout switch, yearly, and seats only for the prerendered cohort CHECKOUT_OPEN (committed false) turns the buy buttons into links to the account app's /checkout?plan=, carrying the visit's ref, and the JSON-LD offers from PreOrder to InStock. The yearly tier's id is now "yearly", the plan name checkout and billing use. The founding card drops seats left unless the endpoint's cohort is the one whose price it prerendered. Co-Authored-By: Claude Opus 5.5 --- website/src/lib/hosted-cohorts.ts | 12 ++++- website/src/lib/hosted-pricing.ts | 30 ++++++++++-- website/src/lib/site-analytics.test.ts | 6 +-- website/src/lib/site-analytics.ts | 3 +- website/src/pages/Hosted.test.tsx | 68 ++++++++++++++++++++++++-- website/src/pages/Hosted.tsx | 59 +++++++++++++++------- 6 files changed, 145 insertions(+), 33 deletions(-) diff --git a/website/src/lib/hosted-cohorts.ts b/website/src/lib/hosted-cohorts.ts index 9e6a64064..37af5f2c2 100644 --- a/website/src/lib/hosted-cohorts.ts +++ b/website/src/lib/hosted-cohorts.ts @@ -12,8 +12,12 @@ * renders without it rather than showing an error. The price beside them is * prerendered, so a provider outage costs the page nothing it sells. */ +import { FOUNDING_COHORTS_CLOSED } from "./hosted-pricing"; -/** Where both come from, in one request. Not deployed until checkout ships. */ +/** + * Where both come from, in one request: the site's Worker + * (`website/public/_worker.js`) forwards it to the account Worker. + */ export const COHORT_ENDPOINT = "/api/hosted/cohorts"; /** The most avatars the row draws; every other founder joins the `+N`. */ @@ -80,7 +84,11 @@ export async function fetchCohort(signal?: AbortSignal): Promise { const body: unknown = await response.json(); if (typeof body !== "object" || body === null) return { seatsLeft: null, founders: null }; const fields = body as Record; - return { seatsLeft: count(fields.seatsLeft) ?? null, founders: founders(fields.founders) }; + // Seats are for the cohort whose price this build prerendered: after a + // cohort closes and before the next deploy, they would sit beside the + // old price, so the counter waits for the deploy instead. + const seatsLeft = fields.cohort === FOUNDING_COHORTS_CLOSED ? (count(fields.seatsLeft) ?? null) : null; + return { seatsLeft, founders: founders(fields.founders) }; } catch { return { seatsLeft: null, founders: null }; } diff --git a/website/src/lib/hosted-pricing.ts b/website/src/lib/hosted-pricing.ts index ff91105ec..81ff5ab53 100644 --- a/website/src/lib/hosted-pricing.ts +++ b/website/src/lib/hosted-pricing.ts @@ -9,6 +9,7 @@ * * See docs/specs/pricing.md -> Published prices and -> The Hosted page. */ +import { HOSTED_REF_PARAM, type HostedRef } from "dormouse-lib/lib/hosted-links"; /** * List prices, which the founding ladder climbs toward and is struck against. @@ -51,7 +52,8 @@ export const FOUNDING_COHORTS_CLOSED = 0; /** One purchasable plan, at the price it is on sale at today. */ export type Tier = { - id: "monthly" | "annual" | "founding"; + /** The plan name checkout and the account's billing routes use. */ + id: "monthly" | "yearly" | "founding"; /** What the buy button and the checkout notice call it. */ name: string; /** What the buyer pays today, in whole US dollars. */ @@ -74,7 +76,7 @@ export const HOSTED_MONTHLY: Tier = { }; export const HOSTED_YEARLY: Tier = { - id: "annual", + id: "yearly", name: "Hosted yearly", price: LIST_ANNUAL, per: "/year", @@ -98,6 +100,24 @@ export function tiersOnSale(): Tier[] { return [HOSTED_MONTHLY, HOSTED_YEARLY, foundingTier()]; } +/** + * Whether the buy buttons sell. False until the account Worker's Stripe + * bindings are set: each button then opens a notice saying so. True, each + * links to {@link checkoutUrl} and the offers go `InStock`. + */ +export const CHECKOUT_OPEN: boolean = false; + +/** The account app's checkout, which signs the buyer in before it shows the plan. */ +export const CHECKOUT_PAGE = "https://hosted.dormouse.sh/checkout"; + +/** Where `tier`'s buy button goes once checkout is open, carrying the visit's `ref`. */ +export function checkoutUrl(tier: Tier, ref: HostedRef | "other" | null): string { + const url = new URL(CHECKOUT_PAGE); + url.searchParams.set("plan", tier.id); + if (ref) url.searchParams.set(HOSTED_REF_PARAM, ref); + return url.href; +} + /** * `Product` / `Offer` structured data for the paid plans, at the prices the * page prints. @@ -105,9 +125,9 @@ export function tiersOnSale(): Tier[] { * Prerendered with the rest of the prices so an assistant fetching the page * can quote them without running the counters. Availability is `PreOrder` * until checkout opens — the buttons explain what is left to build, so - * claiming `InStock` here would be the page's only false statement. + * claiming `InStock` there would be the page's only false statement. */ -export function pricingJsonLd(pageUrl: string): string { +export function pricingJsonLd(pageUrl: string, checkoutOpen: boolean): string { const data = { "@context": "https://schema.org", "@type": "Product", @@ -123,7 +143,7 @@ export function pricingJsonLd(pageUrl: string): string { price: String(tier.price), priceCurrency: "USD", url: `${pageUrl}#pricing`, - availability: "https://schema.org/PreOrder", + availability: `https://schema.org/${checkoutOpen ? "InStock" : "PreOrder"}`, priceSpecification: { "@type": "UnitPriceSpecification", price: String(tier.price), diff --git a/website/src/lib/site-analytics.test.ts b/website/src/lib/site-analytics.test.ts index c717c5fb7..4b8ee8bd3 100644 --- a/website/src/lib/site-analytics.test.ts +++ b/website/src/lib/site-analytics.test.ts @@ -61,7 +61,7 @@ describe("the loader", () => { const ph = fakePosthog(); const { trackPageview, trackEvent } = await fresh(); trackPageview("readme"); - trackEvent({ name: "hosted plan clicked", plan: "annual" }); + trackEvent({ name: "hosted plan clicked", plan: "yearly" }); await settle(); expect(ph.loaded).not.toHaveBeenCalled(); }); @@ -71,12 +71,12 @@ describe("the loader", () => { const { trackPageview, trackEvent } = await fresh(); expect(ph.loaded).not.toHaveBeenCalled(); trackPageview(null); - trackEvent({ name: "hosted plan clicked", plan: "annual" }); + trackEvent({ name: "hosted plan clicked", plan: "yearly" }); await settle(); expect(ph.loaded).toHaveBeenCalledTimes(1); expect(ph.capture.mock.calls).toEqual([ ["$pageview", undefined], - ["hosted plan clicked", { plan: "annual" }], + ["hosted plan clicked", { plan: "yearly" }], ]); }); }); diff --git a/website/src/lib/site-analytics.ts b/website/src/lib/site-analytics.ts index ada29eb8e..c49868dae 100644 --- a/website/src/lib/site-analytics.ts +++ b/website/src/lib/site-analytics.ts @@ -13,6 +13,7 @@ import type { PostHog, PostHogConfig } from "posthog-js/dist/module.slim.no-external.js"; import type { HostedRef } from "dormouse-lib/lib/hosted-links"; import type { TutorialState } from "./tutorial-state"; +import type { Tier } from "./hosted-pricing"; /** PostHog's public project key: public by design, it only lets a page send. */ export const POSTHOG_PROJECT_KEY = "phc_AxHDxNuQ9uGy5AmejhxcZz3vNUPwZY2WgmCgW6WgNkTU"; @@ -61,7 +62,7 @@ export type SiteRouteHandle = { ownPageview?: boolean }; /** The named events, and nothing else. */ export type SiteEvent = - | { name: "hosted plan clicked"; plan: "monthly" | "annual" | "founding" } + | { name: "hosted plan clicked"; plan: Tier["id"] } | { name: "tutorial completed"; tutorial: "desktop" | "pocket" }; type PrivacySignals = { globalPrivacyControl?: boolean; doNotTrack?: string | null }; diff --git a/website/src/pages/Hosted.test.tsx b/website/src/pages/Hosted.test.tsx index 4fda66289..b684c056a 100644 --- a/website/src/pages/Hosted.test.tsx +++ b/website/src/pages/Hosted.test.tsx @@ -11,15 +11,26 @@ vi.mock("dormouse-lib/site/DocsThemeControl", () => ({ default: () => null })); vi.mock("dormouse-lib/site/docs-paint", () => ({ useDocsThemedBody: () => {}, DOCS_HEADER_STYLE: {}, DOCS_PAINT_PROFILE: {} })); const analytics = vi.hoisted(() => ({ trackPageview: vi.fn(), trackEvent: vi.fn() })); vi.mock("../lib/site-analytics", () => analytics); +// `CHECKOUT_OPEN` is committed false; the open page is tested by flipping it here. +const checkout = vi.hoisted(() => ({ open: false })); +vi.mock("../lib/hosted-pricing", async (importOriginal) => ({ + ...(await importOriginal()), + get CHECKOUT_OPEN() { + return checkout.open; + }, +})); const { default: Hosted } = await import("./Hosted"); const { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } = await import("../lib/hosted-cohorts"); const { + CHECKOUT_PAGE, + FOUNDING_COHORTS_CLOSED, FOUNDING_COHORT_SIZE, HOSTED_MONTHLY, HOSTED_YEARLY, LIST_ANNUAL, foundingTier, + pricingJsonLd, tiersOnSale, } = await import("../lib/hosted-pricing"); @@ -51,6 +62,7 @@ afterEach(() => { vi.unstubAllGlobals(); analytics.trackPageview.mockClear(); analytics.trackEvent.mockClear(); + checkout.open = false; }); describe("what the Hosted page prerenders", () => { @@ -121,10 +133,19 @@ describe("what the Hosted page prerenders", () => { expect(data["@type"]).toBe("Product"); expect(data.offers.map((offer: { name: string; price: string }) => [offer.name, offer.price])) .toEqual(tiersOnSale().map((tier) => [tier.name, String(tier.price)])); - // Nothing takes payment yet, so the one claim the page must not make is - // that these are in stock. + // Checkout is closed, so the one claim the page must not make is that + // these are in stock. for (const offer of data.offers) expect(offer.availability).toBe("https://schema.org/PreOrder"); }); + + it("puts the offers in stock once checkout opens", () => { + const offers = (open: boolean) => + JSON.parse(pricingJsonLd("https://dormouse.sh/hosted/", open)).offers.map( + (offer: { availability: string }) => offer.availability, + ); + expect(new Set(offers(true))).toEqual(new Set(["https://schema.org/InStock"])); + expect(new Set(offers(false))).toEqual(new Set(["https://schema.org/PreOrder"])); + }); }); describe("the Hosted card's billing toggle", () => { @@ -183,10 +204,22 @@ const mountCohort = (body: unknown) => describe("the founding card's live half", () => { it("fills in the seats left after hydration", async () => { - const el = await mountCohort({ seatsLeft: 73 }); + const el = await mountCohort({ cohort: FOUNDING_COHORTS_CLOSED, seatsLeft: 73 }); expect(el.textContent).toContain(`73 of ${FOUNDING_COHORT_SIZE} seats left at $${foundingTier().price}`); }); + it("shows no seats for a cohort other than the one whose price it prerendered", async () => { + // A cohort closed since this deploy: its seats would sit beside the old price. + for (const cohort of [FOUNDING_COHORTS_CLOSED + 1, undefined, String(FOUNDING_COHORTS_CLOSED)]) { + const el = await mountCohort({ cohort, seatsLeft: 73, founders: { total: 27, shown: [] } }); + expect(el.textContent, String(cohort)).not.toContain("seats left"); + expect(el.querySelector('[aria-label="27 founders so far"]'), String(cohort)).not.toBeNull(); + act(() => root?.unmount()); + container?.remove(); + root = null; + } + }); + it("draws opted-in founders, then +N for everyone else", async () => { const el = await mountCohort({ founders: { total: 12, shown: [{ name: "Ada", avatar: "/api/hosted/founders/ada.png" }, { name: "kim" }] }, @@ -276,6 +309,35 @@ describe("the buy buttons", () => { expect(analytics.trackEvent.mock.calls).toEqual([[{ name: "hosted plan clicked", plan: HOSTED_MONTHLY.id }]]); }); + it("link to the account's checkout once it opens, carrying the visit's ref", async () => { + checkout.open = true; + history.replaceState(null, "", "/hosted/?ref=settings-voice"); + const el = await mount(async () => new Response("{}", { status: 404 })); + const links = [...el.querySelectorAll('a[aria-label^="Buy "]')] as HTMLAnchorElement[]; + expect(buyButtons(el)).toHaveLength(0); + expect(links.map((a) => a.getAttribute("href"))).toEqual([ + `${CHECKOUT_PAGE}?plan=monthly&ref=settings-voice`, + `${CHECKOUT_PAGE}?plan=founding&ref=settings-voice`, + ]); + links[0]!.addEventListener("click", (event) => event.preventDefault()); + await act(async () => { + links[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(analytics.trackEvent.mock.calls).toEqual([[{ name: "hosted plan clicked", plan: "monthly" }]]); + }); + + it("link without a ref when the visit had none", async () => { + checkout.open = true; + history.replaceState(null, "", "/hosted/"); + const el = await mount(async () => new Response("{}", { status: 404 })); + const yearly = [...el.querySelectorAll("button")].find((b) => b.textContent === "Yearly")!; + await act(async () => { + yearly.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(el.querySelector('a[aria-label="Buy Hosted yearly"]')?.getAttribute("href")).toBe(`${CHECKOUT_PAGE}?plan=yearly`); + }); + it("close on Escape", async () => { await openFirst(); await act(async () => { diff --git a/website/src/pages/Hosted.tsx b/website/src/pages/Hosted.tsx index 92535dbd6..5c9da96b1 100644 --- a/website/src/pages/Hosted.tsx +++ b/website/src/pages/Hosted.tsx @@ -9,7 +9,7 @@ * * See docs/specs/pricing.md -> The Hosted page. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckIcon, CloudArrowUpIcon, @@ -44,18 +44,21 @@ import { type Founders, } from "../lib/hosted-cohorts"; import { + CHECKOUT_OPEN, FOUNDING_COHORT_SIZE, HOSTED_MONTHLY, HOSTED_YEARLY, LIST_ANNUAL, LIST_MONTHLY, YEARLY_SAVING, + checkoutUrl, foundingTier, pricingJsonLd, type Tier, } from "../lib/hosted-pricing"; import { canonicalUrl, siteMeta, sitePath } from "../lib/site-meta"; import { takeVisitRef } from "../lib/hosted-ref"; +import type { HostedRef } from "dormouse-lib/lib/hosted-links"; import { trackEvent, trackPageview, type SiteRouteHandle } from "../lib/site-analytics"; const PAGE_PATH = "/hosted"; @@ -162,15 +165,23 @@ function Price({ tier }: { tier: Pick }) { ); } +/** What a buy button does: counts the click, then goes to `href` or, without one, opens the notice. */ +type Buy = { onBuy: (tier: Tier) => void; href: (tier: Tier) => string | null }; + /** * "Buy" plus what it buys on the label a screen reader reads, since it hears - * the buttons out of their cards. + * the buttons out of their cards. A link once checkout is open. */ -function BuyButton({ tier, label, onBuy }: { tier: Tier; label: string; onBuy: (tier: Tier) => void }) { - return ( +function BuyButton({ tier, label, buy }: { tier: Tier; label: string; buy: Buy }) { + const href = buy.href(tier); + return href ? ( + buy.onBuy(tier)} aria-label={`Buy ${tier.name}`} className={ACTION_CLASS}> + {label} + + ) : (