From e42f0e9d9c90d66ec4689163f8c66bf66c46d3b1 Mon Sep 17 00:00:00 2001 From: morencyr Date: Mon, 19 Oct 2026 11:23:01 -0400 Subject: [PATCH 01/26] Onboarding: ask where people heard about OpenProfit An optional select on the workspace form. The answer goes out as heard_from on workspace_created, so signups can be counted per channel once paid ads start. The page only asks when PostHog is configured. --- src/lib/app.ts | 23 +++++++++++++++++ src/routes/onboarding.tsx | 40 +++++++++++++++++++++++++++--- src/server/onboarding.functions.ts | 4 +++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/lib/app.ts b/src/lib/app.ts index 7365438..91f7703 100644 --- a/src/lib/app.ts +++ b/src/lib/app.ts @@ -6,6 +6,29 @@ export const SITE_URL = "https://openprofit.dev"; export const SITE_DESCRIPTION = "Open-source dashboard that puts your Stripe or Paddle revenue next to your OpenAI, Vercel and other bills, and shows profit per product."; +// Answers to onboarding's "Where did you hear about OpenProfit?", sent with +// workspace_created so signups can be counted per channel. +export const HEARD_FROM = [ + "reddit", + "x", + "google", + "ai", + "hacker_news", + "github", + "friend", + "other", +] as const; +export const HEARD_FROM_LABELS: Record<(typeof HEARD_FROM)[number], string> = { + reddit: "Reddit", + x: "X", + google: "Google search", + ai: "ChatGPT, Claude or another AI", + hacker_news: "Hacker News", + github: "GitHub", + friend: "A friend or colleague", + other: "Other", +}; + // Title, description, canonical and social tags for a public page. `image` // replaces the root's 1200x630 social image. export function seo({ diff --git a/src/routes/onboarding.tsx b/src/routes/onboarding.tsx index 71512ec..b0448f7 100644 --- a/src/routes/onboarding.tsx +++ b/src/routes/onboarding.tsx @@ -18,9 +18,10 @@ import { NativeSelectOption, } from "#/components/ui/native-select"; import { identify } from "#/lib/analytics"; -import { isLocalPath, NOINDEX } from "#/lib/app"; +import { HEARD_FROM, HEARD_FROM_LABELS, isLocalPath, NOINDEX } from "#/lib/app"; import { localDay } from "#/lib/books-settings"; import { CURRENCIES } from "#/lib/format"; +import { getAnalyticsConfig } from "#/server/analytics.functions"; import { getSession } from "#/server/auth.functions"; import { createWorkspaceFn } from "#/server/onboarding.functions"; @@ -34,15 +35,19 @@ export const Route = createFileRoute("/onboarding")({ redirect: z.string().refine(isLocalPath).optional().catch(undefined), }), loader: async () => { - const user = await getSession(); + const [user, analytics] = await Promise.all([ + getSession(), + getAnalyticsConfig(), + ]); if (!user) throw redirect({ to: "/login" }); - return user; + // The answer only goes to PostHog, so self-hosts without it don't ask. + return { user, askHeardFrom: analytics.key !== null }; }, component: Onboarding, }); function Onboarding() { - const user = Route.useLoaderData(); + const { user, askHeardFrom } = Route.useLoaderData(); useEffect(() => identify(user.id, user.analytics), [user]); const back = Route.useSearch().redirect; const navigate = useNavigate(); @@ -50,6 +55,9 @@ function Onboarding() { const [name, setName] = useState(""); const [currency, setCurrency] = useState<(typeof CURRENCIES)[number]>("USD"); const [incorporated, setIncorporated] = useState(false); + const [heardFrom, setHeardFrom] = useState<(typeof HEARD_FROM)[number] | "">( + "", + ); const [busy, setBusy] = useState(false); async function submit(e: React.FormEvent) { @@ -62,6 +70,7 @@ function Onboarding() { currency, timezone, incorporatedOn: incorporated ? localDay() : null, + heardFrom: heardFrom || undefined, }, }); if (back) navigate({ href: back }); @@ -120,6 +129,29 @@ function Onboarding() { change it in Books. + {askHeardFrom && ( + + + Where did you hear about OpenProfit? + + + setHeardFrom(e.target.value as (typeof HEARD_FROM)[number]) + } + > + + Choose one (optional) + + {HEARD_FROM.map((k) => ( + + {HEARD_FROM_LABELS[k]} + + ))} + + + )}