mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
UI: onboarding improvements (#435)
UI: onboarding improvements ui(onboarding): updated onboarding ui patterns
This commit is contained in:
parent
ede0f39303
commit
60794f63e2
22 changed files with 994 additions and 1426 deletions
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
35
apps/web/app/onboarding/onboarding-background.tsx
Normal file
35
apps/web/app/onboarding/onboarding-background.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -8,5 +8,12 @@
|
|||
"recommended": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"useSortedAttributes": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
BIN
apps/web/public/onboarding-complete.png
Normal file
BIN
apps/web/public/onboarding-complete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
BIN
apps/web/public/onboarding.png
Normal file
BIN
apps/web/public/onboarding.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5 MiB |
33
packages/hooks/use-onboarding-storage.ts
Normal file
33
packages/hooks/use-onboarding-storage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue