Public: anyone can read this; the crew can change it.
Settings → Billing shows the selling funnel for the last 7 days: visits to ho… #11263
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11259-settings-billing-shows-the-selling-funne → main 5 commits fork 2 threads · 2 unresolved
19 files
changed +621
−4
docs/features/selling-funnel.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # The selling funnel | |
| 2 | ||
| 3 | Settings → Billing, below Provider and Plans, has "Last 7 days: from visit to paid": one row per step — visits to the anonymous home, plans came into view, a plan's button pressed, forges made, entitlements active through a provider — each with its share of the step before; then a row per plan clicked and the top 5 referrers of visits that pressed a plan. | |
| 4 | - In the terminal: the palette's "From visit to paid" (`tui/screens/funnel.ts`), the same rows, for a forge admin who is a person; `w` flips 7 and 30 days. | |
| 5 | ||
| 6 | - `GET /api/v1/admin/billing/funnel?days=7` (a person admin; `days` 1 to 90) answers the same as JSON. | |
| 7 | - `GET /api/v1/admin/traffic/probe?scope=funnel` counts each step again in SQL; `agrees` false and staying false is a claim to look at. | |
| 8 | - Counts are the traffic view's own (`perf/funnel.ts`): scope `funnel` in `traffic_view`, the daily-salted visitor hash, scrapers never counted, no personal data, kept 90 days. Steps 1 to 3 count a visitor once a day; forges count forges; paid reads `entitlement_change` where the forge's provider is not `none`. | |
| 9 | - The home's script (`web/funnel_door.ts`) posts to `/_funnel` (same-origin, rate-limited, nothing under Do-Not-Track); the plan must be one the forge offers. The free start is the "Get a forge" button, counted under the free plan's name. | |
| 10 | - Not counted: forges made through the API or MCP (only the new-forge form), and the home served to a signed-in person. |
src/contract/openapi/routes/getApiV1AdminBillingFunnel.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/admin/billing/funnel", | |
| 6 | summary: | |
| 7 | "Read the selling funnel (a person admin's token, any scope); query: days (1 to 90, default 7); answers { as_of, days, steps: [{ id, label, count, pct }] for home, plans, click, forge, paid (pct is the share of the step before), clicks_by_plan: [{ plan, count }], referrers: [{ source, count }] (top 5 for visits that clicked a plan) }; scrapers are never counted; GET /api/v1/admin/traffic/probe?scope=funnel counts each step again", | |
| 8 | auth: true, | |
| 9 | role: "admin", | |
| 10 | refusals: { not_found: "the token is not a person who administers the forge" }, | |
| 11 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -36,6 +36,7 @@ export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwne | ||
| 36 | 36 | export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts"; |
| 37 | 37 | export { route as getApiV1 } from "./getApiV1.ts"; |
| 38 | 38 | export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts"; |
| 39 | export { route as getApiV1AdminBillingFunnel } from "./getApiV1AdminBillingFunnel.ts"; | |
| 39 | 40 | export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts"; |
| 40 | 41 | export { route as getApiV1AdminCrawlers } from "./getApiV1AdminCrawlers.ts"; |
| 41 | 42 | export { route as getApiV1AdminDrills } from "./getApiV1AdminDrills.ts"; |
src/http/api/admin.ts +10 −1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { crawlReport } from "../../perf/crawlers.ts"; |
| 10 | 10 | import { getAppById } from "../../core/apps.ts"; |
| 11 | import { FUNNEL_SCOPE, daysOf, funnelReport } from "../../perf/funnel.ts"; | |
| 11 | 12 | import { probeViews } from "../../perf/views_probe.ts"; |
| 12 | 13 | import { kpiProbe } from "../../web/studio/kpi_probe.ts"; |
| 13 | 14 | import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts"; |
| @@ -152,6 +153,14 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 152 | 153 | return json(kpiProbe(ctx.app)); |
| 153 | 154 | }); |
| 154 | 155 | |
| 156 | // The selling funnel for the last `days` (`perf/funnel.ts`): the same figures as Settings → Billing. | |
| 157 | router.add("GET", "/api/v1/admin/billing/funnel", (ctx) => { | |
| 158 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 159 | const denied = accountAdmin(ctx, "ro"); | |
| 160 | if (denied) return denied; | |
| 161 | return json(funnelReport(ctx.app.db, daysOf(ctx.url.searchParams.get("days"))), 200, { "cache-control": "private, no-cache" }); | |
| 162 | }); | |
| 163 | ||
| 155 | 164 | // The traffic view's numbers, each counted again another way (`perf/views_probe.ts`). |
| 156 | 165 | // `scope` is `forge` or an app's id; `number`, `dimension`+`key` and `steps` pick what to show. |
| 157 | 166 | router.add("GET", "/api/v1/admin/traffic/probe", (ctx) => { |
| @@ -160,7 +169,7 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 160 | 169 | if (denied) return denied; |
| 161 | 170 | const q = ctx.url.searchParams; |
| 162 | 171 | const scope = q.get("scope") ?? "forge"; |
| 163 | if (scope !== "forge" && getAppById(ctx.app.db, scope) === undefined) return notFound(); | |
| 172 | if (scope !== "forge" && scope !== FUNNEL_SCOPE && getAppById(ctx.app.db, scope) === undefined) return notFound(); | |
| 164 | 173 | const probed = probeViews(ctx.app.db, scope, q.get("window"), q.get("who") === "scrapers" ? "scrapers" : "people", Date.now(), { |
| 165 | 174 | dimension: q.get("dimension"), |
| 166 | 175 | key: q.get("key"), |
src/http/funnel.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * POST /_funnel: the home page's two funnel signals (#11259) — the plans came | |
| 3 | * into view, a plan's button was pressed. Same rules as the page-view beacon | |
| 4 | * (`http/measure.ts`): same-origin only, opted-out visitors store nothing, a | |
| 5 | * rate per client, a tiny body, every field checked. The plan must be one the | |
| 6 | * forge offers, so nothing a client invents is stored. Always an empty 204 or | |
| 7 | * a short refusal. | |
| 8 | */ | |
| 9 | ||
| 10 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { getPlans } from "../settings/plans.ts"; | |
| 13 | import { optedOut } from "../web/measure.ts"; | |
| 14 | import { noteStep } from "../perf/funnel.ts"; | |
| 15 | import type { Ctx } from "./context.ts"; | |
| 16 | import { readBounded } from "./limits.ts"; | |
| 17 | import { clientAddress, clientBucket } from "./peer.ts"; | |
| 18 | ||
| 19 | /** What the home's buttons call the free start when no plan is free. */ | |
| 20 | export const START = "start"; | |
| 21 | ||
| 22 | const limits = new WeakMap<App, RateLimiter>(); | |
| 23 | const refuse = (status: number, headers?: Record<string, string>): Response => | |
| 24 | new Response(null, { status, headers: { "cache-control": "no-store", ...headers } }); | |
| 25 | ||
| 26 | export async function funnelSubmit(ctx: Ctx): Promise<Response> { | |
| 27 | const { request, app } = ctx; | |
| 28 | if (request.headers.get("origin") !== ctx.url.origin) return refuse(403); | |
| 29 | if (optedOut(request)) return refuse(204); | |
| 30 | let limiter = limits.get(app); | |
| 31 | if (limiter === undefined) { | |
| 32 | limiter = new RateLimiter({ perMinute: 30, adminPerMinute: 30, enabled: true }); | |
| 33 | limits.set(app, limiter); | |
| 34 | } | |
| 35 | const verdict = limiter.take(clientBucket(request), "ro"); | |
| 36 | if (!verdict.allowed) return refuse(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) }); | |
| 37 | const bytes = await readBounded(request, 200); | |
| 38 | if (bytes === "too_large") return refuse(413); | |
| 39 | let raw: unknown; | |
| 40 | try { | |
| 41 | raw = JSON.parse(new TextDecoder().decode(bytes)); | |
| 42 | } catch { | |
| 43 | return refuse(400); | |
| 44 | } | |
| 45 | if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return refuse(400); | |
| 46 | const body = raw as Record<string, unknown>; | |
| 47 | if (Object.keys(body).some((k) => k !== "step" && k !== "plan")) return refuse(400); | |
| 48 | const client = clientAddress(request); | |
| 49 | const agent = request.headers.get("user-agent") ?? ""; | |
| 50 | if (body["step"] === "plans" && body["plan"] === undefined) { | |
| 51 | noteStep(app.db, { kind: "plans", client, agent }); | |
| 52 | return refuse(204); | |
| 53 | } | |
| 54 | if (body["step"] === "click" && typeof body["plan"] === "string") { | |
| 55 | const known = body["plan"] === START || getPlans(app.db).some((p) => p.name === body["plan"]); | |
| 56 | if (!known) return refuse(400); | |
| 57 | noteStep(app.db, { kind: "click", plan: body["plan"], client, agent }); | |
| 58 | return refuse(204); | |
| 59 | } | |
| 60 | return refuse(400); | |
| 61 | } |
src/http/server.ts +2
| @@ -275,6 +275,7 @@ import { familyPage } from "../web/pages/family.ts"; | ||
| 275 | 275 | import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, |
| 276 | 276 | forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 277 | 277 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 278 | import { funnelSubmit } from "./funnel.ts"; | |
| 278 | 279 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| 279 | 280 | import { askedPage } from "../web/studio/asked_page.ts"; |
| 280 | 281 | import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts"; |
| @@ -825,6 +826,7 @@ export function buildRouter(): Router<Handler> { | ||
| 825 | 826 | router.add("GET", "/sw.js", serviceWorker); |
| 826 | 827 | // The page-view beacon's report (http/measure.ts): numbers only, no IP kept. |
| 827 | 828 | router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf)); |
| 829 | router.add("POST", "/_funnel", funnelSubmit); | |
| 828 | 830 | // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only. |
| 829 | 831 | router.add("POST", "/csp-report", cspReportSubmit); |
| 830 | 832 | router.add("GET", "/", home); |
src/perf/funnel.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * The selling funnel (#11259): visits to the anonymous home, views where the | |
| 3 | * plans came into view, clicks on each plan's button, forges made by a visitor, | |
| 4 | * entitlements that became active through a provider. Steps 1-4 are page views | |
| 5 | * of the scope `funnel` in `traffic_view` (so the same visitor hash, daily | |
| 6 | * salt, scraper flag, caps and 90-day retention as the traffic view, and no | |
| 7 | * personal data); the path names the step: `home`, `plans`, `click:<plan>`, | |
| 8 | * `forge`. Step 5 is read from `entitlement_change`, where the provider wrote it. | |
| 9 | * | |
| 10 | * Counting: steps 1-3 count people (a visitor-day once), the forge step counts | |
| 11 | * forges, the paid step counts changes. Scrapers are never counted. The | |
| 12 | * percentage is a step over the step before it. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import type { Origin } from "./sources.ts"; | |
| 17 | import { flushViews, noteView } from "./views_store.ts"; | |
| 18 | import { DAY_MS } from "./views_report.ts"; | |
| 19 | ||
| 20 | export const FUNNEL_SCOPE = "funnel"; | |
| 21 | export const FUNNEL_DAYS_MAX = 90; | |
| 22 | export const NO_ORIGIN: Origin = { refHost: "", source: "", medium: "", campaign: "", out: "" }; | |
| 23 | ||
| 24 | export type StepKind = "home" | "plans" | "click" | "forge"; | |
| 25 | ||
| 26 | export interface Step { | |
| 27 | readonly id: "home" | "plans" | "click" | "forge" | "paid"; | |
| 28 | readonly label: string; | |
| 29 | readonly count: number; | |
| 30 | /** Share of the step before, in percent with one decimal; null for the first or when that was zero. */ | |
| 31 | readonly pct: number | null; | |
| 32 | } | |
| 33 | ||
| 34 | export interface FunnelReport { | |
| 35 | readonly as_of: string; | |
| 36 | readonly days: number; | |
| 37 | readonly steps: Step[]; | |
| 38 | readonly clicks_by_plan: { plan: string; count: number }[]; | |
| 39 | /** Where visits that clicked a plan came from, top 5; "(direct)" when no referrer or campaign. */ | |
| 40 | readonly referrers: { source: string; count: number }[]; | |
| 41 | } | |
| 42 | ||
| 43 | /** `days` from the query: a whole number 1..90, else 7. */ | |
| 44 | export function daysOf(raw: string | null): number { | |
| 45 | const n = raw !== null && /^\d{1,3}$/.test(raw) ? Number(raw) : 7; | |
| 46 | return n >= 1 && n <= FUNNEL_DAYS_MAX ? n : 7; | |
| 47 | } | |
| 48 | ||
| 49 | export const pathOf = (kind: StepKind, plan = ""): string => (kind === "click" ? `click:${plan.slice(0, 60)}` : kind); | |
| 50 | ||
| 51 | /** Note one step for a visitor; no SQL (the view buffer). */ | |
| 52 | export function noteStep( | |
| 53 | db: Database, | |
| 54 | step: { kind: StepKind; plan?: string; client: string; agent: string; origin?: Origin; at?: number }, | |
| 55 | ): boolean { | |
| 56 | return noteView(db, { | |
| 57 | scope: FUNNEL_SCOPE, | |
| 58 | path: pathOf(step.kind, step.plan), | |
| 59 | origin: step.origin ?? NO_ORIGIN, | |
| 60 | client: step.client, | |
| 61 | agent: step.agent, | |
| 62 | ...(step.at === undefined ? {} : { at: step.at }), | |
| 63 | }); | |
| 64 | } | |
| 65 | ||
| 66 | const pct = (n: number, of: number): number | null => (of === 0 ? null : Math.round((n * 1000) / of) / 10); | |
| 67 | ||
| 68 | /** Entitlements that became active through a provider since `since`: the sql route and the row route agree by construction of the probe. */ | |
| 69 | export function paidSql(db: Database, since: number): number { | |
| 70 | return ( | |
| 71 | db | |
| 72 | .query<{ n: number }, [string]>( | |
| 73 | `SELECT COUNT(*) AS n FROM entitlement_change c | |
| 74 | WHERE c.at >= ?1 AND c.to_status = 'active' AND (c.from_status != 'active' OR c.from_plan != c.to_plan) | |
| 75 | AND EXISTS (SELECT 1 FROM entitlement e WHERE e.forge = c.forge AND e.provider NOT IN ('none', ''))`, | |
| 76 | ) | |
| 77 | .get(new Date(since).toISOString())?.n ?? 0 | |
| 78 | ); | |
| 79 | } | |
| 80 | ||
| 81 | interface Row { | |
| 82 | day: string; | |
| 83 | visitor: string; | |
| 84 | path: string; | |
| 85 | source: string; | |
| 86 | } | |
| 87 | ||
| 88 | export function funnelReport(db: Database, days: number, now: number = Date.now()): FunnelReport { | |
| 89 | flushViews(db, now); | |
| 90 | const since = now - days * DAY_MS; | |
| 91 | const rows = db | |
| 92 | .query<Row, [string, number]>( | |
| 93 | "SELECT day, visitor, path, source FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 0 ORDER BY at, id", | |
| 94 | ) | |
| 95 | .all(FUNNEL_SCOPE, since); | |
| 96 | const people = { home: new Set<string>(), plans: new Set<string>(), click: new Set<string>() }; | |
| 97 | const byPlan = new Map<string, Set<string>>(); | |
| 98 | const sourceOf = new Map<string, string>(); | |
| 99 | let forges = 0; | |
| 100 | for (const r of rows) { | |
| 101 | const who = `${r.day}\0${r.visitor}`; | |
| 102 | if (r.path === "home") { | |
| 103 | people.home.add(who); | |
| 104 | if (!sourceOf.has(who)) sourceOf.set(who, r.source); | |
| 105 | } else if (r.path === "plans") people.plans.add(who); | |
| 106 | else if (r.path === "forge") forges += 1; | |
| 107 | else if (r.path.startsWith("click:")) { | |
| 108 | people.click.add(who); | |
| 109 | const plan = r.path.slice(6); | |
| 110 | let set = byPlan.get(plan); | |
| 111 | if (set === undefined) byPlan.set(plan, (set = new Set())); | |
| 112 | set.add(who); | |
| 113 | } | |
| 114 | } | |
| 115 | const sources = new Map<string, number>(); | |
| 116 | for (const who of people.click) { | |
| 117 | const s = sourceOf.get(who) || "(direct)"; | |
| 118 | sources.set(s, (sources.get(s) ?? 0) + 1); | |
| 119 | } | |
| 120 | const counts = [people.home.size, people.plans.size, people.click.size, forges, paidSql(db, since)]; | |
| 121 | const labels: [Step["id"], string][] = [ | |
| 122 | ["home", "Visits to home"], | |
| 123 | ["plans", "Saw the plans"], | |
| 124 | ["click", "Clicked a plan"], | |
| 125 | ["forge", "Forges made"], | |
| 126 | ["paid", "Paid"], | |
| 127 | ]; | |
| 128 | const top = <T extends { count: number }>(list: T[], key: (t: T) => string, n: number): T[] => | |
| 129 | list.sort((a, b) => b.count - a.count || key(a).localeCompare(key(b))).slice(0, n); | |
| 130 | return { | |
| 131 | as_of: new Date(now).toISOString(), | |
| 132 | days, | |
| 133 | steps: labels.map(([id, label], i) => ({ id, label, count: counts[i]!, pct: i === 0 ? null : pct(counts[i]!, counts[i - 1]!) })), | |
| 134 | clicks_by_plan: top([...byPlan].map(([plan, s]) => ({ plan, count: s.size })), (t) => t.plan, 50), | |
| 135 | referrers: top([...sources].map(([source, count]) => ({ source, count })), (t) => t.source, 5), | |
| 136 | }; | |
| 137 | } |
src/perf/views_probe.ts +29
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { FUNNEL_SCOPE, funnelReport, paidSql } from "./funnel.ts"; | |
| 10 | 11 | import { |
| 11 | 12 | DIMENSIONS, |
| 12 | 13 | DIRECT, |
| @@ -16,6 +17,7 @@ import { | ||
| 16 | 17 | readVisits, |
| 17 | 18 | sinceOf, |
| 18 | 19 | windowOfViews, |
| 20 | WINDOWS, | |
| 19 | 21 | type Dimension, |
| 20 | 22 | type Filter, |
| 21 | 23 | type ViewWindow, |
| @@ -81,6 +83,7 @@ export function probeViews( | ||
| 81 | 83 | one("SELECT COUNT(*) AS n FROM (SELECT DISTINCT day, visitor FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 1)", scope, since), |
| 82 | 84 | { scope, window }), |
| 83 | 85 | ]; |
| 86 | if (scope === FUNNEL_SCOPE) numbers.push(...probeFunnel(db, window, since, now)); | |
| 84 | 87 | const dimension = DIMENSIONS.find((d) => d === ask.dimension); |
| 85 | 88 | if (dimension !== undefined && typeof ask.key === "string") { |
| 86 | 89 | const key = ask.key === DIRECT && dimension === "source" ? "" : ask.key; |
| @@ -124,3 +127,29 @@ export function probeViews( | ||
| 124 | 127 | } |
| 125 | 128 | return { as_of: new Date(now).toISOString(), scope, window, numbers }; |
| 126 | 129 | } |
| 130 | ||
| 131 | /** | |
| 132 | * The funnel's steps (`funnel.ts`), each counted again in SQL over the stored | |
| 133 | * views (the report counts in memory); the paid step again from the change rows. | |
| 134 | */ | |
| 135 | function probeFunnel(db: Database, window: ViewWindow, since: number, now: number): ProbedView[] { | |
| 136 | const report = funnelReport(db, WINDOWS[window], now); | |
| 137 | const one = (sql: string, ...args: (string | number)[]): number => db.query<{ n: number }, (string | number)[]>(sql).get(...args)?.n ?? 0; | |
| 138 | const distinct = (like: string): number => | |
| 139 | one("SELECT COUNT(*) AS n FROM (SELECT DISTINCT day, visitor FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 0 AND path LIKE ?3)", FUNNEL_SCOPE, since, like); | |
| 140 | const sql: Record<string, number> = { | |
| 141 | home: distinct("home"), | |
| 142 | plans: distinct("plans"), | |
| 143 | click: distinct("click:%"), | |
| 144 | forge: one("SELECT COUNT(*) AS n FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 0 AND path = 'forge'", FUNNEL_SCOPE, since), | |
| 145 | paid: paidSql(db, since), | |
| 146 | }; | |
| 147 | const counts: Record<string, string> = { | |
| 148 | home: "Visitors who were served the anonymous home page, once a day each; scrapers apart.", | |
| 149 | plans: "Visitors for whom the plans came into view, once a day each.", | |
| 150 | click: "Visitors who pressed a plan's button (a payment link or the free start), once a day each.", | |
| 151 | forge: "Forges made from the new-forge form (free or by code).", | |
| 152 | paid: "Entitlements that became active through a billing provider.", | |
| 153 | }; | |
| 154 | return report.steps.map((s) => made(`funnel_${s.id}`, s.label, counts[s.id]!, s.count, sql[s.id]!, { window, days: report.days })); | |
| 155 | } |
src/tui/screens/entries.ts +2
| @@ -35,6 +35,7 @@ import { entry as disks } from "./disks.ts"; | ||
| 35 | 35 | import { entry as domains } from "./domains.ts"; |
| 36 | 36 | import { entry as edgeOnly } from "./edgeonly.ts"; |
| 37 | 37 | import { entry as traffic } from "./trafficview.ts"; |
| 38 | import { entry as funnelEntry } from "./funnel.ts"; | |
| 38 | 39 | import { entry as featureReview } from "./features.ts"; |
| 39 | 40 | import { entry as feed } from "./feed.ts"; |
| 40 | 41 | import { entry as featureFeed } from "./featurefeed.ts"; |
| @@ -123,6 +124,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 123 | 124 | access, |
| 124 | 125 | edgeOnly, |
| 125 | 126 | traffic, |
| 127 | funnelEntry, | |
| 126 | 128 | audit, |
| 127 | 129 | securityControls, |
| 128 | 130 | secrets, |
src/tui/screens/funnel.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * From visit to paid, in the terminal (#11259): the last 7 days of Settings → | |
| 3 | * Billing's block (`perf/funnel.ts`), the same figures, offered to the same | |
| 4 | * people: a forge admin who is a person. Anyone else is offered nothing and, | |
| 5 | * were the screen reached, sees no rows. `w` flips 7 and 30 days. Read-only. | |
| 6 | */ | |
| 7 | ||
| 8 | import { funnelReport, type FunnelReport } from "../../perf/funnel.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import type { Session } from "../session.ts"; | |
| 12 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 13 | import type { PaletteEntry } from "./entries.ts"; | |
| 14 | ||
| 15 | const mayRead = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 16 | ||
| 17 | /** One row per step with its share of the step before, then clicks by plan, then referrers. */ | |
| 18 | export function funnelRows(r: FunnelReport): ListRow[] { | |
| 19 | const rows: ListRow[] = r.steps.map((s) => ({ title: s.label, meta: s.pct === null ? `${s.count}` : `${s.count} · ${s.pct}% of the step before` })); | |
| 20 | for (const c of r.clicks_by_plan) rows.push({ title: `Clicked ${c.plan}`, meta: `${c.count}` }); | |
| 21 | for (const x of r.referrers) rows.push({ title: `Plan clicks from ${x.source}`, meta: `${x.count}` }); | |
| 22 | return rows; | |
| 23 | } | |
| 24 | ||
| 25 | export function funnelScreen(f: Session, ctx: Ctx): Screen { | |
| 26 | const state = new ListState(); | |
| 27 | let days = 7; | |
| 28 | const screen: Screen = { | |
| 29 | title: "From visit to paid", | |
| 30 | keys: [binding(["j", "k"], "move cursor"), binding(["w"], "7 or 30 days"), binding(["esc"], "back")], | |
| 31 | render(area, s) { | |
| 32 | const rows = mayRead(f) ? funnelRows(funnelReport(f.db, days)) : []; | |
| 33 | state.clamp(rows.length); | |
| 34 | drawList(s, area, ctx.theme.c, state, rows); | |
| 35 | }, | |
| 36 | key(e) { | |
| 37 | if (!mayRead(f)) return false; | |
| 38 | if (e.key === "w") { | |
| 39 | days = days === 7 ? 30 : 7; | |
| 40 | return true; | |
| 41 | } | |
| 42 | return state.key(e, funnelRows(funnelReport(f.db, days)).length); | |
| 43 | }, | |
| 44 | }; | |
| 45 | return screen; | |
| 46 | } | |
| 47 | ||
| 48 | export const entry: PaletteEntry = { | |
| 49 | id: "from-visit-to-paid", | |
| 50 | title: "From visit to paid", | |
| 51 | hint: "the last 7 days: home visits, plans seen, plan clicks, forges made, paid", | |
| 52 | offered: mayRead, | |
| 53 | run: (f, ctx) => ctx.push(funnelScreen(f, ctx)), | |
| 54 | }; |
src/web/funnel_door.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * The home's side of the selling funnel (#11259): the first step noted when a | |
| 3 | * stranger is served the home, the name the free start goes by, the forge made | |
| 4 | * from the form, and the small script that reports the plans coming into view | |
| 5 | * and a plan's button pressed (`http/funnel.ts` receives it). Nothing is sent | |
| 6 | * under Do-Not-Track or the opt-out cookie; the server checks both again. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Ctx } from "../http/context.ts"; | |
| 10 | import { clientAddress } from "../http/peer.ts"; | |
| 11 | import { START } from "../http/funnel.ts"; | |
| 12 | import { noteStep } from "../perf/funnel.ts"; | |
| 13 | import { originOf } from "../perf/sources.ts"; | |
| 14 | import type { Plan } from "../settings/plans.ts"; | |
| 15 | import { optedOut } from "./measure.ts"; | |
| 16 | ||
| 17 | /** The free plan's name, or `start` when no plan is free. */ | |
| 18 | export const freePlanName = (plans: readonly Plan[]): string => plans.find((p) => p.priceUsd === 0)?.name ?? START; | |
| 19 | ||
| 20 | const agentOf = (ctx: Ctx): string => ctx.request.headers.get("user-agent") ?? ""; | |
| 21 | ||
| 22 | export function noteHomeVisit(ctx: Ctx): void { | |
| 23 | if (optedOut(ctx.request)) return; | |
| 24 | noteStep(ctx.app.db, { | |
| 25 | kind: "home", | |
| 26 | client: clientAddress(ctx.request), | |
| 27 | agent: agentOf(ctx), | |
| 28 | // The referrer header, as the beacon's document.referrer, and the query for utm_ tags. | |
| 29 | origin: originOf(ctx.request.headers.get("referer") ?? undefined, ctx.url.search, ctx.url.host, undefined), | |
| 30 | }); | |
| 31 | } | |
| 32 | ||
| 33 | /** A forge was made from the form; the visitor's hash is the home's, so the steps line up. */ | |
| 34 | export function noteForgeMade(ctx: Ctx): void { | |
| 35 | if (optedOut(ctx.request)) return; | |
| 36 | noteStep(ctx.app.db, { kind: "forge", client: clientAddress(ctx.request), agent: agentOf(ctx) }); | |
| 37 | } | |
| 38 | ||
| 39 | /** Runs once the page is ready: plans in view once; a click on a [data-plan] sends its plan. */ | |
| 40 | export const funnelJs = `(function(n,d){ | |
| 41 | if(n.doNotTrack=="1"||/(^|; )forge_measure=off/.test(d.cookie)||!n.sendBeacon)return; | |
| 42 | function p(o){n.sendBeacon("/_funnel",JSON.stringify(o))} | |
| 43 | var l=d.querySelector(".door-plans"); | |
| 44 | if(l&&window.IntersectionObserver){var io=new IntersectionObserver(function(e){e.some(function(x){return x.isIntersecting})&&(io.disconnect(),p({step:"plans"}))});io.observe(l)} | |
| 45 | d.addEventListener("click",function(e){var t=e.target,k=t&&t.closest&&t.closest("[data-plan]");k&&p({step:"click",plan:k.getAttribute("data-plan")})},true) | |
| 46 | })(navigator,document)`; |
src/web/pages/billing.ts +15
| @@ -14,6 +14,7 @@ import { SIZES } from "../../spawn/sizes.ts"; | ||
| 14 | 14 | import { BILLING_SIZES, parseBillingLinks } from "../../settings/billing.ts"; |
| 15 | 15 | import { getPlans } from "../../settings/plans.ts"; |
| 16 | 16 | import { changeBillingLinks, readBillingLinks } from "../../actions/signup.ts"; |
| 17 | import { funnelReport, type FunnelReport } from "../../perf/funnel.ts"; | |
| 17 | 18 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 18 | 19 | import { field, readForm } from "../form.ts"; |
| 19 | 20 | import { html, type Html } from "../html.ts"; |
| @@ -119,6 +120,19 @@ export async function billingSetLinks(ctx: Ctx): Promise<Response> { | ||
| 119 | 120 | return billingView(ctx, undefined, {}, undefined, 422, { values: next, error }); |
| 120 | 121 | } |
| 121 | 122 | |
| 123 | /** Last 7 days, from visit to paid: one `dl` row per step with its share of the step before; clicks by plan and where they came from as rows under it. */ | |
| 124 | function funnelRow(r: FunnelReport): Html { | |
| 125 | const row = (label: string, value: string): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`; | |
| 126 | return html`<section class="settings-block" id="funnel"> | |
| 127 | <h2>Last 7 days: from visit to paid</h2> | |
| 128 | <dl class="status-rows" data-funnel> | |
| 129 | ${r.steps.map((s) => row(s.label, s.pct === null ? `${s.count}` : `${s.count} · ${s.pct}% of the step before`))} | |
| 130 | ${r.clicks_by_plan.map((c) => row(`Clicked ${c.plan}`, `${c.count}`))} | |
outdated
Principles 2 and 7: the real rows are hidden in one sentence of arrows with percentages and a plan breakdown in parentheses. Render each step as a |
||
| 131 | ${r.referrers.map((x) => row(`Plan clicks from ${x.source}`, `${x.count}`))} | |
| 132 | </dl> | |
| 133 | </section>`; | |
| 134 | } | |
| 135 | ||
| 122 | 136 | // GET /settings/billing |
| 123 | 137 | export function billingPage(ctx: Ctx): Response { |
| 124 | 138 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -172,6 +186,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = { | ||
| 172 | 186 | ? html`<p class="muted">No forge has been sent from here.</p>` |
| 173 | 187 | : html`<ul class="list setting-list">${rows}</ul>`} |
| 174 | 188 | </section> |
| 189 | ${funnelRow(funnelReport(ctx.app.db, 7))} | |
| 175 | 190 | ${linksSection(ctx, links)} |
| 176 | 191 | ${codesSection(ctx, shown, values, error)}`; |
| 177 | 192 | const response = page({ |
src/web/pages/new-forge.ts +2
| @@ -33,6 +33,7 @@ import { dropGift, keepGift, keptGift } from "../gift_cookie.ts"; | ||
| 33 | 33 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 34 | 34 | import { field, readForm, type Form } from "../form.ts"; |
| 35 | 35 | import { html, type Html } from "../html.ts"; |
| 36 | import { noteForgeMade } from "../funnel_door.ts"; | |
| 36 | 37 | import { page } from "../layout.ts"; |
| 37 | 38 | import { pickList } from "../pick.ts"; |
| 38 | 39 | import { btn, field as formField, row, selectField } from "../ui.ts"; |
| @@ -553,6 +554,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 553 | 554 | title: "New forge", |
| 554 | 555 | body: formView(ctx, user, values, {}, planView(ctx, user, values, door.body), readsFor(ctx, user)), |
| 555 | 556 | }); |
| 557 | noteForgeMade(ctx); | |
| 556 | 558 | const response = page( |
| 557 | 559 | { ctx, title: "New forge", body: madeView(door.body) }, |
| 558 | 560 | door.status, |
src/web/plans_row.ts +1 −1
| @@ -15,7 +15,7 @@ export function plansRow(plans: readonly Plan[], links: BillingLinks = {}, price | ||
| 15 | 15 | return html`<ul class="door-plans">${plans.map((p) => { |
| 16 | 16 | const w = planWords(p, priced, links); |
| 17 | 17 | return html`<li><strong>${w.name}</strong> <span class="muted">${w.price}</span>${ |
| 18 | w.buy === undefined ? "" : html` <a href="${w.buy}" rel="noopener noreferrer" target="_blank">Pay for ${w.name}</a>` | |
| 18 | w.buy === undefined ? "" : html` <a href="${w.buy}" data-plan="${w.name}" rel="noopener noreferrer" target="_blank">Pay for ${w.name}</a>` | |
| 19 | 19 | }<span class="muted small">${w.is}</span></li>`; |
| 20 | 20 | })}</ul>`; |
| 21 | 21 | } |
src/web/studio/home.ts +5 −2
| @@ -25,6 +25,7 @@ import { accountsClosedIn } from "../../core/users.ts"; | ||
| 25 | 25 | import { markTold, untoldAnswer } from "../../core/requests.ts"; |
| 26 | 26 | import { requestsCard } from "../requests_card.ts"; |
| 27 | 27 | import { plansRow } from "../plans_row.ts"; |
| 28 | import { freePlanName, funnelJs, noteHomeVisit } from "../funnel_door.ts"; | |
| 28 | 29 | import { publicPlans } from "../../settings/plans.ts"; |
| 29 | 30 | import { getDoorSettings } from "../../settings/door.ts"; |
| 30 | 31 | import { GET_A_FORGE_PATH } from "../../billing/plan_words.ts"; |
| @@ -772,6 +773,8 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 772 | 773 | // Render is what is left of the page once the shelf read is taken out. |
| 773 | 774 | const rendering = performance.now(); |
| 774 | 775 | const visitor = ctx.auth.user === undefined; |
| 776 | // The funnel's first step: a stranger at the home (#11259), no SQL here. | |
| 777 | if (visitor && ctx.url.pathname === "/" && ctx.request.method === "GET") noteHomeVisit(ctx); | |
| 775 | 778 | const admin = ctx.auth.user?.isAdmin === true && ctx.url.pathname === "/front"; |
| 776 | 779 | // A share sheet lands here with the shared words in the query; signing in |
| 777 | 780 | // comes back to them rather than to a bare home, so they are not lost. |
| @@ -886,7 +889,7 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 886 | 889 | Hosted for you, or free and open source on a machine you keep.${accounts ? "" : mode === "invite" ? " New accounts on this forge are by invitation." : " This forge is not taking new accounts."} |
| 887 | 890 | </p> |
| 888 | 891 | ${plansRow(publicPlans(allPlans), links)} |
| 889 | <div class="bar">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary", size: "lg" })}</div> | |
| 892 | <div class="bar" data-plan="${freePlanName(allPlans)}">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary", size: "lg" })}</div> | |
| 890 | 893 | <p class="door-support">Or run it yourself, free and open source: <a href="${FORGE_REPO}">read the step-by-step guide</a>.</p> |
| 891 | 894 | <pre class="door-code"><code>git clone ${FORGE_REPO}.git |
| 892 | 895 | cd forge |
| @@ -913,7 +916,7 @@ bun run src/main.ts</code></pre> | ||
| 913 | 916 | title: "forge: software for what you're building, yours to keep", |
| 914 | 917 | description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.", |
| 915 | 918 | body, |
| 916 | scriptJs: `${liveClientJs}\n${liveRegionsJs}`, | |
| 919 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}`, | |
| 917 | 920 | jsonLd: siteStructuredData(origin, running().version), |
| 918 | 921 | ...(visitor |
| 919 | 922 | ? { |
test/forge-looks.test.ts +1
| @@ -54,6 +54,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 54 | 54 | "/verify": "answers 400 without the emailed code", |
| 55 | 55 | "/logout": "a mutation, answers 404 to a GET", |
| 56 | 56 | "/csp-report": "a mutation: where browsers report what the page policy blocked", |
| 57 | "/_funnel": "a mutation: the home page's beacon of funnel signals, POST only (#11259); covered by test/funnel.test.ts", | |
| 57 | 58 | "/theme": "a mutation, answers 404 to a GET", |
| 58 | 59 | "/tell": "answers 404 to a GET", |
| 59 | 60 | "/asks": "redirects to /#needs-you", |
test/funnel.test.ts added +167
| @@ -0,0 +1,167 @@ | ||
| 1 | /** | |
| 2 | * The selling funnel (#11259): a visitor goes home → plan click → free forge | |
| 3 | * and each count moves by one; a scraper's visit moves none; a provider's | |
| 4 | * activation is the paid step; Settings → Billing renders the row and | |
| 5 | * `/api/v1/admin/billing/funnel` and the traffic probe say the same. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { putEntitlement } from "../src/billing/store.ts"; | |
| 13 | import { createSession } from "../src/core/sessions.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { ok } from "../src/kernel/result.ts"; | |
| 18 | import { daysOf, funnelReport } from "../src/perf/funnel.ts"; | |
| 19 | import { funnelRows } from "../src/tui/screens/funnel.ts"; | |
| 20 | import { funnelJs } from "../src/web/funnel_door.ts"; | |
| 21 | import { getPlans } from "../src/settings/plans.ts"; | |
| 22 | import { bootHostedChild } from "../src/spawn/host.ts"; | |
| 23 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | const PERSON = "Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0 Safari/537.36"; | |
| 29 | const BOT = "Mozilla/5.0 (compatible; Reflectionbot/1.0)"; | |
| 30 | ||
| 31 | let dir: string; | |
| 32 | let app: App; | |
| 33 | let servers: Servers; | |
| 34 | let cookie = ""; | |
| 35 | let token = ""; | |
| 36 | let freePlan = ""; | |
| 37 | ||
| 38 | const counts = () => Object.fromEntries(funnelReport(app.db, 7).steps.map((s) => [s.id, s.count])); | |
| 39 | const home = (agent: string, extra: Record<string, string> = {}) => | |
| 40 | fetch(`${servers.url}/`, { headers: { "user-agent": agent, ...extra } }); | |
| 41 | const signal = (agent: string, body: unknown, extra: Record<string, string> = {}) => | |
| 42 | fetch(`${servers.url}/_funnel`, { method: "POST", headers: { "user-agent": agent, origin: servers.url, ...extra }, body: JSON.stringify(body) }); | |
| 43 | const makeForge = (agent: string, name: string) => | |
| 44 | fetch(`${servers.url}/forges/new`, { | |
| 45 | method: "POST", | |
| 46 | redirect: "manual", | |
| 47 | headers: { "user-agent": agent, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 48 | body: new URLSearchParams({ name }), | |
| 49 | }); | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dir = await mkdtemp(join(tmpdir(), "forge-funnel-")); | |
| 53 | app = createApp({ dataDir: dir, port: 0 }); | |
| 54 | servers = await serve(app); | |
| 55 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", password: "correct-horse-battery", adminIfFirst: true }); | |
| 56 | if (!a.ok) throw new Error(a.error); | |
| 57 | cookie = `forge_session=${createSession(app.db, a.value.id).token}`; | |
| 58 | token = createToken(app.db, a.value.id, "ro", "ro").token; | |
| 59 | freePlan = getPlans(app.db).find((p) => p.priceUsd === 0)?.name ?? "start"; | |
| 60 | useProbe(app, () => async () => true); | |
| 61 | usePlacement(app, { place: async (p) => ok({ url: `https://${p.name}.fake.test`, bodyId: `fake-${p.name}` }), retire: async () => ok() }); | |
| 62 | }, 30_000); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | await bootHostedChild(app, "funnel-one"); | |
| 66 | servers.stop(); | |
| 67 | app.close(); | |
| 68 | await rm(dir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | describe("the selling funnel", () => { | |
| 72 | test("a visitor moves each step by one; a scraper moves none; the home carries the plan hooks", async () => { | |
| 73 | expect(counts()).toEqual({ home: 0, plans: 0, click: 0, forge: 0, paid: 0 }); | |
| 74 | const page = await (await home(PERSON)).text(); | |
| 75 | expect(page).toContain(`data-plan="${freePlan}"`); | |
| 76 | expect(funnelJs).toContain("/_funnel"); | |
| 77 | expect(page).toContain("page-"); | |
| 78 | expect(counts()).toMatchObject({ home: 1 }); | |
| 79 | expect((await signal(PERSON, { step: "plans" })).status).toBe(204); | |
| 80 | expect(counts()).toMatchObject({ home: 1, plans: 1 }); | |
| 81 | expect((await signal(PERSON, { step: "click", plan: freePlan })).status).toBe(204); | |
| 82 | expect(counts()).toMatchObject({ home: 1, plans: 1, click: 1 }); | |
| 83 | const made = await makeForge(PERSON, "funnel-one"); | |
| 84 | expect(made.status).toBeLessThan(400); | |
| 85 | expect(counts()).toEqual({ home: 1, plans: 1, click: 1, forge: 1, paid: 0 }); | |
| 86 | // The same visitor again the same day is the same visit. | |
| 87 | await home(PERSON); | |
| 88 | await signal(PERSON, { step: "plans" }); | |
| 89 | expect(counts()).toMatchObject({ home: 1, plans: 1 }); | |
| 90 | ||
| 91 | const before = counts(); | |
| 92 | await home(BOT); | |
| 93 | await signal(BOT, { step: "plans" }); | |
| 94 | await signal(BOT, { step: "click", plan: freePlan }); | |
| 95 | expect(counts()).toEqual(before); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a plan the forge does not offer, a stray field or a cross-origin post stores nothing", async () => { | |
| 99 | const before = counts(); | |
| 100 | expect((await signal("Mozilla/5.0 Firefox/130", { step: "click", plan: "nonesuch" })).status).toBe(400); | |
| 101 | expect((await signal("Mozilla/5.0 Firefox/130", { step: "click", plan: freePlan, who: "me" })).status).toBe(400); | |
| 102 | expect((await signal("Mozilla/5.0 Firefox/130", { step: "other" })).status).toBe(400); | |
| 103 | expect((await signal("Mozilla/5.0 Firefox/130", { step: "plans" }, { origin: "https://evil.example" })).status).toBe(403); | |
| 104 | expect((await signal("Mozilla/5.0 Firefox/130", { step: "plans" }, { dnt: "1" })).status).toBe(204); | |
| 105 | expect(counts()).toEqual(before); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a provider's activation is the paid step; a hand grant is not", () => { | |
| 109 | putEntitlement(app.db, { forge: "payer", plan: "medium", status: "active", source: "granted", provider: "none" }, "small", "x"); | |
| 110 | expect(counts()).toMatchObject({ paid: 0 }); | |
| 111 | putEntitlement(app.db, { forge: "payer2", plan: "medium", status: "active", source: "stripe", provider: "stripe" }, "small", ""); | |
| 112 | expect(counts()).toMatchObject({ paid: 1 }); | |
| 113 | expect(funnelReport(app.db, 7).steps.find((s) => s.id === "paid")!.pct).toBe(100); | |
| 114 | }); | |
| 115 | ||
| 116 | test("referrers of visits that clicked a plan are listed, top five, direct apart", async () => { | |
| 117 | const agent = "Mozilla/5.0 Safari/605 Referred"; | |
| 118 | await home(agent, { referer: "https://news.example/story?x=1" }); | |
| 119 | await signal(agent, { step: "click", plan: freePlan }); | |
| 120 | const r = funnelReport(app.db, 7); | |
| 121 | expect(r.referrers).toContainEqual({ source: "news.example", count: 1 }); | |
| 122 | expect(r.referrers).toContainEqual({ source: "(direct)", count: 1 }); | |
| 123 | expect(r.clicks_by_plan).toEqual([{ plan: freePlan, count: 2 }]); | |
| 124 | expect(r.referrers.length).toBeLessThanOrEqual(5); | |
| 125 | }); | |
| 126 | ||
| 127 | test("Settings → Billing renders the row; the API and the probe say the same", async () => { | |
| 128 | const r = funnelReport(app.db, 7); | |
| 129 | const page = await (await fetch(`${servers.url}/settings/billing`, { headers: { cookie } })).text(); | |
| 130 | const block = /<dl class="status-rows" data-funnel>([\s\S]*?)<\/dl>/.exec(page)?.[1] ?? ""; | |
| 131 | const rows = [...block.matchAll(/<dt>([^<]*)<\/dt><dd>([^<]*)<\/dd>/g)].map((m) => [m[1], m[2]]); | |
| 132 | expect(rows.slice(0, 5).map((x) => x[0])).toEqual(["Visits to home", "Saw the plans", "Clicked a plan", "Forges made", "Paid"]); | |
| 133 | expect(rows[0]![1]).toBe(`${r.steps[0]!.count}`); | |
| 134 | expect(rows[1]![1]).toStartWith(`${r.steps[1]!.count}`); | |
| 135 | expect(rows[1]![1]).toContain("of the step before"); | |
| 136 | expect(rows).toContainEqual([`Clicked ${freePlan}`, "2"]); | |
| 137 | expect(page).toContain("Plan clicks from "); | |
| 138 | expect(page).toContain("Last 7 days: from visit to paid"); | |
| 139 | expect(page).not.toContain("→ "); | |
| 140 | expect(page.indexOf('id="funnel"')).toBeGreaterThan(page.indexOf("<h2>Plans</h2>")); | |
| 141 | ||
| 142 | const api = await fetch(`${servers.url}/api/v1/admin/billing/funnel?days=7`, { headers: { authorization: `Bearer ${token}` } }); | |
| 143 | expect(api.status).toBe(200); | |
| 144 | const body = (await api.json()) as typeof r; | |
| 145 | expect(body.days).toBe(7); | |
| 146 | expect(body.steps.map((s) => s.count)).toEqual(r.steps.map((s) => s.count)); | |
| 147 | expect((await fetch(`${servers.url}/api/v1/admin/billing/funnel`)).status).toBe(404); | |
| 148 | ||
| 149 | const probe = await fetch(`${servers.url}/api/v1/admin/traffic/probe?scope=funnel`, { headers: { authorization: `Bearer ${token}` } }); | |
| 150 | const probed = (await probe.json()) as { numbers: { id: string; value: number; recomputed: number; agrees: boolean }[] }; | |
| 151 | const steps = probed.numbers.filter((n) => n.id.startsWith("funnel_")); | |
| 152 | expect(steps.map((n) => n.id)).toEqual(["funnel_home", "funnel_plans", "funnel_click", "funnel_forge", "funnel_paid"]); | |
| 153 | expect(steps.every((n) => n.agrees)).toBe(true); | |
| 154 | expect(steps.map((n) => n.value)).toEqual(r.steps.map((s) => s.count)); | |
| 155 | }); | |
| 156 | ||
| 157 | test("the terminal's rows are the page's: a step, its share, clicks by plan", () => { | |
| 158 | const r = funnelReport(app.db, 7); | |
| 159 | const rows = funnelRows(r); | |
| 160 | expect(rows.slice(0, 5).map((x) => x.title)).toEqual(r.steps.map((x) => x.label)); | |
| 161 | expect(rows.some((x) => x.title === `Clicked ${freePlan}` && x.meta === "2")).toBe(true); | |
| 162 | }); | |
| 163 | ||
| 164 | test("days is a whole number 1 to 90, else a week", () => { | |
| 165 | expect([daysOf(null), daysOf("30"), daysOf("0"), daysOf("91"), daysOf("x"), daysOf("1e2")]).toEqual([7, 30, 7, 7, 7, 7]); | |
| 166 | }); | |
| 167 | }); |
test/palette-roster.test.ts +1
| @@ -164,6 +164,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 164 | 164 | ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"], |
| 165 | 165 | ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"], |
| 166 | 166 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], |
| 167 | ["from-visit-to-paid", "From visit to paid", "the last 7 days: home visits, plans seen, plan clicks, forges made, paid"], | |
| 167 | 168 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 168 | 169 | ["security-controls", "Security controls", "each safeguard, and when it was last tested"], |
| 169 | 170 | ["billing", "Billing", "each forge's plan, and grant one by hand"], |
test/tui-funnel.test.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** From visit to paid in the terminal (#11259): an admin person is offered it and reads the steps; a member is offered nothing. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 7 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 8 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 9 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 10 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | import { settleApp } from "./helpers/tui.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | let dir: string; | |
| 16 | let forge: Forge; | |
| 17 | ||
| 18 | function boot(username: string) { | |
| 19 | const user = getUserByName(forge.db, username)!; | |
| 20 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 21 | const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(140, 32), exitHooks: false }); | |
| 22 | app.start(); | |
| 23 | const k = (key: string, ctrl = false) => app.dispatch({ type: "key", key, ctrl, alt: false, shift: false }); | |
| 24 | return { | |
| 25 | text: () => app.screen.toText(), | |
| 26 | palette: async (query: string) => { | |
| 27 | k("k", true); | |
| 28 | for (const ch of query) k(ch); | |
| 29 | await settleApp(app); | |
| 30 | const seen = app.screen.toText(); | |
| 31 | k("enter"); | |
| 32 | await settleApp(app); | |
| 33 | return seen; | |
| 34 | }, | |
| 35 | stop: () => app.stop(), | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dir = await mkdtemp(join(tmpdir(), "forge-tui-funnel-")); | |
| 41 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 42 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 43 | const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 44 | if (!made.ok) throw new Error(made.error); | |
| 45 | } | |
| 46 | }); | |
| 47 | afterAll(async () => { | |
| 48 | forge.close(); | |
| 49 | await rm(dir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | test("an admin opens it and reads every step", async () => { | |
| 53 | const h = boot("alice"); | |
| 54 | await h.palette("From visit to paid"); | |
| 55 | const t = h.text(); | |
| 56 | for (const label of ["Visits to home", "Saw the plans", "Clicked a plan", "Forges made", "Paid"]) expect(t).toContain(label); | |
| 57 | h.stop(); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a member is offered nothing", async () => { | |
| 61 | const h = boot("bob"); | |
| 62 | const seen = await h.palette("From visit to paid"); | |
| 63 | expect(seen).not.toContain("the last 7 days: home visits"); | |
| 64 | expect(h.text()).not.toContain("Visits to home"); | |
| 65 | h.stop(); | |
| 66 | }); |
new above
offline
Principle 1 and 8: "Selling funnel, 7 days" is jargon; say plainly what it is, e.g. "Last 7 days: from visit to paid". Also the block sits above Stripe and Plans, ahead of what this page is for (connecting billing, choosing plans); put it after them.