From c5896d1aaad6118626c2585021043505a238f9a5 Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Thu, 12 Nov 2026 18:57:23 -0500 Subject: [PATCH 1/4] WIP: Pro (renamed from Team), 3-day trial, Free limited to the totals and one suggestion, suggest keeps its answer with the model's ideas and website designs Co-Authored-By: Claude Opus 5.5 --- billing/plans.json | 31 +- packages/cli/src/api.mjs | 29 +- packages/cli/src/app.mjs | 27 +- packages/cli/src/ask.mjs | 2 +- packages/cli/src/manage.mjs | 6 +- packages/cli/src/session.mjs | 8 +- packages/cli/src/state.mjs | 2 +- packages/cli/src/suggest/digest.mjs | 6 +- packages/cli/src/suggest/index.mjs | 84 +- packages/cli/src/tui.mjs | 16 + packages/cli/src/views.mjs | 2 +- .../migrations/0019_suggest_results.sql | 15 + telemetry-worker/src/auth.ts | 8 +- telemetry-worker/src/billing-pages.ts | 6 +- telemetry-worker/src/billing.ts | 27 +- telemetry-worker/src/cli.ts | 65 +- telemetry-worker/src/collector.ts | 7 +- telemetry-worker/src/dashboard.ts | 19 + telemetry-worker/src/pages.ts | 21 +- telemetry-worker/src/plans.ts | 24 +- telemetry-worker/src/stripe.ts | 5 +- telemetry-worker/src/suggest.ts | 220 +- telemetry-worker/test/billing.test.ts.orig | 1848 ----------------- 23 files changed, 526 insertions(+), 1952 deletions(-) create mode 100644 telemetry-worker/migrations/0019_suggest_results.sql delete mode 100644 telemetry-worker/test/billing.test.ts.orig diff --git a/billing/plans.json b/billing/plans.json index f5602f04..39d4a083 100644 --- a/billing/plans.json +++ b/billing/plans.json @@ -1,40 +1,40 @@ { - "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. There is no trial. Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone.", + "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. Pro (id team; named Team until 2026-11-12) has a trial: trial_days (3) days free at Checkout, a card taken up front and charged when it ends unless cancelled, once per account (telemetry-worker/src/billing.ts trialFor). Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone. Entitlements `problems` (the problem lists, pages, activity and fix prompts on the dashboard and in the genclass command; Free shows only the totals) and `ask` (genclass ask) were added 2026-11-12 (the owner's 'limit free fully'); a plan file without the key gives them.", "version": 4, "currency": "usd", "contact": "mehar@genclass.dev", "grant_plan": "enterprise", "retired_plans": ["pro", "custom", "individual"], - "footer": "Questions about Team, Enterprise, your own classifier, self-hosting or invoices? Email mehar@genclass.dev.", + "footer": "Questions about Pro, Enterprise, your own classifier, self-hosting or invoices? Email mehar@genclass.dev.", "plans": [ { "id": "free", "name": "Free", - "summary": "Find, see and fix the problems. Up to 3 projects.", + "summary": "The totals, one suggestion, up to 3 projects.", "price_usd_cents": { "monthly": 0, "yearly": null }, "stripe_lookup_keys": { "monthly": null, "yearly": null }, "stripe_tax_code": null, "managed_payments": false, "per_seat": false, "limits": { "apps": 3, "history_days": 7, "seats": 1, "live_fixes_per_month": 100 }, - "suggestions": { "per_day": 3, "shown": 3 }, - "entitlements": { "dashboard": true, "forwarding": false, "export": false, "analytics": false, "custom_model": false, "github_private": false }, + "suggestions": { "per_day": 3, "shown": 1 }, + "entitlements": { "dashboard": true, "forwarding": false, "export": false, "analytics": false, "custom_model": false, "github_private": false, "problems": false, "ask": false }, "features": [ { "text": "Open-source runtime, every mode", "available": true }, { "text": "On-device model", "available": true }, { "text": "Coding-agent loop on your machine (@genclass/mcp)", "available": true }, - { "text": "Dashboard: the problems found, last 7 days", "available": true }, + { "text": "Dashboard totals, last 7 days", "available": true }, { "text": "Up to 3 projects", "available": true }, { "text": "100 live fixes a month per project, then observe only", "available": true }, { "text": "GitHub App on public repositories", "available": true }, { "text": "Page check", "available": true }, - { "text": "Suggestions: your top 3 next steps", "available": true }, + { "text": "Suggestions: your top one", "available": true }, { "text": "Self-host the collector", "available": true } ] }, { "id": "team", - "name": "Team", + "name": "Pro", "summary": "Everything for one project, your whole team included.", "price_usd_cents": { "monthly": 1500, "yearly": null }, "stripe_lookup_keys": { "monthly": "genclass_team_monthly", "yearly": null }, @@ -43,11 +43,15 @@ "per_seat": false, "limits": { "apps": null, "history_days": 90, "seats": null, "live_fixes_per_month": null }, "suggestions": { "per_day": 30, "shown": null }, - "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": false, "github_private": true }, + "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": false, "github_private": true, "problems": true, "ask": true }, "features": [ { "text": "Everything in Free", "available": true }, + { "text": "3 days free to start", "available": true }, + { "text": "Every problem, its pages and a fix prompt", "available": true }, + { "text": "Ask about your app in plain words", "available": true }, + { "text": "Every suggestion, plus the model's own ideas", "available": true }, + { "text": "Website design ideas from sites like yours", "available": true }, { "text": "Unlimited live fixes", "available": true }, - { "text": "Every suggestion, with a prompt for your agent", "available": true }, { "text": "GitHub App on private repositories", "available": true }, { "text": "Visitor analytics", "available": true }, { "text": "Alerts to Sentry, PostHog, Datadog, Slack or a webhook", "available": true }, @@ -55,7 +59,8 @@ { "text": "90 days of history", "available": true }, { "text": "Your whole team on the project", "available": true } ], - "per_project": true + "per_project": true, + "trial_days": 3 }, { "id": "enterprise", @@ -68,9 +73,9 @@ "per_seat": false, "limits": { "apps": null, "history_days": 365, "seats": null, "live_fixes_per_month": null }, "suggestions": { "per_day": 100, "shown": null }, - "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": true, "github_private": true }, + "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": true, "github_private": true, "problems": true, "ask": true }, "features": [ - { "text": "Everything in Team", "available": true }, + { "text": "Everything in Pro", "available": true }, { "text": "Your own classifier, trained by us", "available": true }, { "text": "Our models in our cloud", "available": true }, { "text": "The coding-agent loop run for your team", "available": true }, diff --git a/packages/cli/src/api.mjs b/packages/cli/src/api.mjs index 1c9c4bbd..424ea56e 100644 --- a/packages/cli/src/api.mjs +++ b/packages/cli/src/api.mjs @@ -79,6 +79,7 @@ function offerOf(o, host) { features: (Array.isArray(o.features) ? o.features : []).filter((x) => typeof x === "string" && x.trim()).slice(0, 12).map((x) => x.slice(0, 120)), checkoutUrl, free: { name: str(f.name, 40) ?? "Free", apps: num(f.apps), historyDays: num(f.history_days), suggestionsShown: num(f.suggestions_shown), liveFixes: num(f.live_fixes) }, + trialDays: num(o.trial_days), }; } @@ -114,7 +115,8 @@ export async function fetchRepoStatus(creds, repo, opts = {}) { * @returns {Promise<{ ok: true, source: "model" | "checks", plan: { id: string, name: string } | null, total: number, items: { id: string, why: string | null, step: string | null, prompt: string | null }[], locked: number, lockedIds: string[], upgradeUrl: string | null, remainingToday: number | null, limited: boolean } | { ok: false, status: number, reason: string, missing?: true }>} */ export async function requestPlan(creds, digest, opts = {}) { - const r = await request("/api/cli/suggest", { method: "POST", token: creds.access_token, host: creds.host, body: digest, fetchFn: opts.fetchFn, timeoutMs: opts.timeoutMs ?? 45_000 }); + // the model works through the checks, its own ideas and three website designs: up to 90 seconds on the server + const r = await request("/api/cli/suggest", { method: "POST", token: creds.access_token, host: creds.host, body: digest, fetchFn: opts.fetchFn, timeoutMs: opts.timeoutMs ?? 120_000 }); if (!r.ok) return { ok: false, status: r.status, reason: failure(r), ...(r.status === 404 ? { missing: true } : {}) }; const j = r.json ?? {}; const items = (Array.isArray(j.items) ? j.items : []) @@ -132,6 +134,20 @@ export async function requestPlan(creds, digest, opts = {}) { upgradeUrl: sameOrigin(j.upgrade_url, creds.host), remainingToday: Number.isFinite(Number(j.remaining_today)) && j.remaining_today !== null ? Number(j.remaining_today) : null, limited: j.limited === true, + /** the model's own ideas (not measured) and website designs: Pro gets them, Free their counts */ + ideas: (Array.isArray(j.ideas) ? j.ideas : []) + .filter((i) => i && typeof i === "object" && str(i.title, 120)) + .slice(0, 20) + .map((i) => ({ title: str(i.title, 120), why: str(i.why, 600), step: str(i.step, 400), prompt: str(i.prompt, 1200) })), + designs: (Array.isArray(j.designs) ? j.designs : []) + .filter((d) => d && typeof d === "object" && str(d.title, 120) && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/\S*)?$/i.test(String(d.inspired_by ?? "")) && str(d.prompt, 4000)) + .slice(0, 4) + .map((d) => ({ title: str(d.title, 120), inspiredBy: String(d.inspired_by).slice(0, 200), why: str(d.why, 600), prompt: str(d.prompt, 4000) })), + ideasLocked: Number.isFinite(Number(j.ideas_locked)) ? Math.max(0, Number(j.ideas_locked)) : 0, + designsLocked: Number.isFinite(Number(j.designs_locked)) ? Math.max(0, Number(j.designs_locked)) : 0, + /** this answer was kept from an earlier run (Free sees its first one again until it pays) */ + cachedAt: str(j.cached_at, 40), + upgradePlan: j.upgrade_plan && typeof j.upgrade_plan === "object" ? { plan: str(j.upgrade_plan.name, 40), price: str(j.upgrade_plan.price, 60), trialDays: Number.isInteger(j.upgrade_plan.trial_days) && j.upgrade_plan.trial_days > 0 ? j.upgrade_plan.trial_days : null } : null, }; } @@ -157,6 +173,13 @@ export async function signInIsOld(creds, opts = {}) { return old(await ask("/api/cli/app?id=-&range=24h")); } +/** A part the plan leaves out, as the server said it: { plan, trialDays, price, upgradeUrl }, or null. */ +function lockOf(l, host) { + if (!l || typeof l !== "object") return null; + const days = Number(l.trial_days); + return { plan: str(l.plan, 40), trialDays: Number.isInteger(days) && days > 0 ? days : null, price: str(l.price, 60), upgradeUrl: sameOrigin(l.upgrade_url, host) }; +} + /** The live-fix count the server sent with an app's dashboard (src/usage.ts there), or null. */ function liveUsage(u) { if (!u || typeof u !== "object" || !Number.isFinite(Number(u.used))) return null; @@ -203,6 +226,8 @@ export async function fetchApp(creds, id, range = "7d", opts = {}) { visitorsPlan: lock ? { plan: str(lock.plan, 40), upgradeUrl: sameOrigin(lock.upgrade_url, creds.host) } : null, agent: { found: count(st.found), reproduced: count(st.reproduced), fixed: count(st.fixed), closed: count(st.closed), open: count(st.open), last: str(j.agent?.last, 40) }, dashboardUrl: sameOrigin(j.dashboard_url, creds.host), + /** Free sees the totals only: the plan that shows the problems, its trial and price, and its page */ + problemsPlan: lockOf(j.locked?.problems, creds.host), /** the problems fixed live this month against the app's plan: past its limit GenClass observes only */ usage: liveUsage(j.usage), /** the app's plan page (plans per project) */ @@ -282,6 +307,8 @@ const refused = (r, host, what) => ({ ...(r.status === 404 && (typeof r.json?.error !== "string" || r.json.error === "not found") ? { missing: true } : {}), ...(r.json?.reauth === true ? { reauth: true } : {}), ...(sameOrigin(r.json?.upgrade_url, host) ? { upgradeUrl: sameOrigin(r.json.upgrade_url, host) } : {}), + // a part of the paid plan: which plan, its trial and price (the Pro box) + ...(r.json?.locked === true ? { lock: lockOf(r.json, host) } : {}), }); const numOrNull = (v) => (Number.isFinite(Number(v)) && v !== null ? Number(v) : null); const person = (x) => ({ label: str(x?.email, 120) ?? (str(x?.github_login, 60) ? `@${str(x.github_login, 60)}` : "a GenClass account"), joined: str(x?.joined, 40), active: x?.active !== false }); diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index e7192322..f5cdb03a 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -16,7 +16,15 @@ import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs"; import { openBrowser, signIn } from "./actions.mjs"; import { fetchApp, signInIsOld } from "./api.mjs"; import { readState } from "./state.mjs"; -import { ago, bad, clean, header, note, PROMPT, typed, visible, warn, width, wrap } from "./tui.mjs"; +import { ago, bad, clean, header, note, proBox, PROMPT, typed, visible, warn, width, wrap } from "./tui.mjs"; + +/** The views a Free app does not have (the server sends their data to Pro only), and what the Pro box says about each. */ +const PRO_VIEWS = { + problems: "Every problem, the pages it happens on and what GenClass did come with Pro.", + events: "The latest events, one per line, come with Pro.", + report: "The period as Markdown comes with Pro.", + fix: "Handing problems to your coding agent, with a fix prompt for each, comes with Pro.", +}; /** "Dec 1" for an ISO time (UTC). */ const monthDay = (iso) => { @@ -124,7 +132,9 @@ export function render(app, state, o = {}) { out(); out(` ${c.green(sym.ok)} No problems found in this period.`); } - table("Problems", rows(app.problems), all ? 200 : 5, KINDS, all); + // Free: the totals above, and the way to the rest + if (app.problemsPlan) proBox("The problems, the pages they happen on and a fix prompt for each come with Pro. Free shows the totals.", app.problemsPlan); + else table("Problems", rows(app.problems), all ? 200 : 5, KINDS, all); if (all) table("Possible problems (lower confidence)", rows(app.possible), 200, KINDS, all); table("Visual checks", rows(app.checks), all ? 200 : 3, null, all); if (all && app.recent.length) { @@ -145,7 +155,7 @@ export function render(app, state, o = {}) { const bounce = v.visits ? ` · ${Math.round((v.bounces / v.visits) * 100)}% left after one page` : ""; part("Visitors", v.visits ? `${plural(v.visits, "visit")} · ${plural(v.pages, "page view")}${bounce}` : c.gray("none counted in this period (analytics is opt-in: genclass.dev/docs)")); if (v.visits && v.entry.length) for (const l of wrap(`came in on ${v.entry.slice(0, all ? 10 : 3).map((e) => `${clean(e.key, 30)} (${n(e.count)})`).join(", ")}`, width() - 4 - 15)) out(` ${" ".repeat(15)}${c.gray(l)}`); - } else if (app.visitorsPlan) part("Visitors", c.gray(`visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Team", 20)} plan${app.visitorsPlan.upgradeUrl ? `: ${app.visitorsPlan.upgradeUrl}` : ""}`)); + } else if (app.visitorsPlan) part("Visitors", c.gray(`visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Pro", 20)} plan${app.visitorsPlan.upgradeUrl ? `: ${app.visitorsPlan.upgradeUrl}` : ""}`)); if (u && u.limit !== null) part("Live fixes", u.observeOnly ? c.yellow(`${n(u.used)} of ${n(u.limit)} this month · observing only`) : `${n(u.used)} of ${n(u.limit)} this month`); else if (u?.used) part("Live fixes", `${n(u.used)} this month · no limit`); const a = app.agent; @@ -294,9 +304,20 @@ export async function appView(o = {}, deps = {}, view) { return 1; } if (o.json) { + if (app.problemsPlan && PRO_VIEWS[view.name]) { + out(JSON.stringify({ ok: false, locked: true, error: `${PRO_VIEWS[view.name]}`, plan: app.problemsPlan.plan, trial_days: app.problemsPlan.trialDays, upgrade_url: app.problemsPlan.upgradeUrl, run: "npx genclass upgrade" }, null, 2)); + return 0; + } out(JSON.stringify((view.json ?? ((a, st, t) => ({ ok: true, app: t.name, ...a })))(app, state, target), null, 2)); return 0; } + // Free sees the totals only (the server left the rest out): these views are Pro's, and say so with the way to it + if (app.problemsPlan && PRO_VIEWS[view.name]) { + header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + proBox(PRO_VIEWS[view.name], app.problemsPlan); + out(); + return 0; + } // a view whose output is meant to be piped or pasted (the report) has no card over it if (!view.plain) header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); const code = await view.show(app, state, target, readCredentialsFor().creds ?? creds); diff --git a/packages/cli/src/ask.mjs b/packages/cli/src/ask.mjs index ed78f863..cb9d12c6 100644 --- a/packages/cli/src/ask.mjs +++ b/packages/cli/src/ask.mjs @@ -108,7 +108,7 @@ export async function ask(o = {}, deps = {}) { if (r.answer === null) { if (r.why === "limit") { warn(`Today's ${r.perDay === null ? "" : `${n(r.perDay)} `}model runs are used (ask and suggest share them). They come back tomorrow.`); - if (r.upgradeUrl) out(` ${c.gray("More a day with the Team plan:")} ${c.cyan("npx genclass plan")}`); + if (r.upgradeUrl) out(` ${c.gray("More a day with Pro:")} ${c.cyan("npx genclass upgrade")}`); } else if (r.why === "no-model") note("This genclass.dev has no model to ask, so there is no answer in words."); else warn("The model did not answer this time. Asking again in a moment usually works."); out(` ${c.gray("The numbers themselves:")} ${c.cyan("npx genclass problems")}`); diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index 26efe65d..bc0eddba 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -373,7 +373,7 @@ export async function team(o = {}, deps = {}) { out(); if (t.seats !== null && t.seats <= 1) { out(` ${c.bold(`${clean(a.plan.name, 20)} is for one person.`)}`); - note("A team comes with Team: everyone you invite opens the same dashboards."); + note("A team comes with Pro: everyone you invite opens the same dashboards."); out(` ${c.cyan("npx genclass plan")}`); } else { out(` ${c.gray("Invite someone:")} ${c.cyan("npx genclass team invite")}`); @@ -518,7 +518,7 @@ export async function connections(o = {}, deps = {}) { } if (!r.forwarding) { out(); - out(` ${c.bold(`Alerts come with the ${clean(r.plan ?? "Team", 20)} plan.`)} ${c.cyan("npx genclass plan")}`); + out(` ${c.bold(`Alerts come with the ${clean(r.plan ?? "Pro", 20)} plan.`)} ${c.cyan("npx genclass plan")}`); } else { out(); out(` ${c.gray("Set one up:")} ${c.cyan("npx genclass connections add slack")}`); @@ -577,7 +577,7 @@ export async function exportEvents(o = {}, deps = {}) { if (!r.ok) { sp?.stop(null); if (toStd && !o.json) { - process.stderr.write(`genclass export: ${r.reauth ? "sign in again: npx genclass login" : r.upgradeUrl ? "the stored events as a file come with the Team plan: npx genclass plan" : (r.reason ?? "it did not work")}\n`); + process.stderr.write(`genclass export: ${r.reauth ? "sign in again: npx genclass login" : r.upgradeUrl ? "the stored events as a file come with Pro: npx genclass plan" : (r.reason ?? "it did not work")}\n`); return 1; } return refused(r, { what: "The stored events as a file", command: ["export", o.from ? `--from ${o.from}` : "", o.to ? `--to ${o.to}` : "", o.format ? `--format ${o.format}` : "", o.out ? `--out ${o.out}` : ""].filter(Boolean).join(" "), browserUrl: target.dashboard_url, json: o.json }); diff --git a/packages/cli/src/session.mjs b/packages/cli/src/session.mjs index f28b05da..78bb5b8c 100644 --- a/packages/cli/src/session.mjs +++ b/packages/cli/src/session.mjs @@ -11,7 +11,7 @@ import { readCredentialsFor } from "../../runtime/bin/lib/account.mjs"; import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; import { signIn } from "./actions.mjs"; -import { bad, clean, note, typed, warn } from "./tui.mjs"; +import { bad, clean, note, proBox, typed, warn } from "./tui.mjs"; /** * Runs `call(creds)` (one of api.mjs's calls). When the answer says this sign-in is from before the command could @@ -47,10 +47,8 @@ export function refused(r, ctx) { out(); if (r.reauth) warn(`This sign-in is from before the command could do this. Run ${c.cyan("npx genclass login")} once, then ${c.cyan(`npx genclass ${ctx.command}`)}.`); else if (r.upgradeUrl) { - out(` ${c.bold(`${ctx.what} comes with the ${clean(ctx.plan ?? "Team", 20)} plan.`)}`); - note("Everything GenClass finds stays free. The plan adds this, and it starts from here:"); - out(` ${c.cyan("npx genclass plan")}`); - out(` ${c.gray("In the browser")} ${c.cyan(r.upgradeUrl)}`); + const lock = r.lock ?? { plan: ctx.plan ?? null, upgradeUrl: r.upgradeUrl }; + proBox(`${ctx.what} comes with ${clean(lock.plan ?? ctx.plan ?? "Pro", 20)}.`, { ...lock, upgradeUrl: lock.upgradeUrl ?? r.upgradeUrl }); } else if (r.missing) note("This genclass.dev cannot do this from the terminal yet."); else bad(`${ctx.what}: ${clean(r.reason ?? "it did not work", 240)}`); if (ctx.browserUrl && !r.upgradeUrl) out(` ${c.gray("In the browser")} ${c.cyan(ctx.browserUrl)}`); diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index 1daca57d..02ecd09d 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -330,7 +330,7 @@ export async function readState(o = {}, deps = {}) { else if (!repo || !repo.ok) Object.assign(gh, repo?.missing ? { state: "waiting", detail: "check it at genclass.dev/account/github", run: null } : { detail: `could not check${repo ? ` (${repo.reason})` : ""}` }); else if (!repo.known) Object.assign(gh, { detail: "continue with GitHub to check", human: "install the GitHub App and continue with GitHub in your browser" }); else if (!repo.installed) Object.assign(gh, { detail: `not on ${git.github} yet`, human: "install the GitHub App on this repository" }); - else if (repo.needsPlan) Object.assign(gh, { state: "waiting", detail: "private repository: reviews come with the Team plan", run: null }); + else if (repo.needsPlan) Object.assign(gh, { state: "waiting", detail: "private repository: reviews come with Pro", run: null }); else if (repo.paused) Object.assign(gh, { state: "waiting", detail: "paused on your GitHub page at genclass.dev", run: null }); else if (repo.evidence) Object.assign(gh, { state: "done", detail: repo.prs ? `reviewing · ${repo.prs} pull request${repo.prs === 1 ? "" : "s"} so far` : "reviewing" }); else Object.assign(gh, { state: "waiting", detail: "installed · open a pull request and the first review appears", run: null, human: workflows.length ? "commit the workflow file and open a pull request" : "open a pull request" }); diff --git a/packages/cli/src/suggest/digest.mjs b/packages/cli/src/suggest/digest.mjs index 51aa9cfd..c66c1153 100644 --- a/packages/cli/src/suggest/digest.mjs +++ b/packages/cli/src/suggest/digest.mjs @@ -27,6 +27,8 @@ export function buildDigest(app, results) { site: app.site ? clean(app.site, 200) : null, payments: app.payments ? clean(app.payments, 30) : null, hosting: app.hosting ? clean(app.hosting, 30) : null, + // what the site says it is about (its title and meta description): the design ideas fit the company with it + about: app.about ? clean(app.about, 300) : null, }, checks: failed.map((r) => ({ id: r.id, area: r.area, title: clean(r.title, 100), weight: r.weight, evidence: (r.evidence ?? []).slice(0, 3).map((e) => clean(e, 160)).filter(Boolean) })), passed: results.filter((r) => r.status === "pass").length, @@ -36,10 +38,10 @@ export function buildDigest(app, results) { /** The lines that say, in plain words, what a digest holds (shown before it is sent). */ export function describeDigest(d) { - const about = [d.app.name && "its name", d.app.framework && "its framework", d.app.site && "its address", d.app.payments && "its payment provider", d.app.hosting && "its host"].filter(Boolean); + const about = [d.app.name && "its name", d.app.framework && "its framework", d.app.site && "its address", d.app.about && "its site's title and description", d.app.payments && "its payment provider", d.app.hosting && "its host"].filter(Boolean); return [ `The ${d.checks.length} check${d.checks.length === 1 ? "" : "s"} that failed, each with its title and the evidence lines printed below.`, about.length ? `About the app: ${about.join(", ")}.` : null, - "No source code, no file contents and no secrets. genclass.dev passes this to the model that writes the plan and keeps none of it.", + "No source code, no file contents and no secrets. genclass.dev passes this to the model that writes the plan, and keeps the plan it writes for this app, not what was sent.", ].filter(Boolean); } diff --git a/packages/cli/src/suggest/index.mjs b/packages/cli/src/suggest/index.mjs index 1fd51efa..efc5c5f9 100644 --- a/packages/cli/src/suggest/index.mjs +++ b/packages/cli/src/suggest/index.mjs @@ -18,14 +18,14 @@ import { c, interactive, out, sym } from "../../../runtime/bin/lib/ui.mjs"; import { requestPlan } from "../api.mjs"; import { copyText, findAgents, startAgent } from "../fix.mjs"; import { readNotes, saveNotes } from "../store.mjs"; -import { agree, clean, good, header, note, para, PROMPT, rule, spinner, typed, visible, warn, width, wrap } from "../tui.mjs"; +import { agree, clean, good, header, note, para, proBox, PROMPT, rule, spinner, typed, visible, warn, width, wrap } from "../tui.mjs"; import { AREAS, runChecks } from "./checks.mjs"; import { buildDigest, describeDigest } from "./digest.mjs"; import { collectRepoFacts } from "./repo.mjs"; import { collectSiteFacts } from "./site.mjs"; /** Items shown in full when no server said how many (not signed in, or a server without the route). */ -export const FREE_SHOWN = 3; +export const FREE_SHOWN = 1; /** A site address as typed: with https:// in front of a bare host, or null when it is not one. */ export function siteAddress(v) { @@ -94,6 +94,46 @@ function printItem(i, full) { * @param {{ fetchFn?: typeof fetch, dns?: object, runGit?: Function }} [deps] * @returns {Promise} the exit code: 0, or 1 when the site was named and could not be reached at all */ +/** "Nov 12" for an ISO time. */ +const day = (iso) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +}; + +/** The model's own ideas (Pro): not measured, and said so. */ +function printIdeas(ideas, all) { + out(); + rule(); + out(` ${c.bold("Ideas from the model")} ${c.gray("(not measured: judged from your app and its site)")}`); + ideas.forEach((d, i) => { + out(); + out(` ${c.bold(String(i + 1).padStart(2))} ${c.bold(clean(d.title, 90))}`); + if (!all && i >= 5) return; + if (d.why) para(clean(d.why, 600), " "); + if (d.step) out(` ${c.cyan("Do")} ${wrap(clean(d.step, 400), width() - 12).join("\n ")}`); + if (d.prompt) { + out(` ${c.cyan("Ask your agent")}`); + para(clean(d.prompt, 1000), " ", c.gray); + } + }); + if (!all && ideas.length > 6) note(`Ideas 7 and after are listed by title: ${c.cyan("npx genclass suggest --all")} prints each one in full.`); +} + +/** The website designs (Pro): each inspired by a real site, with the whole prompt to paste into a coding agent. */ +function printDesigns(designs) { + out(); + rule(); + out(` ${c.bold("Website designs")} ${c.gray("(the model's choice of sites that fit what you do)")}`); + designs.forEach((d, i) => { + out(); + out(` ${c.bold(String(i + 1).padStart(2))} ${c.bold(clean(d.title, 90))}`); + out(` ${c.gray("Inspired by")} ${c.cyan(clean(d.inspiredBy, 200))}`); + if (d.why) para(clean(d.why, 600), " "); + out(` ${c.cyan("Prompt for Claude")} ${c.gray("(copy it all)")}`); + para(clean(d.prompt, 4000), " ", c.gray); + }); +} + /** * The whole list as one prompt for a coding agent in the repository: each item with its reason, first step and * prompt (the ones the plan shows), the rest by title, most important first, and how to check the work. @@ -138,6 +178,8 @@ async function handToAgent(items, url, cwd, o, deps, plan) { }]), ["Copy the prompt", async () => ((await (deps.copy ?? copyText)(prompt)) ? good("Copied. Paste it into your coding agent.") : out(prompt))], ["Print the prompt", async () => out(`\n${prompt}\n`)], + // each website design's own prompt, to paste into Claude whole + ...(plan?.designs ?? []).map((d) => [`Copy the design prompt: ${clean(d.title, 40)}`, async () => ((await (deps.copy ?? copyText)(d.prompt)) ? good("Copied. Paste it into Claude in your repository.") : out(d.prompt))]), ]; choices.forEach(([label], i) => out(` ${c.gray(String(i + 1).padStart(2))} ${label}`)); if (!agents.length) note("No coding agent's command found here (claude, codex, cursor-agent, gemini)."); @@ -179,7 +221,8 @@ export async function suggest(o = {}, deps = {}) { const failed = results.filter((r) => r.status === "fail"); const passed = results.filter((r) => r.status === "pass"); const unknown = results.filter((r) => r.status === "unknown"); - const app = { name: repo.name ?? null, framework: st.project.label || null, site: site?.reachable ? url : null, payments: repo.payments?.provider ?? null, hosting: repo.hosting?.provider ?? null }; + const about = site?.reachable ? [site.defaultTitle ? null : site.title, site.description].filter(Boolean).join(". ") || null : null; + const app = { name: repo.name ?? null, framework: st.project.label || null, site: site?.reachable ? url : null, about, payments: repo.payments?.provider ?? null, hosting: repo.hosting?.provider ?? null }; const digest = buildDigest(app, results); // ---- the plan @@ -187,7 +230,7 @@ export async function suggest(o = {}, deps = {}) { let plan = null; let planNote = null; if (o.noModel) planNote = "Ordered by the checks' own weights (--no-model: nothing was sent)."; - else if (!failed.length) planNote = null; + else if (!failed.length && !app.site) planNote = null; else if (!creds) planNote = `Ordered by the checks' own weights. Sign in (${c.cyan("npx genclass login")}) and the order and the reasons are written for your app.`; else { let agreed = o.yes || notes.sendOk === true; @@ -200,11 +243,11 @@ export async function suggest(o = {}, deps = {}) { } if (!agreed) planNote = `Ordered by the checks' own weights: nothing was sent. ${quiet || !interactive() ? "Pass --yes to send the failed checks to genclass.dev for a plan written for your app." : ""}`.trim(); else { - const sp = quiet ? null : spinner("Writing the plan for your app"); + const sp = quiet ? null : spinner("Writing the plan for your app (the model also looks for ideas and designs: up to a minute)"); const r = await requestPlan(creds, digest, { fetchFn: deps.fetchFn }); if (r.ok) { plan = { ...r, lockedIds: r.lockedIds ?? [] }; - sp?.stop("ok", r.source === "model" ? "The plan is written for your app." : r.limited ? "Today's plans are used up, so this one is ordered by the checks' own weights." : "Ordered by the checks' own weights."); + sp?.stop("ok", r.cachedAt ? `Your plan from ${day(r.cachedAt)}.` : r.source === "model" ? "The plan is written for your app." : r.limited ? "Today's plans are used up, so this one is ordered by the checks' own weights." : "Ordered by the checks' own weights."); } else { sp?.stop("warn", r.missing ? "This genclass.dev has no suggestions yet: ordered by the checks' own weights." : `No plan from genclass.dev (${clean(r.reason, 120)}): ordered by the checks' own weights.`); } @@ -222,9 +265,14 @@ export async function suggest(o = {}, deps = {}) { source: plan?.source ?? "checks", plan: plan?.plan ?? null, passed: passed.length, - failed: items.map((i) => ({ n: i.n, id: i.id, area: i.area, title: i.title, weight: i.weight, evidence: i.evidence, ...(i.open ? { why: i.why, do: i.step, prompt: i.prompt } : { locked: true }) })), + failed: items.filter((i) => i.open).map((i) => ({ n: i.n, id: i.id, area: i.area, title: i.title, weight: i.weight, evidence: i.evidence, why: i.why, do: i.step, prompt: i.prompt })), + // what the plan does not show is counted, not listed (Free: the first suggestion only) + more: { checks: locked.length, ideas: plan?.ideasLocked ?? 0, designs: plan?.designsLocked ?? 0 }, + ideas: plan?.ideas ?? [], + designs: (plan?.designs ?? []).map((d) => ({ title: d.title, inspired_by: d.inspiredBy, why: d.why, prompt: d.prompt })), + cached_at: plan?.cachedAt ?? null, not_checked: unknown.map((r) => r.id), - upgrade_url: locked.length ? (plan?.upgradeUrl ?? null) : null, + upgrade_url: locked.length || plan?.ideasLocked || plan?.designsLocked ? (plan?.upgradeUrl ?? null) : null, }, null, 2, @@ -239,17 +287,25 @@ export async function suggest(o = {}, deps = {}) { out(` ${c.bold(`${passed.length} of ${total} checks pass.`)} ${failed.length ? `${failed.length} to do${failed.some((r) => r.weight >= 5) ? `, ${failed.filter((r) => r.weight >= 5).length} of them urgent` : ""}.` : c.green("Nothing to do.")}`); if (planNote) note(planNote); if (!site?.reachable) note(url ? "The site could not be read, so only the repository was checked." : `Only the repository was checked. With the site's address there is more: ${c.cyan("npx genclass suggest https://your-site.com")}`); + // Free: the first suggestion only, and how many more there are (the rest is Pro's, the owner's call 2026-11-12) const fullCount = o.all ? items.length : 5; - for (const i of items) printItem(i, i.open && i.n <= fullCount); + for (const i of items.filter((x) => x.open)) printItem(i, i.n <= fullCount); if (items.some((i) => i.open && i.n > fullCount)) { out(); note(`Items ${fullCount + 1} and after are listed by title. ${c.cyan("npx genclass suggest --all")} prints each one's reason, first step and prompt.`); } - if (locked.length) { + if (plan?.ideas?.length) printIdeas(plan.ideas, o.all); + if (plan?.designs?.length) printDesigns(plan.designs); + const hiddenIdeas = plan?.ideasLocked ?? 0; + const hiddenDesigns = plan?.designsLocked ?? 0; + const more = locked.length + hiddenIdeas + hiddenDesigns; + if (more) { out(); rule(); - out(` ${c.bold(`The reason, the first step and the agent prompt for the other ${locked.length} come with the Team plan.`)}`); - note(`${plan?.plan?.name ?? "Free"} shows the plan for the first ${items.length - locked.length}. Every check above is measured for free. Plans: ${c.cyan(plan?.upgradeUrl ?? "https://genclass.dev/pricing")}`); + const parts = [locked.length && `${locked.length} from your checks`, hiddenIdeas && `${hiddenIdeas} idea${hiddenIdeas === 1 ? "" : "s"} from the model`, hiddenDesigns && `${hiddenDesigns} website design${hiddenDesigns === 1 ? "" : "s"} inspired by sites like yours`].filter(Boolean); + const up = plan?.upgradePlan ?? {}; + proBox(`${more} more suggestion${more === 1 ? "" : "s"} for this app: ${parts.join(", ")}, each with a prompt for your coding agent.`, { plan: up.plan ?? "Pro", trialDays: up.trialDays ?? null, price: up.price ?? null, upgradeUrl: plan?.upgradeUrl ?? null }); + if (plan?.cachedAt) note(`This is your plan from ${day(plan.cachedAt)}: on Free it stays this one. Pro writes it again every time you run this.`); } if (unknown.length) { out(); @@ -258,13 +314,13 @@ export async function suggest(o = {}, deps = {}) { else note(`${unknown.length} check${unknown.length === 1 ? "" : "s"} could not be measured on this site this time.`); } out(); - if (failed.length) { + if (failed.length && !more) { good("Fix one, then run it again: everything is measured afresh."); out(` ${c.cyan("npx genclass suggest")}`); note(`With a coding agent: paste an "Ask your agent" block, or tell it to run ${c.cyan("npx genclass suggest --json")} and work through the list.`); } // on a terminal: every item handed to a coding agent in one go (Claude Code, Codex, Cursor, Gemini CLI) - if (items.length && interactive() && !o.yes) await handToAgent(items, site?.reachable ? url : null, cwd, o, deps, plan); + if ((items.some((i) => i.open) || plan?.designs?.length) && interactive() && !o.yes) await handToAgent(items.filter((i) => i.open), site?.reachable ? url : null, cwd, o, deps, plan); out(); return url && site && !site.reachable && o.url ? 1 : 0; } diff --git a/packages/cli/src/tui.mjs b/packages/cli/src/tui.mjs index 66673708..432ce8b9 100644 --- a/packages/cli/src/tui.mjs +++ b/packages/cli/src/tui.mjs @@ -167,6 +167,20 @@ export function card(h = {}) { * @param {{ plan: { name: string }, price: string, summary?: string | null, features: string[] }} offer * @param {string | null} [app] the project it is chosen for */ +/** + * The Pro box: what comes with the paid plan, its trial and price, and the command that starts it. `lock` is what + * the server said ({ plan, trialDays, price, upgradeUrl }); every part of it is cleaned before it is printed. + */ +export function proBox(what, lock = {}) { + const plan = clean(lock.plan ?? "Pro", 20); + out(); + out(` ${badge(plan.toUpperCase())}`); + for (const l of wrap(clean(what, 300), width() - 6)) out(` ${c.bold(l)}`); + const terms = lock.trialDays ? `${lock.trialDays} days free, then ${clean(lock.price ?? "the monthly price", 40)}. Cancel any time.` : lock.price ? `${clean(lock.price, 40)}.` : ""; + for (const l of wrap(`${terms ? `${terms} ` : ""}Start: npx genclass upgrade`, width() - 6)) out(` ${l}`); + if (lock.upgradeUrl) out(` ${c.cyan(clean(lock.upgradeUrl, 200))}`); +} + export function planCard(offer, app) { const inner = width() - 6; const B = unicode ? { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" } : { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" }; @@ -185,6 +199,8 @@ export function planCard(offer, app) { row(price); } if (offer.summary) for (const l of wrap(clean(offer.summary, 200), room)) row(c.gray(l)); + // the trial, when this account has one: said before anything else is + if (offer.trialDays) for (const l of wrap(`${offer.trialDays} days free, then ${clean(offer.price, 40)}. Cancel any time.`, room)) row(c.green(l)); row(); for (const f of offer.features) { const [first = "", ...rest] = wrap(clean(f, 120), room - 2); diff --git a/packages/cli/src/views.mjs b/packages/cli/src/views.mjs index 8b57e349..8407c022 100644 --- a/packages/cli/src/views.mjs +++ b/packages/cli/src/views.mjs @@ -114,7 +114,7 @@ export function renderAnalytics(app, o = {}) { period(app, o.now); if (app.visitorsPlan) { out(); - out(` ${c.bold(`Visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Team", 20)} plan.`)}`); + out(` ${c.bold(`Visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Pro", 20)} plan.`)}`); note("What your visitors do is already being counted, so it shows from the day you switch: visits, pages, time on a page, where people come from, what they click, funnels."); out(` ${c.cyan("npx genclass plan")}`); out(); diff --git a/telemetry-worker/migrations/0019_suggest_results.sql b/telemetry-worker/migrations/0019_suggest_results.sql new file mode 100644 index 00000000..3cf95d25 --- /dev/null +++ b/telemetry-worker/migrations/0019_suggest_results.sql @@ -0,0 +1,15 @@ +-- genclass suggest's last answer per app (the owner's call, 2026-11-12): the measured checks in the model's order with +-- its reasons, steps and prompts, the model's own ideas (not measured) and its website design ideas (each inspired by +-- a real site, with a full prompt to paste into a coding agent). Free sees the first item and the counts, and is +-- shown this same answer again on every run until the account pays; Pro sees all of it. One row per account and +-- app, replaced by the next answer; deleted with the account. The digest it came from is not kept. +-- Apply before deploying the worker that reads it: +-- npx --yes wrangler@4 d1 migrations apply genclass-dashboard --remote + +CREATE TABLE suggest_results ( + account_id TEXT NOT NULL, + app_key TEXT NOT NULL, -- sha256 of the app's site address (else its name), so one app has one answer + created TEXT NOT NULL, + body TEXT NOT NULL, -- JSON: { source, items, ideas, designs } + PRIMARY KEY (account_id, app_key) +); diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts index e546be78..0313181c 100644 --- a/telemetry-worker/src/auth.ts +++ b/telemetry-worker/src/auth.ts @@ -70,6 +70,7 @@ import { sha256Hex, stats, stripAnalytics, + stripProblems, type D1Like, type Project, } from "./dashboard.js"; @@ -81,7 +82,7 @@ import { forgetAccount as forgetAccountGithub, unlinkProject } from "./github/st import { json, mediaType, readCapped } from "./http.js"; import { dashboardPage, ownEndpoint, securityHeaders, setupCommand } from "./pages.js"; import { DEVICE_FAILURES, DEVICE_MS, DUMMY_HASH, FAILURE_WINDOW_MS, FREE_FAILURES, MAX_DELAY_MS, PASSWORD_MAX, delayAfter, hashPassword, needsRehash, passwordProblem, verifyPassword, waitText } from "./password.js"; -import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, keepDays, PAID_PLANS, pickRange, projectAccess, viewRanges } from "./plans.js"; +import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, problemsLock, projectAccess, viewRanges } from "./plans.js"; import { activeOwners, forgetTeam, membershipsOf } from "./team.js"; import { cancelForDeletion } from "./stripe.js"; import { @@ -1787,6 +1788,7 @@ async function deleteAccount(c: C): Promise { const owned = "SELECT id FROM projects WHERE account_id = ?"; // `genclass suggest`'s count of model runs goes with the account (on its own: its table comes with migration 0015) await c.db.prepare("DELETE FROM suggest_usage WHERE account_id = ?").bind(id).run().catch(() => undefined); + await c.db.prepare("DELETE FROM suggest_results WHERE account_id = ?").bind(id).run().catch(() => undefined); await c.db.batch([ // paid features of every app in the account (apps that keep a private link leave without them) c.db.prepare(`DELETE FROM integrations WHERE project_id IN (${owned})`).bind(id), @@ -2070,7 +2072,9 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E const s = await stats(c.db, p, range, c.now); const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null); // visitor analytics are left out for a plan without them, and counted all the same (src/dashboard.ts stripAnalytics) - return json(200, { ...(hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` })), usage, plan: { id: access.plan.id, name: access.plan.name, source: access.source }, plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` }); + const planUrl = `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}`; + const shown = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` }); + return json(200, { ...(hasProblems(access) ? shown : stripProblems(shown, problemsLock(planUrl))), usage, plan: { id: access.plan.id, name: access.plan.name, source: access.source }, plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` }); } const fun = /^\/api\/account\/projects\/([^/]+)\/funnels$/.exec(path); if (fun) { diff --git a/telemetry-worker/src/billing-pages.ts b/telemetry-worker/src/billing-pages.ts index 6de2ad70..c3e3cfb3 100644 --- a/telemetry-worker/src/billing-pages.ts +++ b/telemetry-worker/src/billing-pages.ts @@ -230,6 +230,8 @@ export interface ProjectPlanView { /** The project's own subscription, as the webhook wrote it. */ sub: { status: string | null; periodEnd: string | null; cancels: boolean }; usage: { used: number; limit: number | null; observe_only: boolean; resets: string } | null; + /** Days free this account's first plan starts with (billing.ts trialFor); 0: none. */ + trialDays?: number; live: boolean; testMode: boolean; /** Back from Stripe Checkout (?done=1). */ @@ -281,7 +283,7 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { const features = (offer?.features ?? []).filter((f) => f.available).map((f) => `
  • ${esc(f.text)}
  • `).join(""); const card = offer && !paid && a.source !== "open" ? `

    ${esc(offer.name)}

    ${esc(priceText(offer))}

    ${esc(offer.summary)}

      ${features}
    -
    ${v.live ? `` : 'Paid plans are not on sale yet.'}
    +
    ${v.live ? `${v.trialDays ? `

    ${v.trialDays} days free, then ${esc(priceText(offer))} for ${name}. Cancel before the trial ends and you pay nothing.

    ` : ""}` : 'Paid plans are not on sale yet.'}
    ${a.locked || a.source !== null ? "" : `

    Try for free (limited)

    @@ -371,7 +373,7 @@ ${notice}${test} user: { label, sub: orgName(label) }, ...orgRail("billing"), railLabel: "Organization", - css: DASH_CSS + ORG_CSS + BILLING_CSS + ".plan.big{max-width:560px;padding:34px 32px 30px}.plan.big h3{font-size:34px;letter-spacing:-.01em}.plan.big .price{font-size:22px;font-weight:600;margin-top:6px}.plan.big .sum{font-size:15px}.plan.big li{font-size:15px}.plan.big .act .btn{width:100%;justify-content:center;padding:15px 18px;font-size:17px}.free{margin-top:12px;font-size:11px;max-width:560px;text-align:center}.linkish{background:none;border:0;padding:0;font:inherit;font-size:11px;color:var(--ink3);text-decoration:underline;cursor:pointer}.linkish:disabled{opacity:.6;cursor:default}dialog.why{margin:auto;max-height:calc(100vh - 32px);overflow:auto;max-width:460px;width:calc(100% - 32px);border:1px solid var(--rule);border-radius:14px;background:var(--paper);color:var(--ink);padding:24px 24px 20px}dialog.why::backdrop{background:rgba(0,0,0,.62)}dialog.why h2{font-size:21px;margin:0 0 4px}dialog.why fieldset{border:0;padding:0;margin:14px 0 6px}dialog.why label{display:flex;gap:10px;align-items:center;padding:7px 0;font-size:15px;cursor:pointer}dialog.why textarea{margin-top:6px}.why-act{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}", + css: DASH_CSS + ORG_CSS + BILLING_CSS + ".plan.big{max-width:560px;padding:34px 32px 30px}.plan.big h3{font-size:34px;letter-spacing:-.01em}.plan.big .price{font-size:22px;font-weight:600;margin-top:6px}.plan.big .sum{font-size:15px}.plan.big li{font-size:15px}.plan.big .act .btn{width:100%;justify-content:center;padding:15px 18px;font-size:17px}.free{margin-top:12px;font-size:11px;max-width:560px;text-align:center}.linkish{background:none;border:0;padding:0;font:inherit;font-size:11px;color:var(--ink3);text-decoration:underline;cursor:pointer}.linkish:disabled{opacity:.6;cursor:default}.trialnote{margin:10px 0 0;font-size:13px;color:var(--ink3);text-align:center;width:100%}dialog.why{margin:auto;max-height:calc(100vh - 32px);overflow:auto;max-width:460px;width:calc(100% - 32px);border:1px solid var(--rule);border-radius:14px;background:var(--paper);color:var(--ink);padding:24px 24px 20px}dialog.why::backdrop{background:rgba(0,0,0,.62)}dialog.why h2{font-size:21px;margin:0 0 4px}dialog.why fieldset{border:0;padding:0;margin:14px 0 6px}dialog.why label{display:flex;gap:10px;align-items:center;padding:7px 0;font-size:15px;cursor:pointer}dialog.why textarea{margin-top:6px}.why-act{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}", body, script: CLIENT_JS + script, }); diff --git a/telemetry-worker/src/billing.ts b/telemetry-worker/src/billing.ts index 96668fe6..c60f76d9 100644 --- a/telemetry-worker/src/billing.ts +++ b/telemetry-worker/src/billing.ts @@ -28,7 +28,7 @@ import { accountContext, accountProjects, authenticate, csrfFail, fail, originOk import { billingPage, projectPlanPage } from "./billing-pages.js"; import { liveFixUsage } from "./usage.js"; import type { Env, HandleOptions, RateLimiterLike } from "./collector.js"; -import { randomBase62, sha256Hex, type Project } from "./dashboard.js"; +import { randomBase62, sha256Hex, type D1Like, type Project } from "./dashboard.js"; import { decodeCursor, exportResponse, PAGE_MAX, parseTime } from "./export.js"; import { checkConfig, claimTestSend, deliver, integrationRows, integrationsKey, isKind, maskConfig, openConfig, sealConfig, testRecord, type Kind } from "./forward.js"; import { json, readCapped } from "./http.js"; @@ -64,6 +64,7 @@ import { missingCustomer, modeNote, notifyOwner, + customerSubscriptions, portalSession, sharedCustomer, StripeError, @@ -192,6 +193,7 @@ export async function routeBilling(request: Request, url: URL, path: string, env const row = await c.db.prepare("SELECT subscription_status, current_period_end, cancel_at_period_end, plan_chosen FROM projects WHERE id = ?").bind(p.id).first<{ subscription_status: string | null; current_period_end: string | null; cancel_at_period_end: number | null; plan_chosen: string | null }>(); const apps = (await accountProjects(c, a.account.id)).map((x) => ({ id: x.id!, name: x.name })); const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null); + const trialDays = PAID_PLANS[0] && access.source === null ? await trialFor(c.db, a.account.id, PAID_PLANS[0]) : 0; return page(200, (n) => projectPlanPage(n, { account: { id: a.account.id, email: a.account.email, githubLogin: a.account.github_login }, @@ -200,6 +202,7 @@ export async function routeBilling(request: Request, url: URL, path: string, env access, sub: { status: row?.subscription_status ?? null, periodEnd: row?.current_period_end ?? null, cancels: !!row?.cancel_at_period_end }, usage, + trialDays, live: canBuy(env, a.account), testMode: stripeMode(env) === "test", done: url.searchParams.get("done") === "1", @@ -449,6 +452,22 @@ async function portal(c: C, f: Fetch): Promise { * of quantity 1 per project, its metadata naming the project (src/stripe.ts checkoutSession, applyProjectSubscription); * the account's other subscriptions are never touched. */ +/** + * The days free a purchase of `plan` starts with: the plan's trial_days (Pro: 3) for an account that has never had a + * subscription, none of its apps and not the account (D1), and none on its Stripe customer (`stripeSubs`, any + * status, when the caller has asked Stripe); else 0. One trial per account: a cancelled trial does not start another. + */ +export async function trialFor(db: D1Like, accountId: string, plan: Plan, stripeSubs = 0): Promise { + const days = Number.isInteger(plan.trial_days) && plan.trial_days! > 0 ? plan.trial_days! : 0; + if (!days || stripeSubs > 0) return 0; + const r = await db + .prepare("SELECT (SELECT COUNT(*) FROM projects WHERE account_id = ? AND subscription_id IS NOT NULL) + (SELECT COUNT(*) FROM accounts WHERE id = ? AND subscription_id IS NOT NULL) AS n") + .bind(accountId, accountId) + .first<{ n: number }>() + .catch(() => ({ n: 1 })); + return Number(r?.n ?? 1) ? 0 : days; +} + async function checkoutProject(c: C, f: Fetch, a: Auth, plan: Plan, projectId: string): Promise { const p = await ownedProject(c, a.account.id, projectId); if (!p || !p.id) return fail(404, "not found"); @@ -471,8 +490,10 @@ async function checkoutProject(c: C, f: Fetch, a: Auth, plan: Plan, projectId: s const subs = own.state === "ok" ? own.subs : []; if (subs.some((s) => projectOf(s) === p.id && HOLDING_STATUSES.has(s.status))) return fail(409, `a plan for ${p.name} is already going through; reload this page in a minute`); } - const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, fresh: customer.fresh, project: { id: p.id } }); - return json(200, { url }); + const subs = customer.fresh ? [] : await customerSubscriptions(c.env, f, customer.id).catch(() => [{}]); + const trialDays = await trialFor(c.db, a.account.id, plan, subs.length); + const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, fresh: customer.fresh, project: { id: p.id }, trialDays }); + return json(200, { url, trial_days: trialDays || null }); } catch (e) { return stripeFail(e); } diff --git a/telemetry-worker/src/cli.ts b/telemetry-worker/src/cli.ts index 8dcfea16..bc654e38 100644 --- a/telemetry-worker/src/cli.ts +++ b/telemetry-worker/src/cli.ts @@ -43,16 +43,16 @@ import { answer, appDigest, MAX_ASK_BYTES, readQuestion } from "./ask.js"; import { accountContext, accountJson, accountProjects, authenticate, fail, lockedJson, makeLinkFor, ownedProject, projectJson, readableProject, readJson, upgradeJson, type Auth, type C } from "./auth.js"; -import { exportFor, integrationsFor, overBudget, tooMany } from "./billing.js"; +import { exportFor, integrationsFor, overBudget, tooMany, trialFor } from "./billing.js"; import type { Env, HandleOptions } from "./collector.js"; -import { putFunnels, stats, stripAnalytics } from "./dashboard.js"; +import { putFunnels, stats, stripAnalytics, stripProblems } from "./dashboard.js"; import { isKind, KINDS } from "./forward.js"; import { githubApp } from "./github/config.js"; import { repoStatusForAccount } from "./github/http.js"; import { json } from "./http.js"; -import { accountAccess, accountBilling, accountProjectsAccess, canBuy, entitled, firstPlanWith, FREE, hasAnalytics, hasDashboard, PAID_PLANS, pickRange, priceText, projectAccess, viewRanges, type Access } from "./plans.js"; +import { accountAccess, accountBilling, accountProjectsAccess, canBuy, entitled, firstPaidWith, firstPlanWith, FREE, hasAnalytics, hasAsk, hasDashboard, hasProblems, PAID_PLANS, pickRange, priceText, problemsLock, projectAccess, viewRanges, type Access } from "./plans.js"; import { liveFixUsage } from "./usage.js"; -import { askModel, byWeight, claimRun, hasModel, MAX_SUGGEST_BYTES, readDigest, suggestionLimits, usedToday, type PlanItem } from "./suggest.js"; +import { appKey, askModel, byWeight, claimRun, hasModel, keepAnswer, lastAnswer, MAX_SUGGEST_BYTES, readDigest, suggestionLimits, usedToday, type PlanItem, type SuggestAnswer } from "./suggest.js"; import { createInvite, isTeamError, membershipsOf, teamOf } from "./team.js"; /** A repository's full name as GitHub writes one. */ @@ -71,7 +71,7 @@ async function overview(c: C): Promise { projects: await withPlans(c, a.account.id), github: { app: gh !== null, install_url: gh ? `${c.cfg.origin}/github/install` : null, connect_url: gh?.clientId ? `${c.cfg.origin}/github/connect` : null }, billing_url: `${c.cfg.origin}/account/billing`, - offer: offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), + offer: await offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), }); } @@ -97,7 +97,7 @@ async function withPlans(c: C, accountId: string): Promise | null { +async function offerFor(c: C, account: { id: string; email: string | null }, access: Access, projects?: { row: { id?: string | null }; access: Access }[]): Promise | null> { const plan = PAID_PLANS[0]; if (!plan || !canBuy(c.env, account)) return null; // a plan sold per project is offered while one of the account's apps is on Free, and only once it has one (the @@ -111,6 +111,7 @@ function offerFor(c: C, account: { id: string; email: string | null }, access: A summary: plan.summary, features: plan.features.filter((f) => f.available && !/^Everything in /i.test(f.text)).map((f) => f.text).slice(0, 12), checkout_url: one ? `${c.cfg.origin}/account/billing?project=${encodeURIComponent(one)}&from=cli` : `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1`, + trial_days: (await trialFor(c.db, account.id, plan)) || null, free: { name: FREE.name, apps: FREE.limits.apps, history_days: FREE.limits.history_days, suggestions_shown: suggestionLimits(FREE).shown, live_fixes: FREE.limits.live_fixes_per_month ?? null }, }; } @@ -145,7 +146,10 @@ async function app(c: C): Promise { if (!hasDashboard(access)) return lockedJson(c, access, p.id); const range = pickRange(c.url.searchParams.get("range"), viewRanges(access.plan)); const s = await stats(c.db, p, range, c.now); - const view = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` }); + const planUrl = `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}`; + const seen = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` }); + // Free sees the totals only: the problems, pages, activity and fix prompts come with Pro (the browser's rule too) + const view = hasProblems(access) ? seen : stripProblems(seen, problemsLock(planUrl)); const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null); return json(200, { ...view, recent: view.recent.slice(0, APP_RECENT), dashboard_url: projectJson(c, p).dashboard_url, usage, plan: { id: access.plan.id, name: access.plan.name, source: access.source }, plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` }); } @@ -160,20 +164,37 @@ async function suggest(c: C): Promise { if (typeof digest === "string") return fail(400, digest); const access = await accountAccess(c.db, a.account.id, c.env, c.now); const lim = suggestionLimits(access.plan); + // Free (a plan that shows part of the answer) is shown the answer it got the first time for this app, again, until + // the account pays (the owner's call, 2026-11-12): no new model run, the same first item and the same counts + const partial = lim.shown !== null; + const key = await appKey(digest); + const kept = partial ? await lastAnswer(c.db, a.account.id, key) : null; - // the model, when the server has one and the account has a run left today; the checks' own order otherwise - let plan: PlanItem[] | null = null; + let answer: (SuggestAnswer & { created?: string }) | null = kept; + let created: string | null = kept?.created ?? null; let used: number | null = null; let limited = false; - if (hasModel(c.env) && digest.checks.length) { + if (!answer && hasModel(c.env) && (digest.checks.length || digest.app.site || digest.app.about)) { used = await claimRun(c.db, a.account.id, c.now, lim.per_day); - if (used === null) limited = (await usedToday(c.db, a.account.id, c.now)) >= lim.per_day; - else plan = await askModel(c.env, c.opts.fetch ?? ((u: string, i?: RequestInit) => fetch(u, i)), digest); + if (used === null) { + limited = (await usedToday(c.db, a.account.id, c.now)) >= lim.per_day; + // the day's runs are used up: the last answer kept for this app, if there is one + answer = await lastAnswer(c.db, a.account.id, key); + created = answer?.created ?? null; + } else { + answer = await askModel(c.env, c.opts.fetch ?? ((u: string, i?: RequestInit) => fetch(u, i)), digest); + if (answer) await keepAnswer(c.db, a.account.id, key, answer, c.now); + } } - const source = plan ? "model" : "checks"; - const order: PlanItem[] = plan ?? byWeight(digest.checks).map((x) => ({ id: x.id, why: null, step: null, prompt: null })); + const source = answer?.source ?? "checks"; + const order: PlanItem[] = answer?.items.length ? answer.items : byWeight(digest.checks).map((x) => ({ id: x.id, why: null, step: null, prompt: null })); + const ideas = answer?.ideas ?? []; + const designs = answer?.designs ?? []; const shown = lim.shown === null ? order.length : Math.min(order.length, lim.shown); const locked = order.slice(shown); + // what Free does not see is counted, never sent: the ideas and designs come back as numbers only + const hidden = partial ? ideas.length + designs.length : 0; + const pro = firstPaidWith("problems"); return json(200, { ok: true, source, @@ -182,7 +203,14 @@ async function suggest(c: C): Promise { items: order.slice(0, shown), locked: locked.length, locked_ids: locked.map((x) => x.id), - upgrade_url: locked.length || limited ? `${c.cfg.origin}/account/billing` : null, + ideas: partial ? [] : ideas, + designs: partial ? [] : designs, + ideas_locked: partial ? ideas.length : 0, + designs_locked: partial ? designs.length : 0, + // the answer was kept from an earlier run (Free sees it again; a plan out of runs for the day sees its last) + cached_at: created, + upgrade_url: locked.length || hidden || limited ? `${c.cfg.origin}/account/billing?plan=team&from=cli` : null, + upgrade_plan: pro ? { name: pro.name, price: priceText(pro), trial_days: pro.trial_days ?? null } : null, remaining_today: used === null ? Math.max(0, lim.per_day - (await usedToday(c.db, a.account.id, c.now))) : Math.max(0, lim.per_day - used), limited, }); @@ -237,7 +265,7 @@ async function account(c: C): Promise { memberships: memberships.map((x) => ({ email: x.email, github_login: x.githubLogin, joined: x.joined, active: x.active })), }, billing_url: `${c.cfg.origin}/account/billing`, - offer: offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), + offer: await offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), }); } @@ -303,6 +331,11 @@ async function ask(c: C, f: Fetch): Promise { // the numbers the model gets are the ones this account's dashboard shows for the app: the same plan rules const access = await projectAccess(c.db, p, c.env, c.now); if (!hasDashboard(access)) return lockedJson(c, access, p.id); + // asking about the app comes with Pro (plans.json entitlement `ask`): Free is told which plan, and where + if (!hasAsk(access)) { + const lock = problemsLock(`${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}`); + return json(403, { ok: false, error: `asking about your app comes with ${lock.plan ?? "a paid plan"}`, locked: true, needs_plan: true, plan: lock.plan, upgrade_url: lock.upgrade_url, trial_days: lock.trial_days, price: lock.price }); + } const mine = await accountAccess(c.db, a.account.id, c.env, c.now); const lim = suggestionLimits(mine.plan); const left = async (): Promise => Math.max(0, lim.per_day - (await usedToday(c.db, a.account.id, c.now))); diff --git a/telemetry-worker/src/collector.ts b/telemetry-worker/src/collector.ts index 901595b3..19e46db8 100644 --- a/telemetry-worker/src/collector.ts +++ b/telemetry-worker/src/collector.ts @@ -42,6 +42,7 @@ import { sha256Hex, stats, stripAnalytics, + stripProblems, TOKEN_RE, type D1Like, type Range, @@ -56,7 +57,7 @@ import { afterBatch } from "./paid.js"; import { dashboardPage, notFoundPage, openSourcePage, ownEndpoint, securityHeaders, setupCommand, setupPage, startPage } from "./pages.js"; import { lockedLink } from "./locked.js"; import { applyLock, linkSession, linkUnlocked, lockKind, lockedJson, readBody, readLock, routeLink, startPasskeyOptions, unlockPage } from "./link-lock.js"; -import { firstPlanWith, hasAnalytics, hasDashboard, pickRange, projectAccess, viewRanges } from "./plans.js"; +import { firstPlanWith, hasAnalytics, hasDashboard, hasProblems, pickRange, problemsLock, projectAccess, viewRanges } from "./plans.js"; import { SAMPLE_PROJECT, SAMPLE_SECRET, sampleStats } from "./sample.js"; export const SCHEMA = "genclass-telemetry/1"; @@ -521,7 +522,9 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt // a private link shows the ranges of the app's plan (Free's for an app without an account), never past 30 days const range = pickRange(url.searchParams.get("range"), viewRanges(access.plan).filter((x) => RANGES.includes(x))); const s = await stats(env.DB, p, range, now); - return json(200, hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: upgradeUrl(env) })); + const seen = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: upgradeUrl(env) }); + // a private link's app has no account, so Free's rule: the totals only, and the way to an account with Pro + return json(200, hasProblems(access) ? seen : stripProblems(seen, problemsLock(upgradeUrl(env)))); } if (path === "/start") { diff --git a/telemetry-worker/src/dashboard.ts b/telemetry-worker/src/dashboard.ts index 991915ce..801318d5 100644 --- a/telemetry-worker/src/dashboard.ts +++ b/telemetry-worker/src/dashboard.ts @@ -987,6 +987,8 @@ export interface Stats { /** Why a part of the stats is missing: the first plan that includes it (null: none does) and where the plans are. */ export interface StatsLock { analytics: { plan: string | null; upgrade_url: string }; + /** The problem lists, pages, activity and fix prompts (plans.json entitlement `problems`): Free has the totals only. */ + problems?: { plan: string | null; upgrade_url: string; trial_days: number | null; price: string | null }; } /** @@ -1010,6 +1012,23 @@ export function stripAnalytics(s: Stats, lock: StatsLock["analytics"]): Stats { }; } +/** + * The stats as a plan without the `problems` entitlement sees them (Free since 2026-11-12, the owner's "limit free + * fully"): the totals, the previous period's totals and the series stay; every breakdown but visitor analytics' (which + * its own entitlement decides), the latest events and the agent's log are left out, and `locked.problems` says which + * plan shows them. Nothing is deleted: the same request answers in full once the app's plan includes them. Pure. + */ +export function stripProblems(s: Stats, lock: NonNullable): Stats { + return { + ...s, + breakdowns: Object.fromEntries(Object.entries(s.breakdowns).filter(([k]) => k.startsWith("ix_"))), + // the chart keeps its bars, one kind: how many, not which + typeSeries: s.typeSeries.map((b) => ({ bucket: b.bucket, counts: { problems: Object.values(b.counts).reduce((x, y) => x + y, 0) } })), + recent: [], + locked: { ...(s.locked ?? { analytics: undefined as never }), problems: lock } as StatsLock, + }; +} + // --------------------------------------------------------------------------------------------- visits and funnels export interface VisitStats { diff --git a/telemetry-worker/src/pages.ts b/telemetry-worker/src/pages.ts index 00a756f0..90a238c2 100644 --- a/telemetry-worker/src/pages.ts +++ b/telemetry-worker/src/pages.ts @@ -879,6 +879,7 @@ table.dt td .bdg{margin-left:8px;vertical-align:middle} .ucard svg{margin-top:auto;width:100%;height:110px;display:block} .ucard .ax{display:flex;justify-content:space-between;font:11px var(--mono);color:var(--ink3);margin-top:6px} .uh{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;justify-content:space-between;margin:48px 0 16px} +.prolock{display:flex;flex-wrap:wrap;gap:12px 20px;align-items:center;justify-content:space-between;border:1px solid var(--accent-b,var(--rule));background:var(--accent-t,transparent);border-radius:12px;padding:16px 18px;margin:0 0 24px}.prolock p{margin:4px 0 0;color:var(--ink2);max-width:640px} .uh h2{font-size:18px;color:var(--ink2)} .uh h2 b{color:var(--ink);font-weight:400} .getc{border:1px dashed var(--rule2);border-radius:6px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:var(--s75)} @@ -1198,7 +1199,7 @@ const DASH_JS = ` var d = delta(problemsOf(S), (prev.detections || 0) + (prev.checks || 0)); host.appendChild(stat("Problems found", fmt(problemsOf(S)) + (S.demo ? "+" : ""), S.demo ? "detected in production" : d)); var seenPages = count(S, "page_route"); - host.appendChild(stat("Pages affected", fmt(problemPages(S)), seenPages ? "of " + plural(seenPages, "page", "pages") : "")); + host.appendChild(stat("Pages affected", S.locked && S.locked.problems ? "Pro" : fmt(problemPages(S)), seenPages ? "of " + plural(seenPages, "page", "pages") : "")); if (S.demo) host.appendChild(stat("Fixed live", fmt(T0.interventions), "")); else host.appendChild(stat("Stopped by GenClass", fmt((T0.interventions || 0) + fixed), acting ? (applied ? Math.round(100 * undone / applied) + "% undone" : "") : "Observe mode")); host.appendChild(stat("Model loaded", share(ready, ready + failed), ready ? "average " + (T0.modelLoadAvgMs >= 1000 ? (T0.modelLoadAvgMs / 1000).toFixed(1) + " s" : fmt(T0.modelLoadAvgMs) + " ms") : "")); @@ -1749,6 +1750,21 @@ const DASH_JS = ` var fig = el("figure", "mt-l"), plot = el("div", "plot"); plot.appendChild(g); fig.appendChild(plot); host.appendChild(fig); } + // Free sees the totals only (src/dashboard.ts stripProblems): the problem sections give way to one banner with the + // way to Pro, so an empty list never reads as "no problems" + function proLock(S){ + var lock = S.locked && S.locked.problems, b = document.getElementById("prolock"); + if (b) b.classList.toggle("hidden", !lock); + if (!lock) return; + ["found", "possible-s", "where", "recent-s", "advisor", "adv-s", "fixall2"].forEach(function(id){ var e = document.getElementById(id); if (e) e.classList.add("hidden"); }); + var h = document.getElementById("advn"); if (h && h.parentNode) h.parentNode.classList.add("hidden"); + var m = document.getElementById("prolockmsg"), a = document.getElementById("prolocka"), plan = lock.plan || "Pro"; + if (m) m.textContent = "Every problem, the pages it happens on, what GenClass did about it and a fix prompt for your coding agent come with " + plan + "." + (lock.trial_days ? " " + lock.trial_days + " days free, then cancel any time." : ""); + if (a) { + if (typeof lock.upgrade_url === "string" && lock.upgrade_url.indexOf("https://") === 0) a.href = lock.upgrade_url; + var t = a.querySelector("span"); if (t) t.textContent = lock.trial_days ? "Start your " + lock.trial_days + "-day free trial" : "Upgrade to " + plan; + } + } var IX_SECTIONS = ["clicks", "ix-sources", "ix-devices", "ix-sessions", "ix-entry", "ix-funnels"]; function visits(S){ var B = S.breakdowns, T0 = S.totals, views = T0.ix_views || 0, on = views > 0 || (B.ix_click || []).length > 0; @@ -2349,7 +2365,7 @@ const DASH_JS = ` document.getElementById("content").classList.toggle("hidden", !live); ["period", "mdcopy", "verdict"].forEach(function(id){ var n = document.getElementById(id); if (n) n.classList.toggle("hidden", !live); }); if (!live) return; - verdict(S); tiles(S); usage(S); advisor(S); fixAll(S); trend(S); possibleTrend(S); cards(S); possibleCards(S); pages(S); acted(S); health(S); stages(S); agentLog(S); loop(S); recent(S); visits(S); advanced(S); + verdict(S); tiles(S); usage(S); advisor(S); fixAll(S); trend(S); possibleTrend(S); cards(S); possibleCards(S); pages(S); acted(S); health(S); stages(S); agentLog(S); loop(S); recent(S); visits(S); advanced(S); proLock(S); var B2 = S.breakdowns, T2 = S.totals; function put(id, t){ var n = document.getElementById(id); if (n) n.textContent = t; } put("c-trend", fmt(problemsOf(S))); @@ -2983,6 +2999,7 @@ ${connect}