UI: onboarding improvements (#435)

UI: onboarding improvements

ui(onboarding): updated onboarding ui patterns
This commit is contained in:
MaheshtheDev 2025-10-01 21:59:53 +00:00
parent ede0f39303
commit 60794f63e2
22 changed files with 994 additions and 1426 deletions

View file

@ -1,47 +1,45 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono, Instrument_Serif } from "next/font/google";
import "../globals.css";
import "@ui/globals.css";
import { AuthProvider } from "@lib/auth-context";
import { ErrorTrackingProvider } from "@lib/error-tracking";
import { PostHogProvider } from "@lib/posthog";
import { QueryProvider } from "@lib/query-client";
import { AutumnProvider } from "autumn-js/react";
import { Suspense } from "react";
import { Toaster } from "sonner";
import { TourProvider } from "@/components/tour";
import { MobilePanelProvider } from "@/lib/mobile-panel-context";
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import type { Metadata } from "next"
import { Inter, JetBrains_Mono, Instrument_Serif } from "next/font/google"
import "../globals.css"
import "@ui/globals.css"
import { AuthProvider } from "@lib/auth-context"
import { ErrorTrackingProvider } from "@lib/error-tracking"
import { PostHogProvider } from "@lib/posthog"
import { QueryProvider } from "@lib/query-client"
import { AutumnProvider } from "autumn-js/react"
import { Suspense } from "react"
import { Toaster } from "sonner"
import { MobilePanelProvider } from "@/lib/mobile-panel-context"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { ViewModeProvider } from "@/lib/view-mode-context";
import { ViewModeProvider } from "@/lib/view-mode-context"
const sans = Inter({
subsets: ["latin"],
variable: "--font-sans",
});
})
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
})
const serif = Instrument_Serif({
subsets: ["latin"],
variable: "--font-serif",
weight: ["400"],
});
})
export const metadata: Metadata = {
metadataBase: new URL("https://app.supermemory.ai"),
description: "Your memories, wherever you are",
title: "supermemory app",
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode
}>) {
return (
<html className="dark bg-sm-black" lang="en">
@ -61,10 +59,8 @@ export default function RootLayout({
<PostHogProvider>
<ErrorTrackingProvider>
<NuqsAdapter>
<TourProvider>
<Suspense>{children}</Suspense>
<Toaster richColors theme="dark" />
</TourProvider>
<Suspense>{children}</Suspense>
<Toaster richColors theme="dark" />
</NuqsAdapter>
</ErrorTrackingProvider>
</PostHogProvider>
@ -75,5 +71,5 @@ export default function RootLayout({
</AutumnProvider>
</body>
</html>
);
)
}

View file

@ -1,86 +1,97 @@
"use client";
"use client"
import { Textarea } from "@ui/components/textarea";
import { useOnboarding } from "./onboarding-context";
import { useState } from "react";
import { Button } from "@ui/components/button";
import { AnimatePresence, motion } from "motion/react";
import { NavMenu } from "./nav-menu";
import { $fetch } from "@lib/api";
import { Textarea } from "@ui/components/textarea"
import { useOnboarding } from "./onboarding-context"
import { useState } from "react"
import { Button } from "@ui/components/button"
import { AnimatePresence, motion } from "motion/react"
import { NavMenu } from "./nav-menu"
import { $fetch } from "@lib/api"
export function BioForm() {
const [bio, setBio] = useState("");
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
const [bio, setBio] = useState("")
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding()
function handleNext() {
const trimmed = bio.trim();
if (!trimmed) {
nextStep();
return;
}
function handleNext() {
const trimmed = bio.trim()
if (!trimmed) {
nextStep()
return
}
nextStep();
void $fetch("@post/memories", {
body: {
content: trimmed,
containerTags: ["sm_project_default"],
metadata: { sm_source: "consumer" },
},
}).catch((error) => {
console.error("Failed to save onboarding bio memory:", error);
});
}
return (
<div className="relative">
<div className="space-y-4">
<NavMenu>
<p className="text-base text-zinc-600">
Step {getStepNumberFor("bio")} of {totalSteps}
</p>
</NavMenu>
<h1 className="max-sm:text-4xl">Tell us about yourself</h1>
<p className="text-zinc-600 text-2xl max-sm:text-lg">
What should Supermemory know about you?
</p>
</div>
<Textarea
autoFocus
className="font-sans mt-6 text-base! tracking-normal font-medium border bg-white! border-zinc-200 rounded-lg !field-sizing-normal !min-h-[calc(3*1.5rem+1rem)]"
placeholder="I'm a software engineer from San Francisco..."
rows={3}
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
<AnimatePresence
mode="sync">
{
bio ? (
<motion.div
key="save"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex justify-end mt-2 absolute -bottom-12 right-0">
<Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={handleNext}>
Save & Continue
</Button>
</motion.div>
) : <motion.div
key="skip"
initial={{ opacity: 0, filter: "blur(5px)", }}
animate={{ opacity: 1, filter: "blur(0px)", }}
exit={{ opacity: 0, filter: "blur(5px)", }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex justify-end mt-2 absolute -bottom-12 right-0">
<Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={handleNext}>
Skip For Now
</Button>
</motion.div>
}
</AnimatePresence>
</div>
);
}
nextStep()
void $fetch("@post/memories", {
body: {
content: trimmed,
containerTags: ["sm_project_default"],
metadata: { sm_source: "consumer" },
},
}).catch((error) => {
console.error("Failed to save onboarding bio memory:", error)
})
}
return (
<div className="relative">
<div className="space-y-4">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("bio")} of {totalSteps}
</p>
</NavMenu>
<h1 className="max-sm:text-4xl text-white font-medium">
Tell us about yourself
</h1>
<p className="text-2xl max-sm:text-lg text-white/80">
What should Supermemory know about you?
</p>
</div>
<Textarea
autoFocus
className="font-sans mt-6 text-base! text-white tracking-normal font-medium border bg-white/30 border-zinc-200 rounded-lg !field-sizing-normal !min-h-[calc(3*1.5rem+1rem)]"
placeholder="I'm a software engineer from San Francisco..."
rows={3}
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
<AnimatePresence mode="sync">
{bio ? (
<motion.div
key="save"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex justify-end mt-2 absolute -bottom-12 right-0"
>
<Button
variant="link"
size="lg"
className="text-white/80 font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={handleNext}
>
Save & Continue
</Button>
</motion.div>
) : (
<motion.div
key="skip"
initial={{ opacity: 0, filter: "blur(5px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(5px)" }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex justify-end mt-2 absolute -bottom-12 right-0"
>
<Button
variant="link"
size="lg"
className="text-white/80 font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={handleNext}
>
Skip For Now
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -628,80 +628,91 @@ function TwitterDemo() {
export function ExtensionForm() {
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
return (
<div className="relative flex items-start flex-col gap-6">
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start text-left gap-4">
<NavMenu>
<p className="text-base text-zinc-600">
Step {getStepNumberFor("extension")} of {totalSteps}
</p>
</NavMenu>
<h1>Install the Chrome extension</h1>
<p className="text-zinc-600 text-2xl">
{/* Install the Supermemory extension to start saving and organizing everything that matters. */}
Bring Supermemory everywhere
</p>
</div>
<div className="flex flex-col items-center text-center gap-4">
<a href="https://supermemory.ai/extension" target="_blank" className="bg-zinc-50/80 backdrop-blur-lg border-2 hover:bg-zinc-100/80 transition-colors duration-100 border-zinc-200/80 shadow-xs rounded-full mt-6 pl-4.5 pr-6 py-4 text-2xl font-sans tracking-tight font-medium flex items-center gap-4">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Google_Chrome_icon_%28February_2022%29.svg/2048px-Google_Chrome_icon_%28February_2022%29.svg.png" alt="Chrome" className="size-8" />
Add to Chrome
</a>
</div>
</div>
<div className="relative flex items-start flex-col gap-6">
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start text-left gap-4">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("extension")} of {totalSteps}
</p>
</NavMenu>
<h1 className="text-white">Install the Chrome extension</h1>
<p className="text-white/80 text-2xl">
{/* Install the Supermemory extension to start saving and organizing everything that matters. */}
Bring Supermemory everywhere
</p>
</div>
<div className="flex flex-col items-center text-center gap-4">
<a
href="https://chromewebstore.google.com/detail/afpgkkipfdpeaflnpoaffkcankadgjfc?utm_source=item-share-cb"
rel="noopener noreferrer"
target="_blank"
className="bg-zinc-50/80 backdrop-blur-lg border-2 hover:bg-zinc-100/80 transition-colors duration-100 border-zinc-200/80 shadow-xs rounded-full mt-6 pl-4.5 pr-6 py-4 text-2xl font-sans tracking-tight font-medium flex items-center gap-4"
>
<img
src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Google_Chrome_icon_%28February_2022%29.svg/2048px-Google_Chrome_icon_%28February_2022%29.svg.png"
alt="Chrome"
className="size-8"
/>
Add to Chrome
</a>
</div>
</div>
<div className="grid grid-cols-3 w-fit gap-4 text-base font-sans font-medium tracking-normal">
<div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Remember anything, anywhere</h2>
<p className="text-zinc-600 text-sm">
Just right-click to save instantly.
</p>
</div>
<div className="aspect-square bg-blue-500">
<SnippetDemo />
</div>
</div>
<div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Integrate with your AI</h2>
{/* Supercharge your AI with memory */}
{/* Supercharge any AI with Supermemory. */}
{/* ChatGPT is better with Supermemory. */}
{/* Seamless integration with your workflow */}
<p className="text-zinc-600 text-sm">
{/* Integrates with ChatGPT and Claude. */}
{/* Integrates with your chat apps */}
Enhance any prompt with Supermemory.
{/* Seamlessly */}
</p>
</div>
<div className="aspect-square bg-blue-500">
<ChatGPTDemo />
</div>
</div>
<div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Import Twitter bookmarks</h2>
<p className="text-zinc-600 text-sm">
Search semantically and effortlessly.
{/* Import instantly and search effortlessly. */}
</p>
</div><div className="aspect-square bg-blue-500">
<TwitterDemo />
</div>
</div>
</div>
<div className="flex w-full justify-end">
<Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}>
Continue
<ChevronRightIcon className="size-4" />
</Button>
</div>
</div>
);
<div className="grid grid-cols-3 w-fit gap-4 text-base font-sans font-medium tracking-normal">
<div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Remember anything, anywhere</h2>
<p className="text-zinc-600 text-sm">
Just right-click to save instantly.
</p>
</div>
<div className="aspect-square bg-blue-500">
<SnippetDemo />
</div>
</div>
<div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Integrate with your AI</h2>
{/* Supercharge your AI with memory */}
{/* Supercharge any AI with Supermemory. */}
{/* ChatGPT is better with Supermemory. */}
{/* Seamless integration with your workflow */}
<p className="text-zinc-600 text-sm">
{/* Integrates with ChatGPT and Claude. */}
{/* Integrates with your chat apps */}
Enhance any prompt with Supermemory.
{/* Seamlessly */}
</p>
</div>
<div className="aspect-square bg-blue-500">
<ChatGPTDemo />
</div>
</div>
<div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
<div className="p-4 bg-white">
<h2 className="text-lg">Import Twitter bookmarks</h2>
<p className="text-zinc-600 text-sm">
Search semantically and effortlessly.
{/* Import instantly and search effortlessly. */}
</p>
</div>
<div className="aspect-square bg-blue-500">
<TwitterDemo />
</div>
</div>
</div>
<div className="flex w-full justify-end">
<Button
variant="link"
size="lg"
className="text-white/80 font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Continue
<ChevronRightIcon className="size-4" />
</Button>
</div>
</div>
)
}

View file

@ -14,7 +14,7 @@ export function Intro() {
return (
<motion.div
className="flex flex-col gap-4 relative max-sm:text-4xl max-sm:w-full"
className="flex flex-col gap-4 relative max-sm:text-4xl max-sm:w-full text-white"
layout
transition={{
layout: { duration: 0.8, ease: "anticipate" }
@ -76,8 +76,6 @@ export function Intro() {
}}
>
<AnimatedText trigger={triggers.third} delay={0.4}>
{/* You just found it. */}
{/* You're about to find it. */}
It's right here.
{/* It's right in front of you. */}
{/* You're looking at it. */}
@ -101,7 +99,7 @@ export function Intro() {
<Button
variant={"link"}
size={"lg"}
className={cn("text-zinc-600 flex items-center gap-2 hover:text-zinc-950 text-2xl underline group font-normal w-fit px-0! cursor-pointer", !triggers.fourth && "pointer-events-none")}
className={cn("text-white/60 flex items-center gap-2 hover:text-white/90 text-2xl underline group font-normal w-fit px-0! cursor-pointer", !triggers.fourth && "pointer-events-none")}
style={{
transform: triggers.fourth ? "scale(1)" : "scale(0.95)"
}}

View file

@ -1,206 +1,251 @@
"use client";
"use client"
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from "@ui/components/select";
import { useOnboarding } from "./onboarding-context";
import { useEffect, useState } from "react";
import { Button } from "@ui/components/button";
import { CheckIcon, CircleCheckIcon, CopyIcon, LoaderIcon } from "lucide-react";
import { toast } from "sonner";
import { TextMorph } from "@/components/text-morph";
import { NavMenu } from "./nav-menu";
import { cn } from "@lib/utils";
import { motion, AnimatePresence } from "framer-motion";
import { useQuery } from "@tanstack/react-query";
import { $fetch } from "@lib/api";
import {
Select,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
} from "@ui/components/select"
import { useOnboarding } from "./onboarding-context"
import { useEffect, useState } from "react"
import { Button } from "@ui/components/button"
import { CheckIcon, CircleCheckIcon, CopyIcon, LoaderIcon } from "lucide-react"
import { TextMorph } from "@/components/text-morph"
import { NavMenu } from "./nav-menu"
import { cn } from "@lib/utils"
import { motion, AnimatePresence } from "framer-motion"
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
const clients = {
cursor: "Cursor",
claude: "Claude Desktop",
vscode: "VSCode",
cline: "Cline",
"roo-cline": "Roo Cline",
witsy: "Witsy",
enconvo: "Enconvo",
"gemini-cli": "Gemini CLI",
"claude-code": "Claude Code",
} as const;
cursor: "Cursor",
claude: "Claude Desktop",
vscode: "VSCode",
cline: "Cline",
"roo-cline": "Roo Cline",
witsy: "Witsy",
enconvo: "Enconvo",
"gemini-cli": "Gemini CLI",
"claude-code": "Claude Code",
} as const
export function MCPForm() {
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
const [client, setClient] = useState<keyof typeof clients>("cursor");
const [isCopied, setIsCopied] = useState(false);
const [isInstalling, setIsInstalling] = useState(true);
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding()
const [client, setClient] = useState<keyof typeof clients>("cursor")
const [isCopied, setIsCopied] = useState(false)
const [isInstalling, setIsInstalling] = useState(true)
const hasLoginQuery = useQuery({
queryKey: ["mcp", "has-login"],
queryFn: async (): Promise<{ previousLogin: boolean }> => {
const response = await $fetch("@get/mcp/has-login");
if (response.error) {
throw new Error(response.error?.message || "Failed to check MCP login");
}
return response.data as { previousLogin: boolean };
},
enabled: isInstalling,
refetchInterval: isInstalling ? 1000 : false,
staleTime: 0,
});
const hasLoginQuery = useQuery({
queryKey: ["mcp", "has-login"],
queryFn: async (): Promise<{ previousLogin: boolean }> => {
const response = await $fetch("@get/mcp/has-login")
if (response.error) {
throw new Error(response.error?.message || "Failed to check MCP login")
}
return response.data as { previousLogin: boolean }
},
enabled: isInstalling,
refetchInterval: isInstalling ? 1000 : false,
staleTime: 0,
})
useEffect(() => {
if (hasLoginQuery.data?.previousLogin) {
setIsInstalling(false);
}
}, [hasLoginQuery.data?.previousLogin]);
useEffect(() => {
if (hasLoginQuery.data?.previousLogin) {
setIsInstalling(false)
}
}, [hasLoginQuery.data?.previousLogin])
return (
<div className="relative flex flex-col gap-6">
<div className="space-y-4">
<NavMenu>
<p className="text-base text-zinc-600">
Step {getStepNumberFor("mcp")} of {totalSteps}
</p>
</NavMenu>
<h1 className="max-sm:text-4xl">Install the MCP server</h1>
<p className="text-zinc-600 text-2xl max-sm:text-lg">
Bring Supermemory to all your favourite tools
</p>
</div>
<div className="flex flex-col gap-4 font-sans text-base tracking-normal font-normal">
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div style={{
height: "calc(100% - 0.5rem)",
}} className="absolute -z-10 left-1/2 top-8 w-[1px] -translate-x-1/2 transform bg-neutral-200"></div>
<div className="size-10 rounded-lg bg-zinc-100 border-zinc-200 border text-zinc-900 font-medium flex items-center justify-center">1</div>
</div>
<div className="mt-2 space-y-2 w-full">
<p>Select the app you want to install Supermemory MCP to</p>
<Select
onValueChange={(value) => setClient(value as keyof typeof clients)}
value={client}
>
<SelectTrigger id="client-select" className="w-full border border-zinc-200 bg-white!">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{Object.entries(clients).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div className="size-10 rounded-lg border-zinc-200 border bg-zinc-100 text-zinc-900 font-medium flex items-center justify-center">2</div>
<div style={{
height: "calc(100% - 0.5rem)",
}} className="absolute left-1/2 -z-10 top-8 w-[1px] -translate-x-1/2 transform bg-neutral-200"></div>
</div>
<div className="mt-2 space-y-2">
<p>Copy the installation command</p>
<div className="bg-white relative shadow-xs rounded-lg max-w-md text-balance py-4 px-5 border border-zinc-200">
<p className="text-zinc-900 font-mono text-xs w-4/5">
npx -y install-mcp@latest https://api.supermemory.ai/mcp --client {client} --oauth=yes
</p>
<Button className="absolute right-2 top-2" onClick={() => {
navigator.clipboard.writeText(`npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 2000);
}}>
{isCopied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
<TextMorph>
{isCopied ? "Copied!" : "Copy"}
</TextMorph>
</Button>
</div>
</div>
</div>
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div className="size-10 rounded-lg bg-zinc-100 border-zinc-200 border text-zinc-900 font-medium flex items-center justify-center">3</div>
</div>
<div className="mt-2 space-y-2 w-full">
<p>Run the command in your terminal of choice</p>
<motion.div
className={cn("px-5 py-4 border shadow-xs rounded-lg flex items-center gap-3 font-mono text-sm")}
animate={{
backgroundColor: isInstalling ? "rgb(250 250 250)" : "rgb(240 253 244)", // zinc-50 to green-50
borderColor: isInstalling ? "rgb(228 228 231)" : "rgb(187 247 208)", // zinc-200 to green-200
}}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
>
<AnimatePresence mode="wait">
{isInstalling ? (
<motion.div
key="loading"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<LoaderIcon className="size-4 animate-spin" />
</motion.div>
) : (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<CircleCheckIcon className="size-4 text-green-500" />
</motion.div>
)}
</AnimatePresence>
<motion.span
key={isInstalling ? "installing" : "complete"}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{isInstalling ? "Waiting for installation..." : "Installation complete!"}
</motion.span>
</motion.div>
</div>
</div>
</div>
<AnimatePresence
mode="sync">
{
!isInstalling ? (
<motion.div
key="save"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex justify-end">
<Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}>
Continue
</Button>
</motion.div>
) : <motion.div
key="skip"
initial={{ opacity: 0, filter: "blur(5px)", }}
animate={{ opacity: 1, filter: "blur(0px)", }}
exit={{ opacity: 0, filter: "blur(5px)", }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex justify-end">
<Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}>
Skip For Now
</Button>
</motion.div>
}
</AnimatePresence>
</div>
);
}
return (
<div className="relative flex flex-col gap-6">
<div className="space-y-4">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("mcp")} of {totalSteps}
</p>
</NavMenu>
<h1 className="max-sm:text-4xl text-white font-medium">
Install the MCP server
</h1>
<p className="text-2xl max-sm:text-lg text-white/80">
Bring Supermemory to all your favourite tools
</p>
</div>
<div className="flex flex-col gap-4 font-sans text-base tracking-normal font-normal">
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div
style={{
height: "calc(100% - 0.5rem)",
}}
className="absolute -z-10 left-1/2 top-8 w-[1px] -translate-x-1/2 transform bg-white/10"
/>
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
1
</div>
</div>
<div className="mt-2 space-y-2 w-full">
<p className="text-white/80">
Select the app you want to install Supermemory MCP to
</p>
<Select
onValueChange={(value) =>
setClient(value as keyof typeof clients)
}
value={client}
>
<SelectTrigger
id="client-select"
className="w-full bg-white/10! text-white"
>
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{Object.entries(clients).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
2
</div>
<div
style={{
height: "calc(100% - 0.5rem)",
}}
className="absolute left-1/2 -z-10 top-8 w-[1px] -translate-x-1/2 transform bg-white/10"
/>
</div>
<div className="mt-2 space-y-2">
<p className="text-white/80">Copy the installation command</p>
<div className="bg-white/10 relative shadow-xs rounded-lg max-w-md text-balance py-4 px-5 align-middle justify-center">
<p className="text-white font-mono text-xs w-4/5 text-nowrap overflow-x-hidden text-ellipsis">
npx -y install-mcp@latest https://api.supermemory.ai/mcp
--client {client} --oauth=yes
</p>
<Button
className="absolute right-2 top-[6px]"
onClick={() => {
navigator.clipboard.writeText(
`npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`,
)
setIsCopied(true)
setTimeout(() => {
setIsCopied(false)
}, 2000)
}}
>
{isCopied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
<TextMorph>{isCopied ? "Copied!" : "Copy"}</TextMorph>
</Button>
</div>
</div>
</div>
<div className="flex gap-4">
<div className="relative flex-shrink-0">
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
3
</div>
</div>
<div className="mt-2 space-y-2 w-full">
<p className="text-white/80">
Run the command in your terminal of choice
</p>
<motion.div
className={cn(
"px-5 py-4 bg-black/10 text-white shadow-xs rounded-lg flex items-center gap-3 font-mono text-sm",
)}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
>
<AnimatePresence mode="wait">
{isInstalling ? (
<motion.div
key="loading"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<LoaderIcon className="size-4 animate-spin" />
</motion.div>
) : (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<CircleCheckIcon className="size-4 text-white" />
</motion.div>
)}
</AnimatePresence>
<motion.span
key={isInstalling ? "installing" : "complete"}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{isInstalling
? "Waiting for installation..."
: "Installation complete!"}
</motion.span>
</motion.div>
</div>
</div>
</div>
<AnimatePresence mode="sync">
{!isInstalling ? (
<motion.div
key="save"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex justify-end"
>
<Button
variant="link"
size="lg"
className="text-black/40 font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Continue
</Button>
</motion.div>
) : (
<motion.div
key="skip"
initial={{ opacity: 0, filter: "blur(5px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(5px)" }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex justify-end"
>
<Button
variant="link"
size="lg"
className="text-black/40 font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Skip For Now
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -1,95 +1,102 @@
"use client";
"use client"
import { Button } from "@repo/ui/components/button";
import { useOnboarding } from "./onboarding-context";
import { useAuth } from "@lib/auth-context";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Input } from "@ui/components/input";
import { CheckIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { NavMenu } from "./nav-menu";
import { authClient } from "@lib/auth";
import { Button } from "@repo/ui/components/button"
import { useOnboarding } from "./onboarding-context"
import { useAuth } from "@lib/auth-context"
import Link from "next/link"
import { useEffect, useState } from "react"
import { Input } from "@ui/components/input"
import { CheckIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { NavMenu } from "./nav-menu"
import { authClient } from "@lib/auth"
export function NameForm() {
const { nextStep, totalSteps, getStepNumberFor } = useOnboarding();
const { user } = useAuth();
const [name, setName] = useState(user?.name ?? "");
const { nextStep, totalSteps, getStepNumberFor } = useOnboarding()
const { user } = useAuth()
const [name, setName] = useState(user?.name ?? "")
useEffect(() => {
if (!name && user?.name) {
setName(user.name);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.name]);
useEffect(() => {
if (!name && user?.name) {
setName(user.name)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.name])
function handleNext(): void {
const trimmed = name.trim();
if (!trimmed) {
nextStep();
return;
}
function handleNext(): void {
const trimmed = name.trim()
if (!trimmed) {
nextStep()
return
}
nextStep();
void authClient
.updateUser({ name: trimmed })
.catch((error: unknown) => {
console.error("Failed to update user name during onboarding:", error);
});
}
nextStep()
void authClient.updateUser({ name: trimmed }).catch((error: unknown) => {
console.error("Failed to update user name during onboarding:", error)
})
}
function handleSubmit(e: React.FormEvent): void {
e.preventDefault();
handleNext();
}
function handleSubmit(e: React.FormEvent): void {
e.preventDefault()
handleNext()
}
if (!user) {
return (
<div className="flex flex-col gap-6">
<h1 className="text-4xl">You need to sign in to continue</h1>
<Link href="/login">Login</Link>
</div>
);
}
if (!user) {
return (
<div className="flex flex-col gap-6">
<h1 className="text-4xl">You need to sign in to continue</h1>
<Link href="/login">Login</Link>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<NavMenu>
<p className="text-base text-zinc-600">
Step {getStepNumberFor("name")} of {totalSteps}
</p>
</NavMenu>
<h1 className="text-4xl">
What should we call you?
</h1>
return (
<div className="flex flex-col gap-4">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("name")} of {totalSteps}
</p>
</NavMenu>
<p className="text-4xl text-white font-medium">What should we call you?</p>
<form onSubmit={handleSubmit} className="flex flex-col group">
<div className="relative flex flex-col">
<input type="text" autoFocus name="name" autoComplete="name" autoCorrect="off" autoCapitalize="none" spellCheck="false" className="text-black outline-0 text-2xl h-12 font-normal p-0" placeholder="John Doe" value={name} onChange={(e) => setName(e.target.value)} />
<AnimatePresence
mode="popLayout">
{
name && (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="next"
transition={{ duration: 0.15 }}
className="absolute pointer-events-none inset-0 flex items-center justify-end">
<button type="submit" className="cursor-pointer transition-colors duration-150 gap-2 pointer-events-auto flex items-center p-2 hover:bg-zinc-100 rounded-lg">
<CheckIcon className="w-4 h-4" />
{/* <span className="text-sm">Next</span> */}
</button>
</motion.div>
)
}
</AnimatePresence>
</div>
<div className="w-full rounded-full group-focus-within:bg-zinc-400 transition-colors h-px bg-zinc-200"></div>
</form>
</div>
);
}
<form onSubmit={handleSubmit} className="flex flex-col group text-white">
<div className="relative flex flex-col">
<input
type="text"
autoFocus
name="name"
autoComplete="name"
autoCorrect="off"
autoCapitalize="none"
spellCheck="false"
className="outline-0 text-2xl h-12 font-normal p-0"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<AnimatePresence mode="popLayout">
{name && (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="next"
transition={{ duration: 0.15 }}
className="absolute pointer-events-none inset-0 flex items-center justify-end"
>
<button
type="submit"
className="cursor-pointer transition-colors duration-150 gap-2 pointer-events-auto flex items-center p-2 hover:bg-zinc-100 hover:text-black/90 rounded-lg"
>
<CheckIcon className="w-4 h-4" />
{/* <span className="text-sm">Next</span> */}
</button>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="w-full rounded-full group-focus-within:bg-zinc-400 transition-colors h-px bg-zinc-200" />
</form>
</div>
)
}

View file

@ -15,7 +15,7 @@ export function NavMenu({ children }: { children: React.ReactNode }) {
intro: "Intro",
name: "Name",
bio: "About you",
connections: "Connections",
// connections: "Connections",
mcp: "MCP",
extension: "Extension",
welcome: "Welcome",

View file

@ -0,0 +1,35 @@
"use client"
import { useOnboarding } from "./onboarding-context"
interface OnboardingBackgroundProps {
children: React.ReactNode
}
export function OnboardingBackground({ children }: OnboardingBackgroundProps) {
const { currentStep, visibleSteps } = useOnboarding()
const backgroundImage = "url(/onboarding.png)"
const currentZoomStepIndex = visibleSteps.indexOf(currentStep)
const zoomScale =
currentZoomStepIndex >= 0 ? 1.0 + currentZoomStepIndex * 0.1 : 1.0
return (
<div className="min-h-screen w-full overflow-x-hidden text-zinc-900 flex items-center justify-center relative">
<div
className="absolute inset-0 transition-transform duration-700 ease-in-out"
style={{
backgroundImage,
backgroundSize: "cover",
backgroundPosition: "bottom",
backgroundRepeat: "no-repeat",
transform: `scale(${zoomScale})`,
transformOrigin: "center bottom",
}}
/>
<div className="relative z-10">{children}</div>
</div>
)
}

View file

@ -1,202 +1,232 @@
"use client";
"use client"
import { createContext, useContext, useState, useEffect, type ReactNode, useMemo } from "react";
import { useQueryState } from "nuqs";
import { useIsMobile } from "@hooks/use-mobile";
import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
useMemo,
} from "react"
import { useQueryState } from "nuqs"
import { useIsMobile } from "@hooks/use-mobile"
// Define the context interface
interface OnboardingContextType {
currentStep: OnboardingStep;
setStep: (step: OnboardingStep) => void;
nextStep: () => void;
previousStep: () => void;
totalSteps: number;
currentStepIndex: number;
// Visible-step aware helpers
visibleSteps: OnboardingStep[];
currentVisibleStepIndex: number;
currentVisibleStepNumber: number;
getStepNumberFor: (step: OnboardingStep) => number;
introTriggers: {
first: boolean;
second: boolean;
third: boolean;
fourth: boolean;
};
orbsRevealed: boolean;
resetIntroTriggers: () => void;
currentStep: OnboardingStep
setStep: (step: OnboardingStep) => void
nextStep: () => void
previousStep: () => void
totalSteps: number
currentStepIndex: number
// Visible-step aware helpers
visibleSteps: OnboardingStep[]
currentVisibleStepIndex: number
currentVisibleStepNumber: number
getStepNumberFor: (step: OnboardingStep) => number
introTriggers: {
first: boolean
second: boolean
third: boolean
fourth: boolean
}
orbsRevealed: boolean
resetIntroTriggers: () => void
}
// Create the context
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
const OnboardingContext = createContext<OnboardingContextType | undefined>(
undefined,
)
// Define the base step order
const BASE_STEP_ORDER = ["intro", "name", "bio", "connections", "mcp", "extension", "welcome"] as const;
const BASE_STEP_ORDER = [
"intro",
"name",
"bio",
"mcp",
"extension",
"welcome",
] as const
export type OnboardingStep = (typeof BASE_STEP_ORDER)[number];
export type OnboardingStep = (typeof BASE_STEP_ORDER)[number]
interface OnboardingProviderProps {
children: ReactNode;
initialStep?: OnboardingStep;
children: ReactNode
initialStep?: OnboardingStep
}
export function OnboardingProvider({ children, initialStep = "intro" }: OnboardingProviderProps) {
// Helper function to validate if a step is valid
const isValidStep = (step: string): step is OnboardingStep => {
return BASE_STEP_ORDER.includes(step as OnboardingStep);
};
export function OnboardingProvider({
children,
initialStep = "intro",
}: OnboardingProviderProps) {
// Helper function to validate if a step is valid
const isValidStep = (step: string): step is OnboardingStep => {
return BASE_STEP_ORDER.includes(step as OnboardingStep)
}
const [currentStep, setCurrentStep] = useQueryState("step", {
defaultValue: initialStep,
parse: (value: string) => {
// Validate the step from URL - if invalid, use the initial step
return isValidStep(value) ? value : initialStep;
},
serialize: (value: OnboardingStep) => value,
});
const [orbsRevealed, setOrbsRevealed] = useState(false);
const [introTriggers, setIntroTriggers] = useState({
first: false,
second: false,
third: false,
fourth: false
});
const isMobile = useIsMobile();
const [currentStep, setCurrentStep] = useQueryState("step", {
defaultValue: initialStep,
parse: (value: string) => {
// Validate the step from URL - if invalid, use the initial step
return isValidStep(value) ? value : initialStep
},
serialize: (value: OnboardingStep) => value,
})
const [orbsRevealed, setOrbsRevealed] = useState(false)
const [introTriggers, setIntroTriggers] = useState({
first: false,
second: false,
third: false,
fourth: false,
})
const isMobile = useIsMobile()
// Compute visible steps based on device
const visibleSteps = useMemo(() => {
if (isMobile) {
// On mobile, hide MCP and Extension steps
return BASE_STEP_ORDER.filter(s => s !== "mcp" && s !== "extension");
}
return [...BASE_STEP_ORDER];
}, [isMobile]);
// Compute visible steps based on device
const visibleSteps = useMemo(() => {
if (isMobile) {
// On mobile, hide MCP and Extension steps
return BASE_STEP_ORDER.filter((s) => s !== "mcp" && s !== "extension")
}
return [...BASE_STEP_ORDER]
}, [isMobile])
// Setup intro trigger timings when on intro step
useEffect(() => {
if (currentStep !== "intro") return;
// Setup intro trigger timings when on intro step
useEffect(() => {
if (currentStep !== "intro") return
const cleanups = [
setTimeout(() => {
setIntroTriggers(prev => ({ ...prev, first: true }));
}, 300),
setTimeout(() => {
setIntroTriggers(prev => ({ ...prev, second: true }));
}, 2000),
setTimeout(() => {
setIntroTriggers(prev => ({ ...prev, third: true }));
}, 4000),
setTimeout(() => {
setIntroTriggers(prev => ({ ...prev, fourth: true }));
}, 5500),
];
const cleanups = [
setTimeout(() => {
setIntroTriggers((prev) => ({ ...prev, first: true }))
}, 300),
setTimeout(() => {
setIntroTriggers((prev) => ({ ...prev, second: true }))
}, 300),
setTimeout(() => {
setIntroTriggers((prev) => ({ ...prev, third: true }))
}, 300),
setTimeout(() => {
setIntroTriggers((prev) => ({ ...prev, fourth: true }))
}, 400),
]
return () => cleanups.forEach(cleanup => clearTimeout(cleanup));
}, [currentStep]);
return () => cleanups.forEach((cleanup) => clearTimeout(cleanup))
}, [currentStep])
// Set orbs as revealed once the fourth trigger is activated OR if we're on any non-intro step
useEffect(() => {
if (currentStep !== "intro") {
// If we're not on the intro step, orbs should always be visible
// (user has either completed intro or navigated directly to another step)
if (!orbsRevealed) {
setOrbsRevealed(true);
}
} else if (introTriggers.fourth && !orbsRevealed) {
// On intro step, reveal orbs only after the fourth trigger
setOrbsRevealed(true);
}
}, [introTriggers.fourth, orbsRevealed, currentStep]);
// Set orbs as revealed once the fourth trigger is activated OR if we're on any non-intro step
useEffect(() => {
if (currentStep !== "intro") {
// If we're not on the intro step, orbs should always be visible
// (user has either completed intro or navigated directly to another step)
if (!orbsRevealed) {
setOrbsRevealed(true)
}
} else if (introTriggers.fourth && !orbsRevealed) {
// On intro step, reveal orbs only after the fourth trigger
setOrbsRevealed(true)
}
}, [introTriggers.fourth, orbsRevealed, currentStep])
// Ensure current step is always part of visible steps; if not, advance to the next visible step
useEffect(() => {
if (!visibleSteps.includes(currentStep)) {
if (visibleSteps.length === 0) return;
const baseIndex = BASE_STEP_ORDER.indexOf(currentStep);
// Find the next visible step after the current base index
const nextAfterBase = visibleSteps.find(step => BASE_STEP_ORDER.indexOf(step) > baseIndex);
const targetStep = nextAfterBase ?? visibleSteps[visibleSteps.length - 1]!;
setCurrentStep(targetStep);
}
}, [visibleSteps, currentStep]);
// Ensure current step is always part of visible steps; if not, advance to the next visible step
useEffect(() => {
if (!visibleSteps.includes(currentStep)) {
if (visibleSteps.length === 0) return
const baseIndex = BASE_STEP_ORDER.indexOf(currentStep)
// Find the next visible step after the current base index
const nextAfterBase = visibleSteps.find(
(step) => BASE_STEP_ORDER.indexOf(step) > baseIndex,
)
const targetStep = nextAfterBase ?? visibleSteps[visibleSteps.length - 1]!
setCurrentStep(targetStep)
}
}, [visibleSteps, currentStep])
function setStep(step: OnboardingStep) {
setCurrentStep(step);
}
function setStep(step: OnboardingStep) {
setCurrentStep(step)
}
function nextStep() {
const currentIndex = visibleSteps.indexOf(currentStep);
const nextIndex = currentIndex + 1;
function nextStep() {
const currentIndex = visibleSteps.indexOf(currentStep)
const nextIndex = currentIndex + 1
if (nextIndex < visibleSteps.length) {
setStep(visibleSteps[nextIndex]!);
}
}
if (nextIndex < visibleSteps.length) {
setStep(visibleSteps[nextIndex]!)
}
}
function previousStep() {
const currentIndex = visibleSteps.indexOf(currentStep);
const previousIndex = currentIndex - 1;
function previousStep() {
const currentIndex = visibleSteps.indexOf(currentStep)
const previousIndex = currentIndex - 1
if (previousIndex >= 0) {
setStep(visibleSteps[previousIndex]!);
}
}
if (previousIndex >= 0) {
setStep(visibleSteps[previousIndex]!)
}
}
function resetIntroTriggers() {
setIntroTriggers({
first: false,
second: false,
third: false,
fourth: false
});
}
function resetIntroTriggers() {
setIntroTriggers({
first: false,
second: false,
third: false,
fourth: false,
})
}
const currentStepIndex = BASE_STEP_ORDER.indexOf(currentStep);
const currentStepIndex = BASE_STEP_ORDER.indexOf(currentStep)
// Visible-step aware helpers
const stepsForNumbering = useMemo(() => visibleSteps.filter(s => s !== "intro" && s !== "welcome"), [visibleSteps]);
// Visible-step aware helpers
const stepsForNumbering = useMemo(
() => visibleSteps.filter((s) => s !== "intro" && s !== "welcome"),
[visibleSteps],
)
function getStepNumberFor(step: OnboardingStep): number {
if (step === "intro" || step === "welcome") {
return 0;
}
const idx = stepsForNumbering.indexOf(step);
return idx === -1 ? 0 : idx + 1;
}
function getStepNumberFor(step: OnboardingStep): number {
if (step === "intro" || step === "welcome") {
return 0
}
const idx = stepsForNumbering.indexOf(step)
return idx === -1 ? 0 : idx + 1
}
const currentVisibleStepIndex = useMemo(() => visibleSteps.indexOf(currentStep), [visibleSteps, currentStep]);
const currentVisibleStepNumber = useMemo(() => getStepNumberFor(currentStep), [currentStep, stepsForNumbering]);
const totalSteps = stepsForNumbering.length;
const currentVisibleStepIndex = useMemo(
() => visibleSteps.indexOf(currentStep),
[visibleSteps, currentStep],
)
const currentVisibleStepNumber = useMemo(
() => getStepNumberFor(currentStep),
[currentStep, stepsForNumbering],
)
const totalSteps = stepsForNumbering.length
const contextValue: OnboardingContextType = {
currentStep,
setStep,
nextStep,
previousStep,
totalSteps,
currentStepIndex,
visibleSteps,
currentVisibleStepIndex,
currentVisibleStepNumber,
getStepNumberFor,
introTriggers,
orbsRevealed,
resetIntroTriggers,
};
const contextValue: OnboardingContextType = {
currentStep,
setStep,
nextStep,
previousStep,
totalSteps,
currentStepIndex,
visibleSteps,
currentVisibleStepIndex,
currentVisibleStepNumber,
getStepNumberFor,
introTriggers,
orbsRevealed,
resetIntroTriggers,
}
return (
<OnboardingContext.Provider value={contextValue}>
{children}
</OnboardingContext.Provider>
);
return (
<OnboardingContext.Provider value={contextValue}>
{children}
</OnboardingContext.Provider>
)
}
export function useOnboarding() {
const context = useContext(OnboardingContext);
const context = useContext(OnboardingContext)
if (context === undefined) {
throw new Error("useOnboarding must be used within an OnboardingProvider");
}
if (context === undefined) {
throw new Error("useOnboarding must be used within an OnboardingProvider")
}
return context;
return context
}

View file

@ -1,55 +1,66 @@
"use client";
"use client"
import { motion, AnimatePresence } from "motion/react";
import { NameForm } from "./name-form";
import { Intro } from "./intro";
import { useOnboarding } from "./onboarding-context";
import { BioForm } from "./bio-form";
import { ConnectionsForm } from "./connections-form";
import { ExtensionForm } from "./extension-form";
import { MCPForm } from "./mcp-form";
import { Welcome } from "./welcome";
import { motion, AnimatePresence } from "motion/react"
import { NameForm } from "./name-form"
import { Intro } from "./intro"
import { useOnboarding } from "./onboarding-context"
import { BioForm } from "./bio-form"
import { ExtensionForm } from "./extension-form"
import { MCPForm } from "./mcp-form"
import { Welcome } from "./welcome"
import { Space_Grotesk } from "next/font/google"
import { cn } from "@lib/utils"
const sans = Space_Grotesk({
subsets: ["latin"],
variable: "--font-sans",
})
export function OnboardingForm() {
const { currentStep, resetIntroTriggers } = useOnboarding();
const { currentStep, resetIntroTriggers } = useOnboarding()
return (
<div className="text-6xl px-6 py-8 tracking-wide font-bold font-serif flex flex-col justify-center max-sm:w-full">
<AnimatePresence mode="wait" onExitComplete={resetIntroTriggers}>
{currentStep === "intro" && (
<motion.div
key="intro"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }}
transition={{ duration: 0.28, ease: "easeInOut" }}
>
<Intro />
</motion.div>
)}
{currentStep === "name" && (
<motion.div
key="name"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<NameForm />
</motion.div>
)}
{currentStep === "bio" && (
<motion.div
key="bio"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<BioForm />
</motion.div>
)}
{currentStep === "connections" && (
return (
<div
className={cn(
"text-4xl px-6 py-8 flex flex-col justify-center max-sm:w-full",
sans.variable,
)}
>
<AnimatePresence mode="wait" onExitComplete={resetIntroTriggers}>
{currentStep === "intro" && (
<motion.div
key="intro"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }}
transition={{ duration: 0.28, ease: "easeInOut" }}
>
<Intro />
</motion.div>
)}
{currentStep === "name" && (
<motion.div
key="name"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<NameForm />
</motion.div>
)}
{currentStep === "bio" && (
<motion.div
key="bio"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<BioForm />
</motion.div>
)}
{/*{currentStep === "connections" && (
<motion.div
key="connections"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
@ -59,41 +70,41 @@ export function OnboardingForm() {
>
<ConnectionsForm />
</motion.div>
)}
{currentStep === "mcp" && (
<motion.div
key="mcp"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<MCPForm />
</motion.div>
)}
{currentStep === "extension" && (
<motion.div
key="extension"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<ExtensionForm />
</motion.div>
)}
{currentStep === "welcome" && (
<motion.div
key="welcome"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<Welcome />
</motion.div>
)}
</AnimatePresence>
</div>
);
}
)}*/}
{currentStep === "mcp" && (
<motion.div
key="mcp"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<MCPForm />
</motion.div>
)}
{currentStep === "extension" && (
<motion.div
key="extension"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<ExtensionForm />
</motion.div>
)}
{currentStep === "welcome" && (
<motion.div
key="welcome"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<Welcome />
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -1,28 +1,26 @@
import { getSession } from "@lib/auth";
import { OnboardingForm } from "./onboarding-form";
import { OnboardingProvider } from "./onboarding-context";
import { FloatingOrbs } from "./floating-orbs";
import { OnboardingProgressBar } from "./progress-bar";
import { redirect } from "next/navigation";
import { type Metadata } from "next";
import { getSession } from "@lib/auth"
import { OnboardingForm } from "./onboarding-form"
import { OnboardingProvider } from "./onboarding-context"
import { OnboardingProgressBar } from "./progress-bar"
import { redirect } from "next/navigation"
import { OnboardingBackground } from "./onboarding-background"
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Welcome to Supermemory",
description: "We're excited to have you on board.",
};
title: "Welcome to Supermemory",
description: "We're excited to have you on board.",
}
export default function OnboardingPage() {
const session = getSession();
const session = getSession()
if (!session) redirect("/login");
if (!session) redirect("/login")
return (
<div className="min-h-screen w-full overflow-x-hidden text-zinc-900 bg-white flex items-center justify-center relative">
<OnboardingProvider>
<OnboardingProgressBar />
<FloatingOrbs />
<OnboardingForm />
</OnboardingProvider>
</div>
);
}
return (
<OnboardingProvider>
<OnboardingProgressBar />
<OnboardingBackground>
<OnboardingForm />
</OnboardingBackground>
</OnboardingProvider>
)
}

View file

@ -1,26 +1,25 @@
"use client";
"use client"
import { motion } from "motion/react";
import { useOnboarding } from "./onboarding-context";
import { motion } from "motion/react"
import { useOnboarding } from "./onboarding-context"
export function OnboardingProgressBar() {
const { currentVisibleStepNumber, totalSteps } = useOnboarding();
const { currentVisibleStepNumber, totalSteps } = useOnboarding()
const progress = totalSteps === 0
? 0
: (currentVisibleStepNumber / totalSteps) * 100;
const progress =
totalSteps === 0 ? 0 : (currentVisibleStepNumber / totalSteps) * 100
return (
<div className="fixed top-0 left-0 right-0 z-50 h-1 bg-zinc-200">
<motion.div
className="h-full bg-gradient-to-r from-orange-500 via-amber-500 to-gold-600"
initial={{ width: "0%" }}
animate={{ width: `${progress}%` }}
transition={{
duration: 0.8,
ease: "easeInOut"
}}
/>
</div>
);
return (
<div className="fixed top-0 left-0 right-0 z-50 h-3 bg-zinc-200">
<motion.div
className="h-full bg-gradient-to-r from-[#06245B] via-[#1A5EA7] to-[#DDF5FF]"
initial={{ width: "0%" }}
animate={{ width: `${progress}%` }}
transition={{
duration: 0.8,
ease: "easeInOut",
}}
/>
</div>
)
}

View file

@ -1,20 +1,33 @@
"use client";
"use client"
import { Button } from "@ui/components/button";
import { ArrowRightIcon, ChevronRightIcon } from "lucide-react";
import { ArrowRightIcon } from "lucide-react"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
import { useRouter } from "next/navigation"
export function Welcome() {
return (
<div className="flex flex-col gap-4 items-center text-center">
<h1>Welcome to Supermemory</h1>
<p className="text-zinc-600 text-2xl">
We're excited to have you on board.
</p>
const { markOnboardingCompleted } = useOnboardingStorage()
const router = useRouter()
<a href="/" className="tracking-normal w-fit flex items-center justify-center text-2xl underline cursor-pointer font-medium text-zinc-900">
Get started
<ArrowRightIcon className="size-4 ml-2" />
</a>
</div>
);
}
const handleGetStarted = () => {
markOnboardingCompleted()
router.push("/")
}
return (
<div className="flex flex-col gap-4 items-center text-center">
<h1 className="text-white">Welcome to Supermemory</h1>
<p className="text-white/80 text-2xl">
We're excited to have you on board.
</p>
<button
type="button"
onClick={handleGetStarted}
className="tracking-normal w-fit flex items-center justify-center text-2xl underline cursor-pointer font-medium text-white/80 hover:text-white transition-colors"
>
Get started
<ArrowRightIcon className="size-4 ml-2" />
</button>
</div>
)
}

View file

@ -1,6 +1,7 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import { MemoryGraph } from "@repo/ui/memory-graph"
@ -19,6 +20,7 @@ import {
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { z } from "zod"
import { ConnectAIModal } from "@/components/connect-ai-modal"
@ -27,11 +29,8 @@ import { MemoryListView } from "@/components/memory-list-view"
import Menu from "@/components/menu"
import { ProjectSelector } from "@/components/project-selector"
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal"
import type { TourStep } from "@/components/tour"
import { TourAlertDialog, useTour } from "@/components/tour"
import { AddMemoryView } from "@/components/views/add-memory"
import { ChatRewrite } from "@/components/views/chat"
import { TOUR_STEP_IDS, TOUR_STORAGE_KEY } from "@/lib/tour-constants"
import { useViewMode } from "@/lib/view-mode-context"
import { useChatOpen, useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
@ -42,9 +41,8 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
const MemoryGraphPage = () => {
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const isMobile = useIsMobile()
const { viewMode, setViewMode, isInitialized } = useViewMode()
const { viewMode, setViewMode } = useViewMode()
const { selectedProject } = useProject()
const { setSteps, isTourCompleted } = useTour()
const { isOpen, setIsOpen } = useChatOpen()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
@ -66,167 +64,6 @@ const MemoryGraphPage = () => {
(p: any) => p.containerTag === selectedProject,
)?.isExperimental
// Tour state
const [showTourDialog, setShowTourDialog] = useState(false)
// Define tour steps with useMemo to prevent recreation
const tourSteps: TourStep[] = useMemo(() => {
return [
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Memories Overview
</h3>
<p className="text-gray-200">
This is your memory graph. Each node represents a memory, and
connections show relationships between them.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MEMORY_GRAPH,
position: "center",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Add Memories
</h3>
<p className="text-gray-200">
Click here to add new memories to your knowledge base. You can add
text, links, or connect external sources.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_ADD_MEMORY,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Connections
</h3>
<p className="text-gray-200">
Connect your external accounts like Google Drive, Notion, or
OneDrive to automatically sync and organize your content.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_CONNECTIONS,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Projects</h3>
<p className="text-gray-200">
Organize your memories into projects. Switch between different
contexts easily.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_PROJECTS,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
MCP Servers
</h3>
<p className="text-gray-200">
Access Model Context Protocol servers to give AI tools access to
your memories securely.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_MCP,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Billing</h3>
<p className="text-gray-200">
Manage your subscription and billing information.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_BILLING,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
View Toggle
</h3>
<p className="text-gray-200">
Switch between graph view and list view to see your memories in
different ways.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.VIEW_TOGGLE,
position: "left",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Legend</h3>
<p className="text-gray-200">
Understand the different types of nodes and connections in your
memory graph.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.LEGEND,
position: "left",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Chat Assistant
</h3>
<p className="text-gray-200">
Ask questions or add new memories using our AI-powered chat
interface.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.FLOATING_CHAT,
position: "left",
},
]
}, [])
// Check if tour has been completed before
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"
if (!hasCompletedTour && !isTourCompleted) {
const timer = setTimeout(() => {
setShowTourDialog(true)
setShowConnectAIModal(false)
}, 1000) // Show after 1 second
return () => clearTimeout(timer)
}
}, [isTourCompleted])
// Set up tour steps
useEffect(() => {
setSteps(tourSteps)
}, [setSteps, tourSteps])
// Save tour completion to localStorage
useEffect(() => {
if (isTourCompleted) {
localStorage.setItem(TOUR_STORAGE_KEY, "true")
}
}, [isTourCompleted])
// Progressive loading via useInfiniteQuery
const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
@ -374,13 +211,10 @@ const MemoryGraphPage = () => {
)
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"
if (hasCompletedTour && allDocuments.length === 0 && !showTourDialog) {
if (allDocuments.length === 0) {
setShowConnectAIModal(true)
} else if (showTourDialog) {
setShowConnectAIModal(false)
}
}, [allDocuments.length, showTourDialog])
}, [allDocuments.length])
// Prevent body scrolling
useEffect(() => {
@ -413,7 +247,6 @@ const MemoryGraphPage = () => {
<motion.div
animate={{ opacity: 1, y: 0 }}
className="absolute md:top-4 md:right-4 md:bottom-auto md:left-auto bottom-8 left-6 z-20 rounded-xl overflow-hidden"
id={TOUR_STEP_IDS.VIEW_TOGGLE}
initial={{ opacity: 0, y: -20 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
@ -482,7 +315,6 @@ const MemoryGraphPage = () => {
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0"
exit={{ opacity: 0, scale: 0.95 }}
id={TOUR_STEP_IDS.MEMORY_GRAPH}
initial={{ opacity: 0, scale: 0.95 }}
key="graph"
transition={{
@ -501,7 +333,7 @@ const MemoryGraphPage = () => {
isExperimental={isCurrentProjectExperimental}
isLoading={isPending}
isLoadingMore={isLoadingMore}
legendId={TOUR_STEP_IDS.LEGEND}
legendId={undefined}
loadMoreDocuments={loadMoreDocuments}
occludedRightPx={isOpen && !isMobile ? 600 : 0}
showSpacesSelector={false}
@ -546,7 +378,6 @@ const MemoryGraphPage = () => {
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0 md:ml-18"
exit={{ opacity: 0, scale: 0.95 }}
id={TOUR_STEP_IDS.MEMORY_LIST}
initial={{ opacity: 0, scale: 0.95 }}
key="list"
transition={{
@ -609,11 +440,8 @@ const MemoryGraphPage = () => {
rel="noopener noreferrer"
target="_blank"
>
<LogoFull
className="h-8 hidden md:block"
id={TOUR_STEP_IDS.LOGO}
/>
<Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} />
<LogoFull className="h-8 hidden md:block" />
<Logo className="h-8 md:hidden" />
</Link>
<div className="hidden sm:block">
@ -697,7 +525,6 @@ const MemoryGraphPage = () => {
{/* Chat panel - positioned absolutely */}
<motion.div
className="fixed top-0 right-0 h-full z-50 md:z-auto"
id={TOUR_STEP_IDS.FLOATING_CHAT}
style={{
width: isOpen ? (isMobile ? "100vw" : "600px") : 0,
pointerEvents: isOpen ? "auto" : "none",
@ -726,9 +553,6 @@ const MemoryGraphPage = () => {
/>
)}
{/* Tour Alert Dialog */}
<TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} />
{/* Referral/Upgrade Modal */}
<ReferralUpgradeModal
isOpen={showReferralModal}
@ -741,6 +565,9 @@ const MemoryGraphPage = () => {
// Wrapper component to handle auth and waitlist checks
export default function Page() {
const { user, session } = useAuth()
const { shouldShowOnboarding, isLoading: onboardingLoading } =
useOnboardingStorage()
const router = useRouter()
useEffect(() => {
const url = new URL(window.location.href)
@ -765,8 +592,13 @@ export default function Page() {
}
}, [user, session])
// Show loading state while checking authentication and waitlist status
if (!user) {
useEffect(() => {
if (user && !onboardingLoading && shouldShowOnboarding()) {
router.push("/onboarding")
}
}, [user, shouldShowOnboarding, onboardingLoading, router])
if (!user || onboardingLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0f1419]">
<div className="flex flex-col items-center gap-4">
@ -777,7 +609,10 @@ export default function Page() {
)
}
// If we have a user and they have access, show the main component
if (shouldShowOnboarding()) {
return null
}
return (
<>
<MemoryGraphPage />

View file

@ -8,5 +8,12 @@
"recommended": true
}
}
},
"assist": {
"actions": {
"source": {
"useSortedAttributes": "off"
}
}
}
}

View file

@ -16,10 +16,8 @@ import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { Drawer } from "vaul"
import { useMobilePanel } from "@/lib/mobile-panel-context"
import { TOUR_STEP_IDS } from "@/lib/tour-constants"
import { useChatOpen } from "@/stores"
import { ProjectSelector } from "./project-selector"
import { useTour } from "./tour"
import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"
import { IntegrationsView } from "./views/integrations"
import { ProfileView } from "./views/profile"
@ -65,7 +63,6 @@ function Menu({ id }: { id?: string }) {
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const isMobile = useIsMobile()
const { activePanel, setActivePanel } = useMobilePanel()
const { setMenuExpanded } = useTour()
const autumn = useCustomer()
const { setIsOpen } = useChatOpen()
@ -99,14 +96,6 @@ function Menu({ id }: { id?: string }) {
const shouldShowLimitWarning =
!isProUser && memoriesUsed >= memoriesLimit * 0.8
// Map menu item keys to tour IDs
const menuItemTourIds: Record<string, string> = {
addUrl: TOUR_STEP_IDS.MENU_ADD_MEMORY,
projects: TOUR_STEP_IDS.MENU_PROJECTS,
mcp: TOUR_STEP_IDS.MENU_MCP,
integrations: "", // No tour ID for integrations yet
}
const menuItems = [
{
icon: Plus,
@ -221,14 +210,6 @@ function Menu({ id }: { id?: string }) {
}
}, [isMobile, activePanel])
// Notify tour provider about expansion state changes
useEffect(() => {
const isExpanded = isMobile
? isMobileMenuOpen || !!expandedView
: isHovered || !!expandedView
setMenuExpanded(isExpanded)
}, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded])
// Calculate width based on state
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56
@ -324,7 +305,6 @@ function Menu({ id }: { id?: string }) {
},
}}
className={`flex w-full items-center text-white/80 transition-colors duration-100 hover:text-white cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`}
id={menuItemTourIds[item.key]}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
layout
onClick={() => handleMenuItemClick(item.key)}
@ -590,7 +570,6 @@ function Menu({ id }: { id?: string }) {
},
}}
className="flex w-full items-center gap-3 px-2 py-2 text-white/90 hover:text-white hover:bg-white/10 rounded-lg cursor-pointer relative"
id={menuItemTourIds[item.key]}
initial={{ opacity: 0, y: 10 }}
layout
onClick={() => {

View file

@ -1,414 +0,0 @@
"use client";
import { Button } from "@repo/ui/components/button";
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { analytics } from "@/lib/analytics";
// Types
export interface TourStep {
content: React.ReactNode;
selectorId: string;
position?: "top" | "bottom" | "left" | "right" | "center";
onClickWithinArea?: () => void;
}
interface TourContextType {
currentStep: number;
totalSteps: number;
nextStep: () => void;
previousStep: () => void;
endTour: () => void;
isActive: boolean;
isPaused: boolean;
startTour: () => void;
setSteps: (steps: TourStep[]) => void;
steps: TourStep[];
isTourCompleted: boolean;
setIsTourCompleted: (completed: boolean) => void;
// Expansion state tracking
setMenuExpanded: (expanded: boolean) => void;
setChatExpanded: (expanded: boolean) => void;
}
// Context
const TourContext = React.createContext<TourContextType | undefined>(undefined);
export function useTour() {
const context = React.useContext(TourContext);
if (!context) {
throw new Error("useTour must be used within a TourProvider");
}
return context;
}
// Provider
interface TourProviderProps {
children: React.ReactNode;
onComplete?: () => void;
className?: string;
isTourCompleted?: boolean;
}
export function TourProvider({
children,
onComplete,
className,
isTourCompleted: initialCompleted = false,
}: TourProviderProps) {
const [currentStep, setCurrentStep] = React.useState(-1);
const [steps, setSteps] = React.useState<TourStep[]>([]);
const [isActive, setIsActive] = React.useState(false);
const [isTourCompleted, setIsTourCompleted] =
React.useState(initialCompleted);
// Track expansion states
const [isMenuExpanded, setIsMenuExpanded] = React.useState(false);
const [isChatExpanded, setIsChatExpanded] = React.useState(false);
// Calculate if tour should be paused
const isPaused = React.useMemo(() => {
return isActive && (isMenuExpanded || isChatExpanded);
}, [isActive, isMenuExpanded, isChatExpanded]);
const startTour = React.useCallback(() => {
console.debug("Starting tour with", steps.length, "steps");
analytics.tourStarted();
setCurrentStep(0);
setIsActive(true);
}, [steps]);
const endTour = React.useCallback(() => {
setCurrentStep(-1);
setIsActive(false);
setIsTourCompleted(true); // Mark tour as completed when ended/skipped
analytics.tourSkipped();
if (onComplete) {
onComplete();
}
}, [onComplete]);
const nextStep = React.useCallback(() => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
analytics.tourCompleted();
endTour();
setIsTourCompleted(true);
}
}, [currentStep, steps.length, endTour]);
const previousStep = React.useCallback(() => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
}, [currentStep]);
const setMenuExpanded = React.useCallback((expanded: boolean) => {
setIsMenuExpanded(expanded);
}, []);
const setChatExpanded = React.useCallback((expanded: boolean) => {
setIsChatExpanded(expanded);
}, []);
const value = React.useMemo(
() => ({
currentStep,
totalSteps: steps.length,
nextStep,
previousStep,
endTour,
isActive,
isPaused,
startTour,
setSteps,
steps,
isTourCompleted,
setIsTourCompleted,
setMenuExpanded,
setChatExpanded,
}),
[
currentStep,
steps,
nextStep,
previousStep,
endTour,
isActive,
isPaused,
startTour,
isTourCompleted,
setMenuExpanded,
setChatExpanded,
],
);
return (
<TourContext.Provider value={value}>
{children}
{isActive && !isPaused && (
<>
{console.log(
"Rendering TourHighlight for step:",
currentStep,
currentStep >= 0 && currentStep < steps.length
? steps[currentStep]
: "No step",
)}
<TourHighlight
className={className}
currentStepIndex={currentStep}
steps={steps}
/>
</>
)}
</TourContext.Provider>
);
}
// Tour Highlight Component
function TourHighlight({
currentStepIndex,
steps,
className,
}: {
currentStepIndex: number;
steps: TourStep[];
className?: string;
}) {
const { nextStep, previousStep, endTour } = useTour();
const [elementRect, setElementRect] = React.useState<DOMRect | null>(null);
// Get current step safely
const step =
currentStepIndex >= 0 && currentStepIndex < steps.length
? steps[currentStepIndex]
: null;
React.useEffect(() => {
if (!step) return;
// Use requestAnimationFrame to ensure DOM is ready
const rafId = requestAnimationFrame(() => {
const element = document.getElementById(step.selectorId);
console.debug(
"Looking for element with ID:",
step.selectorId,
"Found:",
!!element,
);
if (element) {
const rect = element.getBoundingClientRect();
console.debug("Element rect:", {
id: step.selectorId,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
bottom: rect.bottom,
right: rect.right,
});
setElementRect(rect);
}
});
// Add click listener for onClickWithinArea
let clickHandler: ((e: MouseEvent) => void) | null = null;
if (step.onClickWithinArea) {
const element = document.getElementById(step.selectorId);
if (element) {
clickHandler = (e: MouseEvent) => {
if (element.contains(e.target as Node)) {
step.onClickWithinArea?.();
}
};
document.addEventListener("click", clickHandler);
}
}
return () => {
cancelAnimationFrame(rafId);
if (clickHandler) {
document.removeEventListener("click", clickHandler);
}
};
}, [step]);
if (!step) return null;
// Keep the wrapper mounted but animate the content
return (
<AnimatePresence mode="wait">
{elementRect && (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key={`tour-step-${currentStepIndex}`}
transition={{ duration: 0.2 }}
>
{/* Highlight Border */}
<motion.div
animate={{ opacity: 1, scale: 1 }}
className={`fixed z-[101] pointer-events-none ${className}`}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
style={{
top: elementRect.top + window.scrollY,
left: elementRect.left + window.scrollX,
width: elementRect.width,
height: elementRect.height,
}}
>
<div className="absolute inset-0 rounded-lg outline-4 outline-blue-500/50 outline-offset-0" />
</motion.div>
{/* Tooltip with Glass Effect */}
<motion.div
animate={{ opacity: 1, y: 0 }}
className="fixed z-[102] w-72 rounded-lg shadow-xl overflow-hidden"
exit={{ opacity: 0, y: step.position === "top" ? 10 : -10 }}
initial={{ opacity: 0, y: step.position === "top" ? 10 : -10 }}
style={{
top: (() => {
const baseTop =
step.position === "bottom"
? elementRect.bottom + 8
: step.position === "top"
? elementRect.top - 200
: elementRect.top + elementRect.height / 2 - 100;
// Ensure tooltip stays within viewport
const maxTop = window.innerHeight - 250; // Leave space for tooltip height
const minTop = 10;
return Math.max(minTop, Math.min(baseTop, maxTop));
})(),
left: (() => {
const baseLeft =
step.position === "right"
? elementRect.right + 8
: step.position === "left"
? elementRect.left - 300
: elementRect.left + elementRect.width / 2 - 150;
// Ensure tooltip stays within viewport
const maxLeft = window.innerWidth - 300; // Tooltip width
const minLeft = 10;
return Math.max(minLeft, Math.min(baseLeft, maxLeft));
})(),
}}
>
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-lg" />
{/* Content */}
<div className="relative z-10 p-4">
<div className="mb-4 text-white">{step.content}</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-300">
{currentStepIndex + 1} / {steps.length}
</span>
<div className="flex gap-2">
<Button
className="border-white/20 text-white hover:bg-white/10"
onClick={endTour}
size="sm"
variant="outline"
>
Skip
</Button>
{currentStepIndex > 0 && (
<Button
className="border-white/20 text-white hover:bg-white/10"
onClick={previousStep}
size="sm"
variant="outline"
>
Previous
</Button>
)}
<Button
className="bg-white/20 text-white hover:bg-white/30"
onClick={nextStep}
size="sm"
>
{currentStepIndex === steps.length - 1 ? "Finish" : "Next"}
</Button>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
// Tour Alert Dialog
interface TourAlertDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TourAlertDialog({ open, onOpenChange }: TourAlertDialogProps) {
const { startTour, setIsTourCompleted } = useTour();
const handleStart = () => {
console.debug("TourAlertDialog: Starting tour");
onOpenChange(false);
startTour();
};
const handleSkip = () => {
analytics.tourSkipped();
setIsTourCompleted(true); // Mark tour as completed when skipped
onOpenChange(false);
};
if (!open) return null;
return (
<AnimatePresence>
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[102] w-[90vw] max-w-2xl rounded-lg shadow-xl overflow-hidden"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-lg" />
{/* Content */}
<div className="relative z-10 p-8 md:p-10 lg:p-12">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Welcome to supermemory
</h2>
<p className="text-lg md:text-xl text-gray-200 mb-8 leading-relaxed">
This is your personal knowledge graph where all your memories are
stored and connected. Let's take a quick tour to help you get
familiar with the interface.
</p>
<div className="flex gap-4 justify-end">
<Button
className="border-white/20 text-white hover:bg-white/10 px-6 py-2 text-base"
onClick={handleSkip}
size="lg"
variant="outline"
>
Skip Tour
</Button>
<Button
className="bg-white/20 text-white hover:bg-white/30 px-6 py-2 text-base"
onClick={handleStart}
size="lg"
>
Start Tour
</Button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View file

@ -2,9 +2,6 @@ import posthog from "posthog-js"
export const analytics = {
userSignedOut: () => posthog.capture("user_signed_out"),
tourStarted: () => posthog.capture("tour_started"),
tourCompleted: () => posthog.capture("tour_completed"),
tourSkipped: () => posthog.capture("tour_skipped"),
memoryAdded: (props: {
type: "note" | "link" | "file"

View file

@ -1,23 +0,0 @@
// Tour step IDs - these should match the IDs added to elements in your app
export const TOUR_STEP_IDS = {
LOGO: "tour-logo",
MENU_BUTTON: "tour-menu-button",
VIEW_TOGGLE: "tour-view-toggle",
MEMORY_GRAPH: "tour-memory-graph",
MEMORY_LIST: "tour-memory-list",
FLOATING_CHAT: "tour-floating-chat",
ADD_MEMORY: "tour-add-memory",
SPACES_DROPDOWN: "tour-spaces-dropdown",
SETTINGS: "tour-settings",
MENU_CONNECTIONS: "tour-connections",
// Menu items
MENU_ADD_MEMORY: "tour-menu-add-memory",
MENU_PROJECTS: "tour-menu-projects",
MENU_MCP: "tour-menu-mcp",
MENU_BILLING: "tour-menu-billing",
// Legend
LEGEND: "tour-legend",
} as const;
// Tour storage key for localStorage
export const TOUR_STORAGE_KEY = "supermemory-tour-completed";

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 MiB

View file

@ -0,0 +1,33 @@
"use client"
import { useCallback, useEffect, useState } from "react"
const ONBOARDING_STORAGE_KEY = "supermemory_onboarding_completed"
export function useOnboardingStorage() {
const [isOnboardingCompleted, setIsOnboardingCompleted] = useState<
boolean | null
>(null)
useEffect(() => {
const completed = localStorage.getItem(ONBOARDING_STORAGE_KEY)
setIsOnboardingCompleted(completed === "true")
}, [])
const markOnboardingCompleted = useCallback(() => {
localStorage.setItem(ONBOARDING_STORAGE_KEY, "true")
setIsOnboardingCompleted(true)
}, [])
const shouldShowOnboarding = useCallback(() => {
if (isOnboardingCompleted === null) return null // Still loading
return !isOnboardingCompleted
}, [isOnboardingCompleted])
return {
isOnboardingCompleted,
markOnboardingCompleted,
shouldShowOnboarding,
isLoading: isOnboardingCompleted === null,
}
}