"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 ( Submit ) } 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 ( {alt} ) } 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: ( MCP ), 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: X, 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" && ( {(() => { const Icon = SOURCE_ICON[detected as "x" | "linkedin"] return })()} )} handleChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && canSubmit) handleSubmit(detected as "x" | "linkedin") }} placeholder="Paste an X handle, LinkedIn URL, or drop a PDF" className={cn( "w-full py-3 bg-[#070E1B] border rounded-xl text-white text-sm placeholder:text-[#525966] focus:outline-none transition-all", detected && detected !== "resume" ? "pl-8 pr-11" : "px-4 pr-11", detected ? "border-[#2261CA]/50 focus:border-[#2261CA]" : "border-[#52596633] focus:border-white/20", )} /> {hasDetectedAccount && detected !== "resume" && ( {isCheckingAccount ? ( ) : canSubmit ? ( handleSubmit(detected as "x" | "linkedin") } className="rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer" style={{ background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)", }} > ) : null} )}
{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}

)}
) }