supermemory/apps/web/app/(app)/onboarding/page.tsx
MaheshtheDev cf47d73126 feat(onboarding): instrument brain onboarding analytics (#1099)
Wire PostHog funnel events (started, step viewed/completed, mode, workspace, sources, ingest, team, completed) across the brain onboarding flow, and drop stale pre-brain event defs from analytics.ts.

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/5b48bbe4-422a-4577-b6bf-fd9416a6846b)
- Requested by: Sreeram Sreedhar (sreeram@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
2026-06-20 07:48:15 +00:00

364 lines
9.6 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { analytics } from "@/lib/analytics"
import { BrainShell } from "@/components/onboarding-brain/shell"
import {
StepAbout,
type AboutValues,
} from "@/components/onboarding-brain/step-about"
import {
StepSources,
type SourcesValues,
} from "@/components/onboarding-brain/step-sources"
import { StepIngest } from "@/components/onboarding-brain/step-ingest"
import {
StepTeam,
type TeamValues,
} from "@/components/onboarding-brain/step-team"
import {
BRAIN_STEPS,
type BrainMetadata,
type BrainMode,
type BrainStep,
containerTagFromWorkspace,
detectModeFromEmail,
generateOrgSlug,
generateUsername,
workspaceDomainFromEmail,
workspaceNameFromEmail,
} from "@/components/onboarding-brain/types"
const STORAGE_KEY = "supermemory-brain-onboarding-v1"
const countsAsConnectedSource = (state: unknown) =>
state === "connected" || state === "waitlist"
export default function BrainOnboardingPage() {
const router = useRouter()
const params = useSearchParams()
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
useAuth()
const stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about"
const initialStep: BrainStep = BRAIN_STEPS.includes(stepFromUrl)
? stepFromUrl
: "about"
const [step, setStep] = useState<BrainStep>(initialStep)
const detectedMode = useMemo(
() => detectModeFromEmail(user?.email),
[user?.email],
)
const suggestedWorkspaceName = useMemo(
() => workspaceNameFromEmail(user?.email),
[user?.email],
)
const domain = useMemo(
() => workspaceDomainFromEmail(user?.email),
[user?.email],
)
const [mode, setMode] = useState<BrainMode>(detectedMode)
const [about, setAbout] = useState<AboutValues>({
name: user?.name ?? "",
about: "",
workspaceName: suggestedWorkspaceName,
workspaceDomain: domain ?? "",
})
const [sources, setSources] = useState<SourcesValues>({
connected: {},
driveScope: "selective",
})
const [team, setTeam] = useState<TeamValues>({
invites: [],
visibility: "team-private",
suggestChanges: false,
})
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const cached = JSON.parse(raw) as {
mode?: BrainMode
about?: AboutValues
sources?: SourcesValues
team?: TeamValues
}
if (cached.mode) setMode(cached.mode)
if (cached.about) setAbout((a) => ({ ...a, ...cached.about }))
if (cached.sources) setSources((s) => ({ ...s, ...cached.sources }))
if (cached.team) setTeam((t) => ({ ...t, ...cached.team }))
} catch {}
}, [])
useEffect(() => {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ mode, about, sources, team }),
)
} catch {}
}, [mode, about, sources, team])
const navTrigger = useRef<"user" | "auto">("auto")
const startedRef = useRef(false)
useEffect(() => {
if (startedRef.current) return
startedRef.current = true
analytics.onboardingStarted({
mode: detectedMode,
entry_step: initialStep,
})
analytics.onboardingStepViewed({
step: initialStep,
index: BRAIN_STEPS.indexOf(initialStep),
trigger: "auto",
})
}, [detectedMode, initialStep])
const firstStepRender = useRef(true)
useEffect(() => {
// Skip the mount run — the gated effect above fires the initial view.
if (firstStepRender.current) {
firstStepRender.current = false
return
}
analytics.onboardingStepViewed({
step,
index: BRAIN_STEPS.indexOf(step),
trigger: navTrigger.current,
})
navTrigger.current = "auto"
}, [step])
const setStepAndUrl = useCallback(
(next: BrainStep) => {
navTrigger.current = "user"
setStep(next)
const url = new URL(window.location.href)
url.searchParams.set("step", next)
router.replace(url.pathname + url.search, { scroll: false })
},
[router],
)
const containerTag = useMemo(
() =>
containerTagFromWorkspace(
about.workspaceName || suggestedWorkspaceName,
mode,
),
[about.workspaceName, suggestedWorkspaceName, mode],
)
const isScale = useMemo(() => {
const plan = (org?.metadata as Record<string, unknown> | undefined)?.plan
return plan === "scale" || plan === "scale_yearly"
}, [org])
const finish = useCallback(async () => {
analytics.onboardingCompleted({
mode,
steps_completed: BRAIN_STEPS.length,
sources_connected: Object.values(sources.connected).filter(
countsAsConnectedSource,
).length,
invites_sent: team.invites.filter((i) => i.email.trim()).length,
})
try {
localStorage.removeItem(STORAGE_KEY)
} catch {}
router.push("/?onboarded=1")
}, [router, mode, sources, team])
const goNext = useCallback(() => {
const idx = BRAIN_STEPS.indexOf(step)
analytics.onboardingStepCompleted({ step, index: idx })
const next = BRAIN_STEPS[idx + 1]
if (!next) {
finish()
return
}
setStepAndUrl(next)
}, [step, setStepAndUrl, finish])
const [creatingOrg, setCreatingOrg] = useState(false)
const creatingOrgRef = useRef(false)
const ensureOrg = useCallback(async () => {
if (organizations && organizations.length > 0) return
const name = (about.workspaceName || suggestedWorkspaceName).trim()
const slug = generateOrgSlug(name)
const metadata: BrainMetadata & { signupSource: string } = {
signupSource: "consumer",
brainOnboardingVersion: "v1",
brainMode: mode,
brainWorkspaceName: name,
brainWorkspaceDomain:
mode === "team" ? about.workspaceDomain || domain : null,
brainContainerTag: containerTag,
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
}
const result = await authClient.organization.create({
name,
slug,
metadata,
})
await setActiveOrg(result.data?.slug ?? slug)
if (about.name.trim()) {
await authClient.updateUser({
name: about.name.trim(),
displayUsername: about.name.trim(),
username: generateUsername(about.name),
})
}
await refetchOrganizations()
analytics.onboardingWorkspaceCreated({
mode,
has_about: Boolean(about.about.trim()),
has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)),
})
}, [
organizations,
about,
suggestedWorkspaceName,
mode,
domain,
containerTag,
setActiveOrg,
refetchOrganizations,
])
const handleAboutContinue = useCallback(async () => {
if (creatingOrgRef.current) return
creatingOrgRef.current = true
setCreatingOrg(true)
try {
await ensureOrg()
goNext()
} catch (e) {
console.error("Failed to create organization:", e)
analytics.onboardingWorkspaceCreateFailed({
error: e instanceof Error ? e.message : String(e),
})
toast.error("Couldn't create your workspace. Please try again.")
} finally {
creatingOrgRef.current = false
setCreatingOrg(false)
}
}, [ensureOrg, goNext])
const [sendingInvites, setSendingInvites] = useState(false)
const sendingInvitesRef = useRef(false)
const handleTeamContinue = useCallback(async () => {
if (sendingInvitesRef.current) return
const pending = team.invites.filter((i) => i.email.trim())
if (pending.length === 0) {
analytics.onboardingTeamSkipped()
goNext()
return
}
sendingInvitesRef.current = true
setSendingInvites(true)
try {
if (!org?.id) throw new Error("No active organization")
const results = await Promise.allSettled(
pending.map((inv) =>
authClient.organization.inviteMember({
email: inv.email.trim().toLowerCase(),
role: inv.role,
organizationId: org.id,
resend: true,
}),
),
)
const failed = results.filter(
(r) =>
r.status === "rejected" ||
(r.status === "fulfilled" && Boolean(r.value?.error)),
).length
analytics.onboardingInvitesSent({
sent: pending.length - failed,
failed,
})
if (failed > 0) {
toast.error(
`${failed} of ${pending.length} invite${pending.length === 1 ? "" : "s"} couldn't be sent.`,
)
} else {
toast.success(
`Sent ${pending.length} invite${pending.length === 1 ? "" : "s"}.`,
)
}
} catch (e) {
console.error("Failed to send invites:", e)
toast.error("Couldn't send invites. You can invite teammates later.")
} finally {
sendingInvitesRef.current = false
setSendingInvites(false)
goNext()
}
}, [team.invites, org, goNext])
const mcpUrl = "https://mcp.supermemory.ai/mcp"
return (
<BrainShell
step={step}
domain={mode === "team" ? about.workspaceDomain || domain : null}
>
{step === "about" && (
<StepAbout
mode={mode}
onModeChange={(m) => {
analytics.onboardingModeSelected({ mode: m })
setMode(m)
}}
domain={domain}
suggestedWorkspaceName={suggestedWorkspaceName}
defaultName={user?.name ?? ""}
avatarUrl={user?.image ?? null}
values={about}
onChange={setAbout}
onContinue={handleAboutContinue}
submitting={creatingOrg}
/>
)}
{step === "sources" && (
<StepSources
containerTag={containerTag}
workspaceName={about.workspaceName || suggestedWorkspaceName}
mode={mode}
values={sources}
onChange={setSources}
onContinue={goNext}
/>
)}
{step === "ingest" && <StepIngest mcpUrl={mcpUrl} onContinue={goNext} />}
{step === "team" && (
<StepTeam
mode={mode}
isScale={isScale}
inviteDomain={about.workspaceDomain || domain || null}
values={team}
onChange={setTeam}
onContinue={handleTeamContinue}
onSkip={() => {
analytics.onboardingTeamSkipped()
goNext()
}}
submitting={sendingInvites}
onUpgrade={() => router.push("/settings/billing")}
/>
)}
</BrainShell>
)
}