mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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.
364 lines
9.6 KiB
TypeScript
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>
|
|
)
|
|
}
|