"use client"
import {
useState,
useRef,
useCallback,
useEffect,
useMemo,
type Dispatch,
type RefObject,
type ReactNode,
type SetStateAction,
} from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@lib/auth-context"
import { Logo } from "@ui/assets/Logo"
import { motion, AnimatePresence } from "motion/react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import NovaOrb from "@/components/nova/nova-orb"
import Image from "next/image"
import { IntegrationGridCard } from "@/components/integrations/integration-grid-card"
import {
CHROME_EXTENSION_URL,
RAYCAST_EXTENSION_URL,
ADD_MEMORY_SHORTCUT_URL,
} from "@repo/lib/constants"
import {
ChromeIcon,
AppleShortcutsIcon,
RaycastIcon,
} from "@/components/integration-icons"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import {
Sparkles,
ChevronLeft,
ChevronRight,
AlertCircle,
CheckCircle2,
Loader2,
} from "lucide-react"
import { analytics, type OnboardingStep } from "@/lib/analytics"
import { consumePendingConnectUrl } from "@/lib/constants"
type DetectedSource = "x" | "linkedin" | "resume" | null
type Status = "idle" | "processing" | "done" | "error"
type AccountLookupStatus = "checking" | "found" | "not_found" | "error"
type AccountLookup = {
source: "x" | "linkedin"
status: AccountLookupStatus
message: string
}
type DocStatus =
| "unknown"
| "queued"
| "extracting"
| "chunking"
| "embedding"
| "indexing"
| "done"
| "failed"
function XIcon({ className }: { className?: string }) {
return (
)
}
function LinkedInIcon({ className }: { className?: string }) {
return (
)
}
function SubmitArrow() {
return (
)
}
function detectSource(value: string): DetectedSource {
const v = value.trim().toLowerCase()
if (!v) return null
if (v.includes("linkedin.com/in/") || v.includes("linkedin.com/pub/"))
return "linkedin"
if (v.includes("x.com/") || v.includes("twitter.com/") || v.startsWith("@"))
return "x"
if (/^[a-z0-9_]{1,50}$/i.test(v)) return "x"
return null
}
function generateUsername(name: string) {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/(^_|_$)/g, "") || "user"
return `${base}${Math.floor(100000 + Math.random() * 900000)}`
}
function generateOrgSlug(name: string) {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "org"
return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
}
const SOURCE_ICON: Record<
"x" | "linkedin",
React.FC<{ className?: string }>
> = {
x: XIcon,
linkedin: LinkedInIcon,
}
const SOURCE_LABEL: Record<"x" | "linkedin", string> = {
x: "X profile detected - checking account",
linkedin: "LinkedIn profile detected - checking account",
}
const SOURCE_NAME: Record<"x" | "linkedin", string> = {
x: "X",
linkedin: "LinkedIn",
}
type SpotlightItem = {
id: string
title: string
description: string
icon: ReactNode
pro?: boolean
onOpen: () => void
}
type SpotlightCategoryId = "coding" | "productivity" | "agents"
const SPOTLIGHT_CATEGORY_TABS: { id: SpotlightCategoryId; label: string }[] = [
{ id: "coding", label: "Coding" },
{ id: "productivity", label: "Productivity" },
{ id: "agents", label: "Agents" },
]
const SPOTLIGHT_CATEGORY_ORDER: SpotlightCategoryId[] =
SPOTLIGHT_CATEGORY_TABS.map((t) => t.id)
function spotlightPluginCornerIcon(src: string, alt: string) {
return (
)
}
const spotlightConnectionsIcon = (
)
function buildSpotlightCatalog(
router: ReturnType,
): Record {
const track = (integration: string) =>
analytics.onboardingIntegrationClicked({ integration })
const openPluginsPanel = () => {
void router.push("/?view=plugins")
}
return {
coding: [
{
id: "mcp",
title: "Connect to AI",
description:
"Set up MCP to use your memory in Cursor, Claude, and more",
icon: (
),
onOpen: () => {
track("mcp")
void router.push("/?view=integrations")
},
},
{
id: "coding-claude-supermemory",
title: "Claude Supermemory",
description:
"Persistent memory for Claude Code — context and decisions across sessions.",
icon: spotlightPluginCornerIcon(
"/images/plugins/claude-code.svg",
"Claude Supermemory",
),
pro: true,
onOpen: () => {
track("plugin_claude_supermemory")
openPluginsPanel()
},
},
{
id: "coding-opencode",
title: "OpenCode",
description:
"Memory layer for OpenCode — search past sessions and inject context.",
icon: spotlightPluginCornerIcon(
"/images/plugins/opencode.svg",
"OpenCode",
),
pro: true,
onOpen: () => {
track("plugin_opencode")
openPluginsPanel()
},
},
{
id: "connections",
title: "Connections",
description:
"Link Notion, Google Drive, or OneDrive to import your docs",
icon: spotlightConnectionsIcon,
pro: true,
onOpen: () => {
track("connections")
void router.push("/?add=connect")
},
},
],
productivity: [
{
id: "chrome",
title: "Chrome Extension",
description:
"Save any webpage, import bookmarks, sync ChatGPT memories",
icon: ,
onOpen: () => {
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer")
analytics.onboardingChromeExtensionClicked({ source: "onboarding" })
},
},
{
id: "raycast",
title: "Raycast",
description: "Add and search memories from Raycast on Mac",
icon: ,
onOpen: () => {
track("raycast")
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer")
},
},
{
id: "shortcuts",
title: "Apple Shortcuts",
description: "Add memories directly from iPhone, iPad or Mac",
icon: ,
onOpen: () => {
track("shortcuts")
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank", "noopener,noreferrer")
},
},
{
id: "import",
title: "Import Bookmarks",
description: "Bring in X/Twitter bookmarks and turn them into memories",
icon:
,
onOpen: () => {
track("import_x")
void router.push("/?view=import")
},
},
],
agents: [
{
id: "agents-openclaw",
title: "OpenClaw",
description:
"Multi-platform memory for OpenClaw — Telegram, WhatsApp, Discord, Slack, and more.",
icon: spotlightPluginCornerIcon(
"/images/plugins/openclaw.svg",
"OpenClaw",
),
pro: true,
onOpen: () => {
track("plugin_openclaw")
openPluginsPanel()
},
},
{
id: "agents-hermes",
title: "Hermes",
description:
"Memory layer for the Hermes agent — recall, capture, and user profile.",
icon: spotlightPluginCornerIcon("/images/plugins/hermes.svg", "Hermes"),
onOpen: () => {
track("plugin_hermes")
openPluginsPanel()
},
},
{
id: "agents-claude-supermemory",
title: "Claude Supermemory",
description:
"Persistent memory for Claude Code — context and decisions across sessions.",
icon: spotlightPluginCornerIcon(
"/images/plugins/claude-code.svg",
"Claude Supermemory",
),
pro: true,
onOpen: () => {
track("plugin_claude_supermemory")
openPluginsPanel()
},
},
{
id: "agents-opencode",
title: "OpenCode",
description:
"Memory layer for OpenCode — search past sessions and inject context.",
icon: spotlightPluginCornerIcon(
"/images/plugins/opencode.svg",
"OpenCode",
),
pro: true,
onOpen: () => {
track("plugin_opencode")
openPluginsPanel()
},
},
{
id: "console-api",
title: "Console & API",
description:
"API keys, orgs, and the hosted API for production agent workloads",
icon: ,
onOpen: () => {
track("console_api")
window.open(
"https://console.supermemory.ai",
"_blank",
"noopener,noreferrer",
)
},
},
],
}
}
function isAccountSource(source: DetectedSource): source is "x" | "linkedin" {
return source === "x" || source === "linkedin"
}
const STATUS_TO_STEP: Record = {
idle: "profile_input",
processing: "processing",
done: "done",
error: "error",
}
function useSpotlightAutoRotation(
status: Status,
pauseSpotlight: boolean,
setSpotlightCategory: Dispatch>,
) {
useEffect(() => {
if (status !== "processing") return
if (pauseSpotlight) return
const n = SPOTLIGHT_CATEGORY_ORDER.length
if (n <= 1) return
const t = setInterval(() => {
setSpotlightCategory((cur) => {
const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur)
const from = i >= 0 ? i : 0
const next = (from + 1) % n
return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur
})
}, 8000)
return () => clearInterval(t)
}, [status, pauseSpotlight, setSpotlightCategory])
}
function useInitialInputFocus(inputRef: RefObject) {
useEffect(() => {
const t = setTimeout(() => inputRef.current?.focus(), 500)
return () => clearTimeout(t)
}, [inputRef])
}
function useAccountLookup({
detected,
status,
value,
}: {
detected: DetectedSource
status: Status
value: string
}) {
const [accountLookup, setAccountLookup] = useState(null)
useEffect(() => {
if (status !== "idle") return
const source = isAccountSource(detected) ? detected : null
const trimmedValue = value.trim()
if (!source || !trimmedValue) {
setAccountLookup(null)
return
}
const controller = new AbortController()
setAccountLookup({
source,
status: "checking",
message: SOURCE_LABEL[source],
})
const timeout = setTimeout(async () => {
try {
const params = new URLSearchParams({
source,
value: trimmedValue,
})
const response = await fetch(
`/api/onboarding/account-status?${params.toString()}`,
{ signal: controller.signal },
)
const data: {
found?: boolean
handle?: string
reason?: string
verified?: boolean
} = await response.json().catch(() => ({}))
if (controller.signal.aborted) return
if (response.ok && data.found === true) {
const account =
source === "x" && data.handle ? ` @${data.handle}` : ""
setAccountLookup({
source,
status: "found",
message: `${SOURCE_NAME[source]} account${account} found - press Enter to continue`,
})
return
}
if (
(response.ok && data.found === false) ||
data.reason === "invalid"
) {
setAccountLookup({
source,
status: "not_found",
message: `${SOURCE_NAME[source]} account not found. Check the link and try again.`,
})
return
}
setAccountLookup({
source,
status: "error",
message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`,
})
} catch (err) {
if (controller.signal.aborted) return
console.error(err)
setAccountLookup({
source,
status: "error",
message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`,
})
}
}, 450)
return () => {
clearTimeout(timeout)
controller.abort()
}
}, [detected, status, value])
return accountLookup
}
function usePollingCleanup(
pollingRef: RefObject | null>,
) {
useEffect(() => {
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [pollingRef])
}
function useDoneAnimation(
status: Status,
setStampLanded: Dispatch>,
setVisibleSnippets: Dispatch>,
) {
useEffect(() => {
if (status !== "done") return
setStampLanded(false)
setVisibleSnippets(0)
const t1 = setTimeout(() => setStampLanded(true), 400)
const t2 = setTimeout(() => setVisibleSnippets(1), 900)
const t3 = setTimeout(() => setVisibleSnippets(2), 1200)
const t4 = setTimeout(() => setVisibleSnippets(3), 1500)
return () => {
clearTimeout(t1)
clearTimeout(t2)
clearTimeout(t3)
clearTimeout(t4)
}
}, [status, setStampLanded, setVisibleSnippets])
}
export default function OnboardingPage() {
const router = useRouter()
const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth()
const [value, setValue] = useState("")
const [detected, setDetected] = useState(null)
const [resumeFile, setResumeFile] = useState(null)
const [isDragging, setIsDragging] = useState(false)
const [status, setStatus] = useState("idle")
const [_docStatus, setDocStatus] = useState("queued")
const [memoriesCount, setMemoriesCount] = useState(0)
const [memorySnippets, setMemorySnippets] = useState([])
const [docTitle, setDocTitle] = useState("")
const [errorMsg, setErrorMsg] = useState("")
const [stampLanded, setStampLanded] = useState(false)
const [visibleSnippets, setVisibleSnippets] = useState(0)
const inputRef = useRef(null)
const fileRef = useRef(null)
const pollingRef = useRef | null>(null)
const skippingRef = useRef(false)
const completedTrackedRef = useRef(false)
const [isSkipping, setIsSkipping] = useState(false)
const [spotlightCategory, setSpotlightCategory] =
useState("productivity")
/** Navigate home, or back to the plugin connect page if one is pending. */
const goHomeOrPendingConnect = useCallback(() => {
const pendingPath = consumePendingConnectUrl()
router.push(pendingPath ?? "/")
}, [router])
const [pauseSpotlight, setPauseSpotlight] = useState(false)
const spotlightCatalog = useMemo(
() => buildSpotlightCatalog(router),
[router],
)
const categoryCards = spotlightCatalog[spotlightCategory] ?? []
const bumpSpotlightCategory = useCallback(
(delta: number) => {
const n = SPOTLIGHT_CATEGORY_ORDER.length
if (n === 0) return
const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(spotlightCategory)
const from = i >= 0 ? i : 0
const next = (from + delta + n) % n
const id = SPOTLIGHT_CATEGORY_ORDER[next]
if (id) setSpotlightCategory(id)
},
[spotlightCategory],
)
useSpotlightAutoRotation(status, pauseSpotlight, setSpotlightCategory)
useInitialInputFocus(inputRef)
const accountLookup = useAccountLookup({ detected, status, value })
usePollingCleanup(pollingRef)
useDoneAnimation(status, setStampLanded, setVisibleSnippets)
// biome-ignore lint/correctness/useExhaustiveDependencies: fire per status transition only
useEffect(() => {
analytics.onboardingStepViewed({
step: STATUS_TO_STEP[status],
trigger: "auto",
})
if (status === "done" && !completedTrackedRef.current) {
completedTrackedRef.current = true
analytics.onboardingCompleted({
source: isAccountSource(detected) ? detected : undefined,
memories_count: memoriesCount,
})
}
}, [status])
const handleChange = (v: string) => {
setValue(v)
setDetected(detectSource(v))
}
const ensureOrg = useCallback(async () => {
if (organizations && organizations.length > 0) return
const name = user?.name || user?.email || "Personal"
const slug = generateOrgSlug(name)
const result = await authClient.organization.create({
name,
slug,
metadata: { signupSource: "consumer" },
})
await setActiveOrg(result.data?.slug ?? slug)
if (user?.name) {
await authClient.updateUser({
displayUsername: user.name,
username: generateUsername(user.name),
})
}
await refetchOrganizations()
}, [user, organizations, refetchOrganizations, setActiveOrg])
const handleSkip = useCallback(async () => {
if (skippingRef.current) return
skippingRef.current = true
setIsSkipping(true)
analytics.onboardingSkipped({ from_step: STATUS_TO_STEP[status] })
try {
await ensureOrg()
const pendingPath = consumePendingConnectUrl()
router.push(pendingPath ?? "/")
} catch (err) {
console.error(err)
skippingRef.current = false
setIsSkipping(false)
}
}, [ensureOrg, router, status])
const pollDocument = useCallback((docId: string) => {
const maxAttempts = 60
let attempt = 0
pollingRef.current = setInterval(async () => {
attempt++
if (attempt > maxAttempts) {
if (pollingRef.current) clearInterval(pollingRef.current)
setErrorMsg("Processing is taking too long. Try again later.")
setStatus("error")
return
}
try {
const res = await $fetch("@get/documents/:id", {
params: { id: docId },
disableValidation: true,
})
if (!res.data) return
const doc = res.data as {
status?: DocStatus
memories?: { memory: string; title?: string }[]
title?: string
}
const s = doc.status ?? "queued"
setDocStatus(s)
if (doc.memories) {
setMemoriesCount(doc.memories.length)
setMemorySnippets(
doc.memories
.slice(0, 3)
.map((m: { memory: string; title?: string }) => m.memory)
.filter(Boolean),
)
}
if (doc.title) setDocTitle(doc.title)
if (s === "done") {
if (pollingRef.current) clearInterval(pollingRef.current)
await new Promise((r) => setTimeout(r, 600))
setStatus("done")
} else if (s === "failed") {
if (pollingRef.current) clearInterval(pollingRef.current)
setErrorMsg("Processing failed. You can skip and try later.")
setStatus("error")
}
} catch {
// keep polling on transient errors
}
}, 1500)
}, [])
const handleSubmit = useCallback(
async (source: "x" | "linkedin" | "resume", resumeFileOverride?: File) => {
analytics.onboardingProfileSubmitted({ source })
setStatus("processing")
setSpotlightCategory("productivity")
setPauseSpotlight(false)
setDocStatus("queued")
setMemoriesCount(0)
setDocTitle("")
try {
await ensureOrg()
let docId: string | undefined
if (source === "x" || source === "linkedin") {
const raw = value.trim()
const content = raw.startsWith("http")
? raw
: source === "x"
? `https://x.com/${raw.replace(/^@/, "")}`
: `https://${raw}`
const res = await $fetch("@post/documents", {
body: {
content,
metadata: { sm_source: "onboarding" },
},
})
docId = (res.data as { id?: string } | undefined)?.id
} else if (source === "resume") {
const file = resumeFileOverride ?? resumeFile
if (!file) throw new Error("No resume file selected")
const formData = new FormData()
formData.append("file", file)
const uploadRes = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
{ method: "POST", body: formData, credentials: "include" },
)
if (!uploadRes.ok) throw new Error("Resume upload failed")
const uploadData = await uploadRes.json()
docId = uploadData?.id
}
if (docId) {
pollDocument(docId)
} else {
await new Promise((r) => setTimeout(r, 2000))
setStatus("done")
}
} catch (err) {
console.error(err)
setErrorMsg("Something went wrong. You can skip and try later.")
setStatus("error")
}
},
[value, resumeFile, ensureOrg, pollDocument],
)
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const f = e.dataTransfer.files[0]
if (f?.type === "application/pdf") {
setResumeFile(f)
handleSubmit("resume", f)
}
}
const hasDetectedAccount = detected === "x" || detected === "linkedin"
const currentAccountLookup =
accountLookup?.source === detected ? accountLookup : null
const isCheckingAccount =
hasDetectedAccount &&
(!currentAccountLookup || currentAccountLookup.status === "checking")
const canSubmit = Boolean(
hasDetectedAccount &&
currentAccountLookup &&
currentAccountLookup.status !== "checking" &&
currentAccountLookup.status !== "not_found",
)
return (
// biome-ignore lint/a11y/noStaticElementInteractions: full-surface drag-and-drop for resume PDF
{
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
{isDragging && (
Drop your PDF resume
)}
{/* ── IDLE ── */}
{status === "idle" && (
Let NOVA know about you
{detected && detected !== "resume" && (
{currentAccountLookup?.status === "found" && (
)}
{currentAccountLookup?.status === "not_found" && (
)}
{currentAccountLookup?.status === "error" && (
)}
{currentAccountLookup?.message ??
SOURCE_LABEL[detected as "x" | "linkedin"]}
)}
{!detected && (
{[
{
label: "@yourhandle",
action: () => {
handleChange("@")
inputRef.current?.focus()
},
},
{
label: "linkedin.com/in/you",
action: () => {
handleChange("linkedin.com/in/")
inputRef.current?.focus()
},
},
{
label: "Drop a PDF resume",
action: () => fileRef.current?.click(),
},
].map((chip) => (
))}
)}
{
const f = e.target.files?.[0]
if (f) {
setResumeFile(f)
handleSubmit("resume", f)
}
}}
/>
)}
{/* ── PROCESSING ── */}
{status === "processing" && (
Finishing your first save
Most finish in under a minute. Below is optional: ways to add
more later.
{/* biome-ignore lint/a11y/noStaticElementInteractions: pause category rotation on hover/focus within */}
setPauseSpotlight(true)}
onMouseLeave={() => setPauseSpotlight(false)}
onFocus={() => setPauseSpotlight(true)}
onBlur={(e) => {
if (
!e.currentTarget.contains(e.relatedTarget as Node | null)
) {
setPauseSpotlight(false)
}
}}
>
{SPOTLIGHT_CATEGORY_TABS.map((tab) => (
))}
{SPOTLIGHT_CATEGORY_TABS.map((tab) => (
{categoryCards.map((card) => (
))}
)}
{/* ── DONE ── */}
{status === "done" && (
It's in your memory
Your first save is ready. When you want more, use Integrations
for browser, phone, editor, and AI tools, all in one place.
{/* Document card with stamp */}
{/* Clickable document card */}
router.push("/?view=list")}
className="group w-full text-left bg-[#080E18] border border-[rgba(255,255,255,0.07)] rounded-2xl p-4 cursor-pointer hover:border-[rgba(255,255,255,0.14)] transition-colors"
>
{/* Faux document lines */}
{docTitle || "Your document"}
{memoriesCount} memories
View in memories →
{/* Stamp */}
{/* Ink ring ripple */}
{stampLanded && (
)}
Memorized
{/* Memory snippets */}
Nova learned
{memorySnippets.slice(0, 3).map((snippet, i) => (
i
? { opacity: 1, x: 0 }
: { opacity: 0, x: -8 }
}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex items-start gap-2 text-left"
>
{snippet}
))}
{/* CTAs */}
)}
{/* ── ERROR ── */}
{status === "error" && (
{errorMsg}
)}
)
}