diff options
| author | MaheshtheDev <[email protected]> | 2025-10-01 21:59:53 +0000 |
|---|---|---|
| committer | MaheshtheDev <[email protected]> | 2025-10-01 21:59:53 +0000 |
| commit | 60794f63e2bde8a4298fe7677f9cad4942fab3ef (patch) | |
| tree | 66f13d6d153ccb9c60eb5cb2476326374bc1c315 | |
| parent | feat: new onboarding flow (#408) (diff) | |
| download | supermemory-09-23-ui_onboarding_improvements.tar.xz supermemory-09-23-ui_onboarding_improvements.zip | |
UI: onboarding improvements (#435)09-23-ui_onboarding_improvements
UI: onboarding improvements
ui(onboarding): updated onboarding ui patterns
22 files changed, 1015 insertions, 1447 deletions
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8b1adc85..70d4916f 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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> - ); + ) } diff --git a/apps/web/app/onboarding/bio-form.tsx b/apps/web/app/onboarding/bio-form.tsx index 984309a9..d985a775 100644 --- a/apps/web/app/onboarding/bio-form.tsx +++ b/apps/web/app/onboarding/bio-form.tsx @@ -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> - ); -}
\ No newline at end of file + 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> + ) +} diff --git a/apps/web/app/onboarding/extension-form.tsx b/apps/web/app/onboarding/extension-form.tsx index 8ce40b99..c94512e0 100644 --- a/apps/web/app/onboarding/extension-form.tsx +++ b/apps/web/app/onboarding/extension-form.tsx @@ -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="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="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-white/80 font-medium! text-lg underline w-fit px-0! cursor-pointer" + onClick={nextStep} + > + Continue + <ChevronRightIcon className="size-4" /> + </Button> + </div> + </div> + ) }
\ No newline at end of file diff --git a/apps/web/app/onboarding/intro.tsx b/apps/web/app/onboarding/intro.tsx index ce106b59..5c8504a5 100644 --- a/apps/web/app/onboarding/intro.tsx +++ b/apps/web/app/onboarding/intro.tsx @@ -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)" }} diff --git a/apps/web/app/onboarding/mcp-form.tsx b/apps/web/app/onboarding/mcp-form.tsx index 50d48979..f8561a09 100644 --- a/apps/web/app/onboarding/mcp-form.tsx +++ b/apps/web/app/onboarding/mcp-form.tsx @@ -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> - ); -}
\ No newline at end of file + 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> + ) +} diff --git a/apps/web/app/onboarding/name-form.tsx b/apps/web/app/onboarding/name-form.tsx index c112933c..2b9520c9 100644 --- a/apps/web/app/onboarding/name-form.tsx +++ b/apps/web/app/onboarding/name-form.tsx @@ -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> - ); -}
\ No newline at end of file + <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> + ) +} diff --git a/apps/web/app/onboarding/nav-menu.tsx b/apps/web/app/onboarding/nav-menu.tsx index e66187d2..957cbbef 100644 --- a/apps/web/app/onboarding/nav-menu.tsx +++ b/apps/web/app/onboarding/nav-menu.tsx @@ -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", diff --git a/apps/web/app/onboarding/onboarding-background.tsx b/apps/web/app/onboarding/onboarding-background.tsx new file mode 100644 index 00000000..366648fa --- /dev/null +++ b/apps/web/app/onboarding/onboarding-background.tsx @@ -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> + ) +} diff --git a/apps/web/app/onboarding/onboarding-context.tsx b/apps/web/app/onboarding/onboarding-context.tsx index 13e13139..260fb51e 100644 --- a/apps/web/app/onboarding/onboarding-context.tsx +++ b/apps/web/app/onboarding/onboarding-context.tsx @@ -1,202 +1,232 @@ -"use client"; - -import { createContext, useContext, useState, useEffect, type ReactNode, useMemo } from "react"; -import { useQueryState } from "nuqs"; -import { useIsMobile } from "@hooks/use-mobile"; +"use client" + +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); - }; - - 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]); - - // 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), - ]; - - 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]); - - // 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 nextStep() { - const currentIndex = visibleSteps.indexOf(currentStep); - const nextIndex = currentIndex + 1; - - if (nextIndex < visibleSteps.length) { - setStep(visibleSteps[nextIndex]!); - } - } - - function previousStep() { - const currentIndex = visibleSteps.indexOf(currentStep); - const previousIndex = currentIndex - 1; - - if (previousIndex >= 0) { - setStep(visibleSteps[previousIndex]!); - } - } - - function resetIntroTriggers() { - setIntroTriggers({ - first: false, - second: false, - third: false, - fourth: false - }); - } - - const currentStepIndex = BASE_STEP_ORDER.indexOf(currentStep); - - // 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; - } - - 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, - }; - - return ( - <OnboardingContext.Provider value={contextValue}> - {children} - </OnboardingContext.Provider> - ); +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() + + // 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 + + 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]) + + // 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]) + + function setStep(step: OnboardingStep) { + setCurrentStep(step) + } + + function nextStep() { + const currentIndex = visibleSteps.indexOf(currentStep) + const nextIndex = currentIndex + 1 + + if (nextIndex < visibleSteps.length) { + setStep(visibleSteps[nextIndex]!) + } + } + + function previousStep() { + const currentIndex = visibleSteps.indexOf(currentStep) + const previousIndex = currentIndex - 1 + + if (previousIndex >= 0) { + setStep(visibleSteps[previousIndex]!) + } + } + + function resetIntroTriggers() { + setIntroTriggers({ + first: false, + second: false, + third: false, + fourth: false, + }) + } + + const currentStepIndex = BASE_STEP_ORDER.indexOf(currentStep) + + // 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 + } + + 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, + } + + 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 } diff --git a/apps/web/app/onboarding/onboarding-form.tsx b/apps/web/app/onboarding/onboarding-form.tsx index 962f8e47..520a2691 100644 --- a/apps/web/app/onboarding/onboarding-form.tsx +++ b/apps/web/app/onboarding/onboarding-form.tsx @@ -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> - ); -}
\ No newline at end of file + )}*/} + {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> + ) +} diff --git a/apps/web/app/onboarding/page.tsx b/apps/web/app/onboarding/page.tsx index b04f1349..dcf64ad0 100644 --- a/apps/web/app/onboarding/page.tsx +++ b/apps/web/app/onboarding/page.tsx @@ -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> - ); -}
\ No newline at end of file + return ( + <OnboardingProvider> + <OnboardingProgressBar /> + <OnboardingBackground> + <OnboardingForm /> + </OnboardingBackground> + </OnboardingProvider> + ) +} diff --git a/apps/web/app/onboarding/progress-bar.tsx b/apps/web/app/onboarding/progress-bar.tsx index 9bc01b01..82aae3e9 100644 --- a/apps/web/app/onboarding/progress-bar.tsx +++ b/apps/web/app/onboarding/progress-bar.tsx @@ -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> + ) } diff --git a/apps/web/app/onboarding/welcome.tsx b/apps/web/app/onboarding/welcome.tsx index d3b19dcd..3f73e43a 100644 --- a/apps/web/app/onboarding/welcome.tsx +++ b/apps/web/app/onboarding/welcome.tsx @@ -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> - ); -}
\ No newline at end of file + 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> + ) +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 5b7561ea..8886067d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -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 /> diff --git a/apps/web/biome.json b/apps/web/biome.json index 48649190..8ab5697e 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -8,5 +8,12 @@ "recommended": true } } + }, + "assist": { + "actions": { + "source": { + "useSortedAttributes": "off" + } + } } }
\ No newline at end of file diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx index db012ab7..f32b56d1 100644 --- a/apps/web/components/menu.tsx +++ b/apps/web/components/menu.tsx @@ -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={() => { diff --git a/apps/web/components/tour.tsx b/apps/web/components/tour.tsx deleted file mode 100644 index 33919efe..00000000 --- a/apps/web/components/tour.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 28f1d4fe..428ac872 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -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" diff --git a/apps/web/lib/tour-constants.ts b/apps/web/lib/tour-constants.ts deleted file mode 100644 index 9857c878..00000000 --- a/apps/web/lib/tour-constants.ts +++ /dev/null @@ -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"; diff --git a/apps/web/public/onboarding-complete.png b/apps/web/public/onboarding-complete.png Binary files differnew file mode 100644 index 00000000..329cccda --- /dev/null +++ b/apps/web/public/onboarding-complete.png diff --git a/apps/web/public/onboarding.png b/apps/web/public/onboarding.png Binary files differnew file mode 100644 index 00000000..f8ba97b1 --- /dev/null +++ b/apps/web/public/onboarding.png diff --git a/packages/hooks/use-onboarding-storage.ts b/packages/hooks/use-onboarding-storage.ts new file mode 100644 index 00000000..ce7a155d --- /dev/null +++ b/packages/hooks/use-onboarding-storage.ts @@ -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, + } +} |