diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index ad35867cb..3f2d72669 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -6,10 +6,8 @@ import { useCallback, useEffect, useMemo, - type Dispatch, type RefObject, type ReactNode, - type SetStateAction, } from "react" import { useRouter } from "next/navigation" import { useAuth } from "@lib/auth-context" @@ -20,32 +18,25 @@ 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 { TeamInviteBeat } from "@/components/onboarding/team-invite-beat" +import { CHROME_EXTENSION_URL } from "@repo/lib/constants" +import { ChromeIcon } from "@/components/integration-icons" import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" import { - Sparkles, - ChevronLeft, - ChevronRight, AlertCircle, CheckCircle2, Loader2, + Check, + ChevronLeft, } from "lucide-react" import { analytics } from "@/lib/analytics" import { consumePendingConnectUrl } from "@/lib/constants" type DetectedSource = "x" | "linkedin" | "resume" | null -type Status = "idle" | "processing" | "done" | "error" +type SubmittedSource = "x" | "linkedin" | "resume" +type Step = 1 | 2 | 3 +type DocState = "idle" | "processing" | "done" | "failed" type AccountLookupStatus = "checking" | "found" | "not_found" | "error" type AccountLookup = { source: "x" | "linkedin" @@ -62,6 +53,8 @@ type DocStatus = | "done" | "failed" +const TOTAL_STEPS = 3 + function XIcon({ className }: { className?: string }) { return ( = { linkedin: "LinkedIn", } -type SpotlightItem = { - id: string +type SmartStep = { title: string description: string icon: ReactNode - pro?: boolean + isExternal?: 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 = ( +const connectionsIcon = (
@@ -187,190 +156,61 @@ const spotlightConnectionsIcon = (
) -function buildSpotlightCatalog( +/** Capability cards shown on step 2, led by the highest-leverage one for the source. */ +function buildNextSteps( + source: SubmittedSource, router: ReturnType, -): Record { +): SmartStep[] { const track = (integration: string) => analytics.onboardingIntegrationClicked({ integration }) - const openPluginsPanel = () => { - void router.push("/?view=plugins") + const importBookmarks: SmartStep = { + title: "Import your bookmarks", + description: "Bring in your X bookmarks and turn them into memories", + icon: X, + onOpen: () => { + track("import_x") + void router.push("/?view=import") + }, + } + const chromeExt: SmartStep = { + title: "Chrome extension", + description: "Save any webpage and sync ChatGPT memories", + icon: , + isExternal: true, + onOpen: () => { + track("chrome") + window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer") + }, + } + const connectTools: SmartStep = { + title: "Connect your tools", + description: "Link Notion, Google Drive, or OneDrive to import docs", + icon: connectionsIcon, + onOpen: () => { + track("connections") + void router.push("/?add=connect") + }, + } + const connectAI: SmartStep = { + title: "Connect to AI", + description: "Use your memory in Cursor, Claude, and more via MCP", + icon: ( + MCP + ), + onOpen: () => { + track("mcp") + void router.push("/?view=integrations") + }, } - 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", - ) - }, - }, - ], + switch (source) { + case "x": + return [importBookmarks, chromeExt, connectAI] + case "linkedin": + return [chromeExt, connectTools, connectAI] + case "resume": + return [connectTools, chromeExt, connectAI] } } @@ -378,28 +218,6 @@ function isAccountSource(source: DetectedSource): source is "x" | "linkedin" { return source === "x" || source === "linkedin" } -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) @@ -409,17 +227,17 @@ function useInitialInputFocus(inputRef: RefObject) { function useAccountLookup({ detected, - status, + active, value, }: { detected: DetectedSource - status: Status + active: boolean value: string }) { const [accountLookup, setAccountLookup] = useState(null) useEffect(() => { - if (status !== "idle") return + if (!active) return const source = isAccountSource(detected) ? detected : null const trimmedValue = value.trim() @@ -498,7 +316,7 @@ function useAccountLookup({ clearTimeout(timeout) controller.abort() } - }, [detected, status, value]) + }, [detected, active, value]) return accountLookup } @@ -513,82 +331,104 @@ function usePollingCleanup( }, [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]) +/** Animated pill, top-right, once the background save lands. Click to jump to the doc. */ +function SavedToast({ + docState, + memoriesCount, + onSeeMemories, + onRetry, +}: { + docState: DocState + memoriesCount: number + onSeeMemories: () => void + onRetry: () => void +}) { + return ( + + {docState === "done" && ( + + + + + + + Your memory is saved + + + {memoriesCount > 0 + ? `${memoriesCount} memories · See it →` + : "Click to see it →"} + + + + )} + {docState === "failed" && ( + + + Couldn't save — tap to retry + + )} + + ) } export default function OnboardingPage() { const router = useRouter() const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth() + const [step, setStep] = useState(1) 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 [submittedSource, setSubmittedSource] = + useState(null) + const [docState, setDocState] = 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 [spotlightCategory, setSpotlightCategory] = - useState("productivity") - /** Navigate home, or back to the plugin connect page if one is pending. */ + const goToMemories = useCallback(() => { + router.push("/?view=list") + }, [router]) + 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], + const nextSteps = useMemo( + () => (submittedSource ? buildNextSteps(submittedSource, router) : []), + [submittedSource, router], ) - useSpotlightAutoRotation(status, pauseSpotlight, setSpotlightCategory) useInitialInputFocus(inputRef) - const accountLookup = useAccountLookup({ detected, status, value }) + const accountLookup = useAccountLookup({ + detected, + active: step === 1, + value, + }) usePollingCleanup(pollingRef) - useDoneAnimation(status, setStampLanded, setVisibleSnippets) const handleChange = (v: string) => { setValue(v) @@ -635,8 +475,7 @@ export default function OnboardingPage() { attempt++ if (attempt > maxAttempts) { if (pollingRef.current) clearInterval(pollingRef.current) - setErrorMsg("Processing is taking too long. Try again later.") - setStatus("error") + setDocState("failed") return } @@ -656,26 +495,14 @@ export default function OnboardingPage() { 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 (doc.memories) setMemoriesCount(doc.memories.length) if (s === "done") { if (pollingRef.current) clearInterval(pollingRef.current) - await new Promise((r) => setTimeout(r, 600)) - setStatus("done") + setDocState("done") } else if (s === "failed") { if (pollingRef.current) clearInterval(pollingRef.current) - setErrorMsg("Processing failed. You can skip and try later.") - setStatus("error") + setDocState("failed") } } catch { // keep polling on transient errors @@ -683,14 +510,14 @@ export default function OnboardingPage() { }, 1500) }, []) - const handleSubmit = useCallback( - async (source: "x" | "linkedin" | "resume", resumeFileOverride?: File) => { - setStatus("processing") - setSpotlightCategory("productivity") - setPauseSpotlight(false) + /** Kick off the save in the background and move the user into the wizard. */ + const startProcessing = useCallback( + async (source: SubmittedSource, resumeFileOverride?: File) => { + setSubmittedSource(source) + setDocState("processing") setDocStatus("queued") setMemoriesCount(0) - setDocTitle("") + setStep(2) try { await ensureOrg() @@ -728,25 +555,29 @@ export default function OnboardingPage() { if (docId) { pollDocument(docId) } else { - await new Promise((r) => setTimeout(r, 2000)) - setStatus("done") + setDocState("done") } } catch (err) { console.error(err) - setErrorMsg("Something went wrong. You can skip and try later.") - setStatus("error") + setDocState("failed") } }, [value, resumeFile, ensureOrg, pollDocument], ) + const retryProcessing = useCallback(() => { + if (pollingRef.current) clearInterval(pollingRef.current) + setDocState("idle") + setStep(1) + }, []) + 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) + startProcessing("resume", f) } } @@ -767,8 +598,7 @@ export default function OnboardingPage() { // biome-ignore lint/a11y/noStaticElementInteractions: full-surface drag-and-drop for resume PDF
{ @@ -793,6 +623,13 @@ export default function OnboardingPage() { )} + +
-
+
- {/* ── IDLE ── */} - {status === "idle" && ( + {/* ── STEP 1 · INPUT ── */} + {step === 1 && ( handleChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && canSubmit) - handleSubmit(detected as "x" | "linkedin") + startProcessing(detected as "x" | "linkedin") }} placeholder="Paste an X handle, LinkedIn URL, or drop a PDF" className={cn( @@ -888,7 +718,9 @@ export default function OnboardingPage() { type="button" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} - onClick={() => handleSubmit(detected as "x" | "linkedin")} + onClick={() => + startProcessing(detected as "x" | "linkedin") + } className="absolute right-1 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: @@ -989,343 +821,108 @@ export default function OnboardingPage() { const f = e.target.files?.[0] if (f) { setResumeFile(f) - handleSubmit("resume", f) + startProcessing("resume", f) } }} /> )} - {/* ── PROCESSING ── */} - {status === "processing" && ( + {/* ── STEP 2 · CAPABILITIES ── */} + {step === 2 && ( - + -
+

- Finishing your first save + Here's what you can do with Nova

- Most finish in under a minute. Below is optional: ways to add - more later. + We're saving your first memory in the background. Explore + while it finishes.

-
- {/* 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) => ( - - ))} - - -
- - +
+ {nextSteps.map((card) => ( + + ))}
)} - {/* ── DONE ── */} - {status === "done" && ( + {/* ── STEP 3 · TEAM ── */} + {step === 3 && ( -
-

- 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. -

-
+ +
+ )} + + + {/* ── WIZARD NAV (steps 2 & 3) ── */} + {step > 1 && ( +
+
+ {Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map((d) => ( + + ))} +
- {/* Document card with stamp */} -
- {/* Clickable document card */} - + {step === 3 && ( + + )} - - {/* 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}

-
- - -
-
- )} - +
+
+ )}
) diff --git a/apps/web/components/onboarding/team-invite-beat.tsx b/apps/web/components/onboarding/team-invite-beat.tsx new file mode 100644 index 000000000..2d75a92da --- /dev/null +++ b/apps/web/components/onboarding/team-invite-beat.tsx @@ -0,0 +1,196 @@ +"use client" + +import { useState, useCallback } from "react" +import { motion, AnimatePresence } from "motion/react" +import NovaOrb from "@/components/nova/nova-orb" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { Plus, Check, Loader2 } from "lucide-react" + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +const W = 300 +const H = 200 +const CX = W / 2 +const CY = H / 2 +const R = 74 + +function orbPoint(index: number, total: number) { + const angle = (-90 + (360 / Math.max(total, 1)) * index) * (Math.PI / 180) + return { x: CX + R * Math.cos(angle), y: CY + R * Math.sin(angle) } +} + +export function TeamInviteBeat() { + const { org } = useAuth() + const [emails, setEmails] = useState([]) + const [draft, setDraft] = useState("") + const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle") + const [error, setError] = useState("") + + const addEmail = useCallback(() => { + const e = draft.trim().toLowerCase() + if (!EMAIL_RE.test(e)) { + setError("Enter a valid email") + return + } + if (emails.includes(e)) { + setDraft("") + return + } + setEmails((prev) => [...prev, e]) + setDraft("") + setError("") + }, [draft, emails]) + + const sendInvites = useCallback(async () => { + if (emails.length === 0 || !org?.id) return + setStatus("sending") + setError("") + const results = await Promise.allSettled( + emails.map((email) => + authClient.organization.inviteMember({ + email, + role: "member", + organizationId: org.id, + resend: true, + }), + ), + ) + const failed = results.filter((r) => r.status === "rejected").length + if (failed === emails.length) { + setError("Could not send invites. Try again later.") + setStatus("idle") + return + } + setStatus("sent") + }, [emails, org?.id]) + + return ( +
+
+

+ Make Nova a team brain +

+

+ One memory for your whole team. Add a few people to share it. +

+
+ +
+ + +
+ +
+ + + {emails.map((email, i) => { + const p = orbPoint(i, emails.length) + return ( + + + + {email[0]} + + + ) + })} + +
+ + {status === "sent" ? ( +
+ + + Invited {emails.length} {emails.length === 1 ? "person" : "people"} + +
+ ) : ( +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addEmail() + } + }} + placeholder="teammate@company.com" + className="flex-1 rounded-xl border border-[#52596633] bg-[#070E1B] px-3 py-2.5 text-sm text-white placeholder:text-[#525966] focus:border-[#2261CA] focus:outline-none" + /> + +
+ + {error &&

{error}

} + + {emails.length > 0 && ( + + )} +
+ )} +
+ ) +}