mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-10 22:41:17 +00:00
remove unused old onboarding flow (#904)
This commit is contained in:
parent
f850a0e8a7
commit
253d16b4b8
14 changed files with 0 additions and 2423 deletions
|
|
@ -1,109 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export type MemoryFormData = {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
} | null
|
||||
|
||||
interface OnboardingContextValue {
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
memoryFormData: MemoryFormData
|
||||
setMemoryFormData: (data: MemoryFormData) => void
|
||||
resetOnboarding: () => void
|
||||
}
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | null>(null)
|
||||
|
||||
export function useOnboardingContext() {
|
||||
const ctx = useContext(OnboardingContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useOnboardingContext must be used within OnboardingLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function OnboardingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
|
||||
const [name, setNameState] = useState<string>("")
|
||||
const [memoryFormData, setMemoryFormDataState] =
|
||||
useState<MemoryFormData>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const storedName = localStorage.getItem("onboarding_name")
|
||||
const storedMemoryFormData = localStorage.getItem(
|
||||
"onboarding_memoryFormData",
|
||||
)
|
||||
|
||||
if (storedName) {
|
||||
setNameState(storedName)
|
||||
} else if (user?.displayUsername) {
|
||||
setNameState(user.displayUsername)
|
||||
localStorage.setItem("onboarding_name", user.displayUsername)
|
||||
} else if (user?.name) {
|
||||
setNameState(user.name)
|
||||
localStorage.setItem("onboarding_name", user.name)
|
||||
}
|
||||
|
||||
if (storedMemoryFormData) {
|
||||
try {
|
||||
setMemoryFormDataState(JSON.parse(storedMemoryFormData))
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [user?.displayUsername, user?.name])
|
||||
|
||||
const setName = useCallback((newName: string) => {
|
||||
setNameState(newName)
|
||||
localStorage.setItem("onboarding_name", newName)
|
||||
localStorage.setItem("username", newName)
|
||||
}, [])
|
||||
|
||||
const setMemoryFormData = useCallback((data: MemoryFormData) => {
|
||||
setMemoryFormDataState(data)
|
||||
if (data) {
|
||||
localStorage.setItem("onboarding_memoryFormData", JSON.stringify(data))
|
||||
} else {
|
||||
localStorage.removeItem("onboarding_memoryFormData")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetOnboarding = useCallback(() => {
|
||||
localStorage.removeItem("onboarding_name")
|
||||
localStorage.removeItem("onboarding_memoryFormData")
|
||||
setNameState("")
|
||||
setMemoryFormDataState(null)
|
||||
}, [])
|
||||
|
||||
const contextValue: OnboardingContextValue = {
|
||||
name,
|
||||
setName,
|
||||
memoryFormData,
|
||||
setMemoryFormData,
|
||||
resetOnboarding,
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/old/onboarding/welcome?step=input")
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black flex items-center justify-center">
|
||||
<div className="text-white/50 text-sm">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useOnboardingContext, type MemoryFormData } from "../layout"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export const SETUP_STEPS = ["integrations"] as const
|
||||
export type SetupStep = (typeof SETUP_STEPS)[number]
|
||||
|
||||
interface SetupContextValue {
|
||||
memoryFormData: MemoryFormData
|
||||
currentStep: SetupStep
|
||||
goToStep: (step: SetupStep) => void
|
||||
goToWelcome: (step?: string) => void
|
||||
finishOnboarding: () => void
|
||||
}
|
||||
|
||||
const SetupContext = createContext<SetupContextValue | null>(null)
|
||||
|
||||
export function useSetupContext() {
|
||||
const ctx = useContext(SetupContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useSetupContext must be used within SetupLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function SetupLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { memoryFormData, resetOnboarding } = useOnboardingContext()
|
||||
|
||||
const stepParam = searchParams.get("step")
|
||||
const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep)
|
||||
? (stepParam as SetupStep)
|
||||
: "integrations"
|
||||
const hasTrackedInitialStep = useRef(false)
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: SetupStep) => {
|
||||
analytics.onboardingStepViewed({ step, trigger: "user" })
|
||||
router.push(`/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToWelcome = useCallback(
|
||||
(step = "input") => {
|
||||
router.push(`/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const finishOnboarding = useCallback(() => {
|
||||
resetOnboarding()
|
||||
router.push("/")
|
||||
}, [router, resetOnboarding])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTrackedInitialStep.current) {
|
||||
analytics.onboardingStepViewed({ step: currentStep, trigger: "user" })
|
||||
hasTrackedInitialStep.current = true
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const contextValue: SetupContextValue = {
|
||||
memoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
goToWelcome,
|
||||
finishOnboarding,
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SetupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatePresence } from "motion/react"
|
||||
|
||||
import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step"
|
||||
|
||||
import { SetupHeader } from "@/components/onboarding/setup/header"
|
||||
import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
import { useSetupContext } from "./layout"
|
||||
|
||||
export default function SetupPage() {
|
||||
const { memoryFormData } = useSetupContext()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden bg-black">
|
||||
<SetupHeader />
|
||||
|
||||
<AnimatedGradientBackground animateFromBottom={false} />
|
||||
|
||||
<main className="relative min-h-screen">
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-col lg:flex-row h-[calc(100vh-90px)] relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-start p-4 md:p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<IntegrationsStep key="integrations" />
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar formData={memoryFormData} />
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isMobile && <ChatSidebar formData={memoryFormData} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useOnboardingContext, type MemoryFormData } from "../layout"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export const WELCOME_STEPS = [
|
||||
"input",
|
||||
"greeting",
|
||||
"welcome",
|
||||
"username",
|
||||
"features",
|
||||
"memories",
|
||||
] as const
|
||||
export type WelcomeStep = (typeof WELCOME_STEPS)[number]
|
||||
|
||||
interface WelcomeContextValue {
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
isSubmitting: boolean
|
||||
setIsSubmitting: (value: boolean) => void
|
||||
showWelcomeContent: boolean
|
||||
memoryFormData: MemoryFormData
|
||||
setMemoryFormData: (data: MemoryFormData) => void
|
||||
currentStep: WelcomeStep
|
||||
goToStep: (step: WelcomeStep) => void
|
||||
goToSetup: (step?: string) => void
|
||||
}
|
||||
|
||||
const WelcomeContext = createContext<WelcomeContextValue | null>(null)
|
||||
|
||||
export function useWelcomeContext() {
|
||||
const ctx = useContext(WelcomeContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useWelcomeContext must be used within WelcomeLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { name, setName, memoryFormData, setMemoryFormData } =
|
||||
useOnboardingContext()
|
||||
const { organizations } = useAuth()
|
||||
const hasOrgs = Array.isArray(organizations) && organizations.length > 0
|
||||
|
||||
const stepParam = searchParams.get("step")
|
||||
const resolvedStep: WelcomeStep = WELCOME_STEPS.includes(
|
||||
stepParam as WelcomeStep,
|
||||
)
|
||||
? (stepParam as WelcomeStep)
|
||||
: "input"
|
||||
const currentStep: WelcomeStep =
|
||||
resolvedStep === "input" && hasOrgs ? "greeting" : resolvedStep
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
|
||||
const isMountedRef = useRef(true)
|
||||
const hasTrackedInitialStep = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === "input") {
|
||||
setShowWelcomeContent(false)
|
||||
const timer = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowWelcomeContent(true)
|
||||
}
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setShowWelcomeContent(true)
|
||||
}, [currentStep])
|
||||
|
||||
useEffect(() => {
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
|
||||
if (currentStep === "greeting") {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" })
|
||||
router.replace("/old/onboarding/welcome?step=welcome")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
} else if (currentStep === "welcome") {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
analytics.onboardingStepViewed({
|
||||
step: "username",
|
||||
trigger: "auto",
|
||||
})
|
||||
router.replace("/old/onboarding/welcome?step=username")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
timers.forEach(clearTimeout)
|
||||
}
|
||||
}, [currentStep, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTrackedInitialStep.current) {
|
||||
analytics.onboardingStepViewed({
|
||||
step: currentStep,
|
||||
trigger: "user",
|
||||
})
|
||||
hasTrackedInitialStep.current = true
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: WelcomeStep) => {
|
||||
analytics.onboardingStepViewed({ step, trigger: "user" })
|
||||
router.push(`/old/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(
|
||||
(step = "integrations") => {
|
||||
router.push(`/old/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const contextValue: WelcomeContextValue = {
|
||||
name,
|
||||
setName,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
showWelcomeContent,
|
||||
memoryFormData,
|
||||
setMemoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
goToSetup,
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</WelcomeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
import { InputStep } from "@/components/onboarding/welcome/input-step"
|
||||
import { GreetingStep } from "@/components/onboarding/welcome/greeting-step"
|
||||
import { WelcomeStep } from "@/components/onboarding/welcome/welcome-step"
|
||||
import { OnboardingContentStep } from "@/components/onboarding/welcome/continue-step"
|
||||
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
|
||||
import {
|
||||
useWelcomeContext,
|
||||
type WelcomeStep as WelcomeStepType,
|
||||
} from "./layout"
|
||||
import { gapVariants, orbVariants } from "@/lib/variants"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { toast } from "sonner"
|
||||
|
||||
function generateSlugFromName(value: string) {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "org"
|
||||
)
|
||||
}
|
||||
|
||||
function generateOrgSlug(name: string) {
|
||||
const base = generateSlugFromName(name.trim())
|
||||
const randomNum = Math.floor(100000 + Math.random() * 900000)
|
||||
return `${base}-${randomNum}`
|
||||
}
|
||||
|
||||
function generateUsername(name: string) {
|
||||
const base = generateSlugFromName(name.trim()).replace(/-/g, "_")
|
||||
const randomNum = Math.floor(100000 + Math.random() * 900000)
|
||||
return `${base}${randomNum}`
|
||||
}
|
||||
|
||||
function UserSupermemory({ name }: { name: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0 top-[-34px] flex items-center justify-center z-10"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
>
|
||||
<Logo className="h-14 text-white" />
|
||||
<div className="flex flex-col items-start justify-center ml-4">
|
||||
<p className="text-white text-[25px] font-medium leading-none">
|
||||
{name.split(" ")[0]}'s
|
||||
</p>
|
||||
<p className="text-white font-bold text-4xl leading-none -mt-2">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepNotFound({
|
||||
goToStep,
|
||||
}: {
|
||||
goToStep: (step: WelcomeStepType) => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep("input")}
|
||||
className="text-blue-400 underline"
|
||||
>
|
||||
Start from beginning
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WelcomePage() {
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
showWelcomeContent,
|
||||
setMemoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
} = useWelcomeContext()
|
||||
|
||||
const { refetchOrganizations, setActiveOrg } = useAuth()
|
||||
const submitLockRef = useRef(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
if (submitLockRef.current) return
|
||||
submitLockRef.current = true
|
||||
localStorage.setItem("username", trimmed)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await authClient.updateUser({
|
||||
displayUsername: trimmed,
|
||||
username: generateUsername(trimmed),
|
||||
})
|
||||
|
||||
const refetchResult = await refetchOrganizations()
|
||||
const refetchData = (
|
||||
refetchResult as { data?: unknown[] | null | undefined }
|
||||
)?.data
|
||||
const existingOrgs = Array.isArray(refetchData) ? refetchData : []
|
||||
|
||||
if (existingOrgs.length > 0) {
|
||||
analytics.onboardingNameSubmitted({
|
||||
name_length: trimmed.length,
|
||||
})
|
||||
goToStep("greeting")
|
||||
return
|
||||
}
|
||||
|
||||
const uniqueSlug = generateOrgSlug(trimmed)
|
||||
const completedAt = new Date().toISOString()
|
||||
const newOrg = await authClient.organization.create({
|
||||
name: trimmed,
|
||||
slug: uniqueSlug,
|
||||
metadata: {
|
||||
signupSource: "consumer",
|
||||
webOnboarding: {
|
||||
completedAt: null,
|
||||
steps: {
|
||||
welcomeInput: {
|
||||
startedAt: completedAt,
|
||||
completedAt,
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await setActiveOrg(newOrg.slug)
|
||||
|
||||
analytics.onboardingNameSubmitted({ name_length: trimmed.length })
|
||||
goToStep("greeting")
|
||||
} catch (error) {
|
||||
console.error("Onboarding submit failed:", error)
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not set up your workspace. Please try again.",
|
||||
)
|
||||
} finally {
|
||||
submitLockRef.current = false
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case "input":
|
||||
return (
|
||||
<InputStep
|
||||
key="input"
|
||||
name={name}
|
||||
setName={setName}
|
||||
handleSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)
|
||||
case "greeting":
|
||||
return <GreetingStep key="greeting" name={name} />
|
||||
case "welcome":
|
||||
return <WelcomeStep key="welcome" />
|
||||
case "username":
|
||||
case "features":
|
||||
case "memories":
|
||||
return (
|
||||
<OnboardingContentStep
|
||||
key="onboarding-content"
|
||||
currentView={
|
||||
currentStep === "username"
|
||||
? "continue"
|
||||
: currentStep === "features"
|
||||
? "features"
|
||||
: "memories"
|
||||
}
|
||||
onSubmit={setMemoryFormData}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return <StepNotFound key="not-found" goToStep={goToStep} />
|
||||
}
|
||||
}
|
||||
|
||||
const minimizeNovaOrb = ["features", "memories"].includes(currentStep)
|
||||
const novaSize = currentStep === "memories" ? 150 : 300
|
||||
const showUserSupermemory = currentStep === "username"
|
||||
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden bg-black">
|
||||
<InitialHeader
|
||||
showUserSupermemory={
|
||||
currentStep === "features" || currentStep === "memories"
|
||||
}
|
||||
showSkipOnboarding={currentStep !== "input"}
|
||||
name={name}
|
||||
/>
|
||||
|
||||
{showWelcomeContent && (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat pointer-events-none"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex flex-col items-center justify-center",
|
||||
)}
|
||||
variants={gapVariants}
|
||||
animate={minimizeNovaOrb ? "minimized" : "default"}
|
||||
>
|
||||
<motion.div
|
||||
variants={orbVariants}
|
||||
animate={
|
||||
currentStep === "features"
|
||||
? "features"
|
||||
: currentStep === "memories"
|
||||
? "memories"
|
||||
: "default"
|
||||
}
|
||||
initial={{
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<NovaOrb size={novaSize} />
|
||||
{showUserSupermemory && <UserSupermemory name={name} />}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,870 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useAgent } from "agents/react"
|
||||
import { useAgentChat } from "@cloudflare/ai-chat/react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
PanelRightCloseIcon,
|
||||
SendIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { collectValidUrls } from "@/lib/url-helpers"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useProject } from "@/stores"
|
||||
import { Streamdown } from "streamdown"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
interface ChatSidebarProps {
|
||||
formData: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
interface DraftDoc {
|
||||
kind: "likes" | "link" | "x_research"
|
||||
content: string
|
||||
metadata: Record<string, string>
|
||||
title?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export function ChatSidebar({ formData }: ChatSidebarProps) {
|
||||
const { user } = useAuth()
|
||||
const { selectedProject } = useProject()
|
||||
const isMobile = useIsMobile()
|
||||
const [message, setMessage] = useState("")
|
||||
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
|
||||
const [timelineMessages, setTimelineMessages] = useState<
|
||||
{
|
||||
message: string
|
||||
type?: "formData" | "exa" | "memory" | "waiting"
|
||||
memories?: {
|
||||
url: string
|
||||
title: string
|
||||
description: string
|
||||
fullContent: string
|
||||
}[]
|
||||
url?: string
|
||||
title?: string
|
||||
description?: string
|
||||
}[]
|
||||
>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isFetchingDrafts, setIsFetchingDrafts] = useState(false)
|
||||
const [draftDocs, setDraftDocs] = useState<DraftDoc[]>([])
|
||||
const [xResearchStatus, setXResearchStatus] = useState<
|
||||
"correct" | "incorrect" | null
|
||||
>(null)
|
||||
const [isConfirmed, setIsConfirmed] = useState(false)
|
||||
const [processingByUrl, setProcessingByUrl] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const displayedMemoriesRef = useRef<Set<string>>(new Set())
|
||||
const contextInjectedRef = useRef(false)
|
||||
const draftsBuiltRef = useRef(false)
|
||||
const isProcessingRef = useRef(false)
|
||||
const draftRequestIdRef = useRef(0)
|
||||
|
||||
const backendUrl = new URL(process.env.NEXT_PUBLIC_BACKEND_URL!)
|
||||
const agent = useAgent({
|
||||
agent: "chat-agent",
|
||||
name: user?.id ?? "anonymous",
|
||||
host: backendUrl.host,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
agent.setState({
|
||||
model: "claude-sonnet-4.6" as const,
|
||||
projectId: selectedProject,
|
||||
})
|
||||
}, [agent, selectedProject])
|
||||
|
||||
const {
|
||||
messages: chatMessages,
|
||||
sendMessage,
|
||||
status,
|
||||
} = useAgentChat({
|
||||
agent,
|
||||
getInitialMessages: null,
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
const buildOnboardingContext = useCallback(() => {
|
||||
if (!formData) return ""
|
||||
|
||||
const contextParts: string[] = []
|
||||
|
||||
if (formData.description?.trim()) {
|
||||
contextParts.push(`User's interests/likes: ${formData.description}`)
|
||||
}
|
||||
|
||||
if (formData.twitter) {
|
||||
contextParts.push(`X/Twitter profile: ${formData.twitter}`)
|
||||
}
|
||||
|
||||
if (formData.linkedin) {
|
||||
contextParts.push(`LinkedIn profile: ${formData.linkedin}`)
|
||||
}
|
||||
|
||||
if (formData.otherLinks.length > 0) {
|
||||
contextParts.push(`Other links: ${formData.otherLinks.join(", ")}`)
|
||||
}
|
||||
|
||||
const memoryTexts = timelineMessages
|
||||
.filter((msg) => msg.type === "memory" && msg.memories)
|
||||
.flatMap(
|
||||
(msg) => msg.memories?.map((m) => `${m.title}: ${m.description}`) || [],
|
||||
)
|
||||
|
||||
if (memoryTexts.length > 0) {
|
||||
contextParts.push(`Extracted memories:\n${memoryTexts.join("\n")}`)
|
||||
}
|
||||
|
||||
return contextParts.join("\n\n")
|
||||
}, [formData, timelineMessages])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!message.trim() || status === "submitted" || status === "streaming")
|
||||
return
|
||||
|
||||
let messageToSend = message
|
||||
|
||||
const context = buildOnboardingContext()
|
||||
|
||||
if (context && !contextInjectedRef.current && chatMessages.length === 0) {
|
||||
messageToSend = `${context}\n\nUser question: ${message}`
|
||||
contextInjectedRef.current = true
|
||||
}
|
||||
|
||||
sendMessage({ text: messageToSend })
|
||||
setMessage("")
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
setIsChatOpen(!isChatOpen)
|
||||
}
|
||||
|
||||
const pollForMemories = useCallback(
|
||||
async (documentIds: string[]) => {
|
||||
const maxAttempts = 30 // 30 attempts * 3 seconds = 90 seconds max
|
||||
const pollInterval = 3000 // 3 seconds
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const response = await $fetch("@get/documents/:id", {
|
||||
params: { id: documentIds[0] ?? "" },
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
console.log("response", response)
|
||||
|
||||
if (response.data) {
|
||||
const document = response.data
|
||||
|
||||
if (document.memories && document.memories.length > 0) {
|
||||
const newMemories: {
|
||||
url: string
|
||||
title: string
|
||||
description: string
|
||||
fullContent: string
|
||||
}[] = []
|
||||
|
||||
document.memories.forEach(
|
||||
(memory: { memory: string; title?: string }) => {
|
||||
if (!displayedMemoriesRef.current.has(memory.memory)) {
|
||||
displayedMemoriesRef.current.add(memory.memory)
|
||||
newMemories.push({
|
||||
url: document.url || "",
|
||||
title: memory.title || document.title || "Memory",
|
||||
description: memory.memory || "",
|
||||
fullContent: memory.memory || "",
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (newMemories.length > 0 && timelineMessages.length < 10) {
|
||||
setTimelineMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: newMemories
|
||||
.map((memory) => memory.description)
|
||||
.join("\n"),
|
||||
type: "memory" as const,
|
||||
memories: newMemories,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
if (document.memories && document.memories.length > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||
} catch (error) {
|
||||
console.warn("Error polling for memories:", error)
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||
}
|
||||
}
|
||||
},
|
||||
[timelineMessages.length],
|
||||
)
|
||||
|
||||
const buildDraftDocs = useCallback(async () => {
|
||||
if (!formData || draftsBuiltRef.current) return
|
||||
draftsBuiltRef.current = true
|
||||
|
||||
const hasContent =
|
||||
formData.twitter ||
|
||||
formData.linkedin ||
|
||||
formData.otherLinks.length > 0 ||
|
||||
formData.description?.trim()
|
||||
|
||||
if (!hasContent) return
|
||||
|
||||
const requestId = ++draftRequestIdRef.current
|
||||
|
||||
setIsFetchingDrafts(true)
|
||||
const drafts: DraftDoc[] = []
|
||||
|
||||
const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
|
||||
const allProcessingUrls: string[] = [...urls]
|
||||
if (formData.twitter) {
|
||||
allProcessingUrls.push(formData.twitter)
|
||||
}
|
||||
|
||||
if (allProcessingUrls.length > 0) {
|
||||
setProcessingByUrl((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const url of allProcessingUrls) {
|
||||
next[url] = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
if (formData.description?.trim()) {
|
||||
drafts.push({
|
||||
kind: "likes",
|
||||
content: formData.description,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
description_source: "user_input",
|
||||
},
|
||||
title: "Your Interests",
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch each URL separately for per-link loading state
|
||||
const linkPromises = urls.map(async (url) => {
|
||||
try {
|
||||
const response = await fetch("/api/onboarding/extract-content", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ urls: [url] }),
|
||||
})
|
||||
const data = await response.json()
|
||||
return data.results?.[0] || null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
// Clear this URL's processing state
|
||||
if (draftRequestIdRef.current === requestId) {
|
||||
setProcessingByUrl((prev) => ({ ...prev, [url]: false }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch X/Twitter research
|
||||
const xResearchPromise = formData.twitter
|
||||
? (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/onboarding/research", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
xUrl: formData.twitter,
|
||||
name: user?.name,
|
||||
email: user?.email,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const data = await response.json()
|
||||
return data?.text?.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
// Clear twitter URL's processing state
|
||||
if (draftRequestIdRef.current === requestId) {
|
||||
setProcessingByUrl((prev) => ({
|
||||
...prev,
|
||||
[formData.twitter]: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
})()
|
||||
: Promise.resolve(null)
|
||||
|
||||
const [exaResults, xResearchResult] = await Promise.all([
|
||||
Promise.all(linkPromises),
|
||||
xResearchPromise,
|
||||
])
|
||||
|
||||
// Guard against stale request completing after a newer one
|
||||
if (draftRequestIdRef.current !== requestId) return
|
||||
|
||||
for (const result of exaResults) {
|
||||
if (result && (result.text || result.description)) {
|
||||
drafts.push({
|
||||
kind: "link",
|
||||
content: result.text || result.description || "",
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
exa_url: result.url,
|
||||
exa_title: result.title,
|
||||
},
|
||||
title: result.title || "Extracted Content",
|
||||
url: result.url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (xResearchResult) {
|
||||
drafts.push({
|
||||
kind: "x_research",
|
||||
content: xResearchResult,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
onboarding_source: "x_research",
|
||||
x_url: formData.twitter,
|
||||
},
|
||||
title: "X/Twitter Profile Research",
|
||||
url: formData.twitter,
|
||||
})
|
||||
}
|
||||
|
||||
setDraftDocs(drafts)
|
||||
} catch (error) {
|
||||
console.warn("Error building draft docs:", error)
|
||||
} finally {
|
||||
if (draftRequestIdRef.current === requestId) {
|
||||
setIsFetchingDrafts(false)
|
||||
}
|
||||
}
|
||||
}, [formData, user])
|
||||
|
||||
const handleConfirmDocs = useCallback(async () => {
|
||||
if (isConfirmed || isProcessingRef.current) return
|
||||
isProcessingRef.current = true
|
||||
setIsConfirmed(true)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const promises = draftDocs.map(async (draft) => {
|
||||
if (draft.kind === "x_research" && xResearchStatus !== "correct") {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const docResponse = await $fetch("@post/documents", {
|
||||
body: {
|
||||
content: draft.content,
|
||||
containerTags: ["sm_project_default"],
|
||||
metadata: draft.metadata,
|
||||
},
|
||||
})
|
||||
|
||||
return docResponse.data?.id
|
||||
} catch (error) {
|
||||
console.warn("Error creating document:", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
const documentIds = results.filter(
|
||||
(id): id is string => id !== null && id !== undefined,
|
||||
)
|
||||
|
||||
if (documentIds.length > 0) {
|
||||
await pollForMemories(documentIds)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error confirming documents:", error)
|
||||
setIsConfirmed(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
isProcessingRef.current = false
|
||||
}
|
||||
}, [draftDocs, xResearchStatus, isConfirmed, pollForMemories])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData) return
|
||||
|
||||
const formDataMessages: typeof timelineMessages = []
|
||||
|
||||
if (formData.twitter) {
|
||||
formDataMessages.push({
|
||||
message: formData.twitter,
|
||||
url: formData.twitter,
|
||||
title: "X/Twitter",
|
||||
description: formData.twitter,
|
||||
type: "formData" as const,
|
||||
})
|
||||
}
|
||||
|
||||
if (formData.linkedin) {
|
||||
formDataMessages.push({
|
||||
message: formData.linkedin,
|
||||
url: formData.linkedin,
|
||||
title: "LinkedIn",
|
||||
description: formData.linkedin,
|
||||
type: "formData" as const,
|
||||
})
|
||||
}
|
||||
|
||||
if (formData.otherLinks.length > 0) {
|
||||
formData.otherLinks.forEach((link) => {
|
||||
formDataMessages.push({
|
||||
message: link,
|
||||
url: link,
|
||||
title: "Link",
|
||||
description: link,
|
||||
type: "formData" as const,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (formData.description?.trim()) {
|
||||
formDataMessages.push({
|
||||
message: formData.description,
|
||||
title: "Likes",
|
||||
description: formData.description,
|
||||
type: "formData" as const,
|
||||
})
|
||||
}
|
||||
|
||||
setTimelineMessages(formDataMessages)
|
||||
buildDraftDocs()
|
||||
}, [formData, buildDraftDocs])
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{!isChatOpen ? (
|
||||
<motion.div
|
||||
key="closed"
|
||||
className={cn(
|
||||
"flex items-start justify-start",
|
||||
isMobile
|
||||
? "fixed bottom-4 right-4 z-50"
|
||||
: "absolute top-0 right-0 m-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
layoutId="chat-toggle-button"
|
||||
>
|
||||
<motion.button
|
||||
onClick={toggleChat}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer shadow-lg",
|
||||
isMobile && "px-4 py-2",
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<NovaOrb size={24} className="blur-none! z-10" />
|
||||
{!isMobile && "Chat with Nova"}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="open"
|
||||
className={cn(
|
||||
"bg-[#0A0E14] backdrop-blur-md flex flex-col",
|
||||
isMobile
|
||||
? "fixed inset-0 z-50 w-full h-dvh rounded-none m-0"
|
||||
: "w-[450px] h-[calc(100vh-110px)] rounded-2xl m-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
initial={
|
||||
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
|
||||
}
|
||||
animate={{ x: 0, y: 0, opacity: 1 }}
|
||||
exit={
|
||||
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
|
||||
}
|
||||
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
||||
>
|
||||
<motion.button
|
||||
onClick={toggleChat}
|
||||
className={cn(
|
||||
"absolute top-4 right-4 flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer",
|
||||
isMobile && "bg-[#0D121A] border border-[#73737333]",
|
||||
)}
|
||||
style={
|
||||
isMobile
|
||||
? {
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}
|
||||
: {
|
||||
background:
|
||||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}
|
||||
}
|
||||
layoutId="chat-toggle-button"
|
||||
>
|
||||
{isMobile ? (
|
||||
<XIcon className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
Close chat
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end overflow-y-auto scrollbar-thin">
|
||||
{timelineMessages.map((msg, i) => (
|
||||
<div
|
||||
key={`message-${i}-${msg.message}`}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
{msg.type === "waiting" ? (
|
||||
<div className="flex items-center gap-2 text-white/50">
|
||||
<NovaOrb size={30} className="blur-none!" />
|
||||
<span className="text-sm">{msg.message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center w-[30px] h-full",
|
||||
i !== 0 && "",
|
||||
)}
|
||||
>
|
||||
{i === 0 && (
|
||||
<div className="w-3 h-3 bg-[#293952]/40 rounded-full mb-1" />
|
||||
)}
|
||||
<div className="w-px flex-1 bg-[#293952]/40" />
|
||||
</div>
|
||||
{msg.type === "formData" && (
|
||||
<div className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-1 flex-1">
|
||||
{msg.title && (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
{msg.title}
|
||||
</h3>
|
||||
{msg.url && processingByUrl[msg.url] && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.url && (
|
||||
<a
|
||||
href={msg.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline break-all block"
|
||||
>
|
||||
{msg.url}
|
||||
</a>
|
||||
)}
|
||||
{msg.title === "Likes" && msg.description && (
|
||||
<p className="text-xs text-white/70 mt-1">
|
||||
{msg.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.type === "memory" && (
|
||||
<div className="space-y-2 w-full max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{msg.memories?.map((memory) => (
|
||||
<div
|
||||
key={memory.url + memory.title}
|
||||
className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-2"
|
||||
>
|
||||
{memory.title && (
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
{memory.title}
|
||||
</h3>
|
||||
)}
|
||||
{memory.url && (
|
||||
<a
|
||||
href={memory.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline break-all"
|
||||
>
|
||||
{memory.url}
|
||||
</a>
|
||||
)}
|
||||
{memory.description && (
|
||||
<p className="text-xs text-white/50 mt-1">
|
||||
{memory.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{chatMessages.map((msg) => {
|
||||
if (msg.role === "user") {
|
||||
const text = msg.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="flex items-start gap-2 justify-end"
|
||||
>
|
||||
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
|
||||
<p className="text-sm text-white">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (msg.role === "assistant") {
|
||||
return (
|
||||
<div key={msg.id} className="flex items-start gap-2">
|
||||
<NovaOrb size={30} className="blur-none!" />
|
||||
<div className="flex-1">
|
||||
{msg.parts.map((part, partIndex) => {
|
||||
if (part.type === "text") {
|
||||
return (
|
||||
<div
|
||||
key={`${msg.id}-${partIndex}`}
|
||||
className="text-sm text-white/90 chat-markdown-content"
|
||||
>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (part.type === "tool-searchMemories") {
|
||||
if (
|
||||
part.state === "input-available" ||
|
||||
part.state === "input-streaming"
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={`${msg.id}-${partIndex}`}
|
||||
className="text-xs text-white/50 italic"
|
||||
>
|
||||
Searching memories...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{(status === "submitted" || status === "streaming") &&
|
||||
chatMessages[chatMessages.length - 1]?.role === "user" && (
|
||||
<div className="flex items-start gap-2">
|
||||
<NovaOrb size={30} className="blur-none!" />
|
||||
<span className="text-sm text-white/50">Thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
{timelineMessages.length === 0 &&
|
||||
chatMessages.length === 0 &&
|
||||
!isLoading &&
|
||||
!formData && (
|
||||
<div className="flex items-center gap-2 text-white/50">
|
||||
<NovaOrb size={28} className="blur-none!" />
|
||||
<span className="text-sm">Waiting for your input</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-foreground/50">
|
||||
<NovaOrb size={28} className="blur-none!" />
|
||||
<span className="text-sm">Extracting memories...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{draftDocs.some((d) => d.kind === "x_research") && !isConfirmed && (
|
||||
<div className="px-4 pb-2 space-y-3">
|
||||
<div className="bg-[#293952]/40 rounded-lg p-3 space-y-2">
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
Your Profile Summary
|
||||
</h3>
|
||||
<div className="overflow-y-auto scrollbar-thin max-h-32">
|
||||
<p className="text-xs text-white/70">
|
||||
{draftDocs.find((d) => d.kind === "x_research")?.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<span className="text-xs text-white/50">
|
||||
Is this accurate?
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setXResearchStatus("correct")
|
||||
handleConfirmDocs()
|
||||
}}
|
||||
disabled={isConfirmed || isLoading}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors cursor-pointer",
|
||||
xResearchStatus === "correct"
|
||||
? "bg-green-500/20 text-green-400 border border-green-500/40"
|
||||
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
|
||||
(isConfirmed || isLoading) &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="size-3" />
|
||||
Correct
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setXResearchStatus("incorrect")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors cursor-pointer",
|
||||
xResearchStatus === "incorrect"
|
||||
? "bg-red-500/20 text-red-400 border border-red-500/40"
|
||||
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
|
||||
)}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
Incorrect
|
||||
</button>
|
||||
</div>
|
||||
{xResearchStatus === "incorrect" && (
|
||||
<>
|
||||
<p className="text-xs text-white/40 pt-1">
|
||||
If incorrect, share your info in the input below, or you
|
||||
can add memories later as well.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirmDocs}
|
||||
disabled={isConfirmed || isLoading}
|
||||
className="w-full bg-[#267BF1] hover:bg-[#1E6AD9] text-white rounded-lg py-2 text-sm cursor-pointer mt-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!draftDocs.some((d) => d.kind === "x_research") &&
|
||||
draftDocs.length > 0 &&
|
||||
!isConfirmed && (
|
||||
<div className="px-4 pb-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirmDocs}
|
||||
disabled={isConfirmed || isLoading}
|
||||
className="w-full bg-[#267BF1] hover:bg-[#1E6AD9] text-white rounded-lg py-2 text-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 space-y-2">
|
||||
{isFetchingDrafts && (
|
||||
<div className="flex items-center gap-2 text-white/50 px-2">
|
||||
<NovaOrb size={20} className="blur-none!" />
|
||||
<span className="text-sm">
|
||||
Getting all relevant info about you...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (message.trim()) {
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Chat with your Supermemory"
|
||||
className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10"
|
||||
disabled={status === "submitted" || status === "streaming"}
|
||||
/>
|
||||
<div className="flex justify-end absolute bottom-3 right-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!message.trim() ||
|
||||
status === "submitted" ||
|
||||
status === "streaming"
|
||||
}
|
||||
className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
|
||||
size="icon"
|
||||
>
|
||||
<SendIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export function SetupHeader() {
|
||||
const { user } = useAuth()
|
||||
const router = useRouter()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
const { markOrgOnboarded, isLoading: isOrgLoading } = useOrgOnboarding()
|
||||
|
||||
const handleSkip = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
const displayName =
|
||||
user?.displayUsername || localStorageUsername || user?.name || ""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative z-20 flex p-6 justify-between items-center"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center gap-2 sm:gap-3 min-w-0 z-10! text-sm",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
className={cn(
|
||||
"flex items-center min-w-0 rounded-lg py-1 pr-2 -ml-1 pl-1",
|
||||
"hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors cursor-pointer text-left",
|
||||
)}
|
||||
>
|
||||
<Logo className="h-7 shrink-0" />
|
||||
{displayName ? (
|
||||
<div className="flex flex-col items-start justify-center ml-2 min-w-0">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
{userName}
|
||||
</p>
|
||||
<p className="text-white font-bold text-xl leading-none -mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-2 font-medium text-white/90">supermemory</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-white/35 shrink-0" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className="text-white/50 font-medium shrink-0">Setup</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3 z-10">
|
||||
{!isOrgLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className={cn(
|
||||
"text-sm text-white/40 hover:text-white/70 transition-colors cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Skip Onboarding
|
||||
</button>
|
||||
)}
|
||||
{user && <UserProfileMenu avatarClassName="border-border" />}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { CHROME_EXTENSION_URL } from "@repo/lib/constants"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
const integrationCards = [
|
||||
{
|
||||
title: "Capture",
|
||||
description: "Add the Chrome extension for one-click saves",
|
||||
icon: (
|
||||
<div className="rounded-full flex items-center justify-center">
|
||||
<img
|
||||
src="/onboarding/chrome.png"
|
||||
alt="Chrome"
|
||||
className="w-20 h-auto"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Connect to AI",
|
||||
description: "Set up once and use your memory in Cursor, Claude, etc",
|
||||
icon: (
|
||||
<div className="rounded flex items-center justify-center">
|
||||
<img src="/onboarding/mcp.png" alt="MCP" className="size-28 h-auto" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Connect",
|
||||
description: "Link Notion, Google Drive, or OneDrive to import your docs",
|
||||
icon: (
|
||||
<div className="rounded flex items-center justify-center">
|
||||
<img
|
||||
src="/onboarding/connectors.png"
|
||||
alt="Connectors"
|
||||
className="w-20 h-auto"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Import",
|
||||
description:
|
||||
"Bring in X/Twitter bookmarks, and turn them into useful memories",
|
||||
icon: (
|
||||
<div className="rounded flex items-center justify-center">
|
||||
<img src="/onboarding/x.png" alt="X" className="size-14" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function IntegrationsStep() {
|
||||
const router = useRouter()
|
||||
const [selectedCard, setSelectedCard] = useState<string | null>(null)
|
||||
const { markOrgOnboarded } = useOrgOnboarding()
|
||||
|
||||
const handleContinue = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
if (selectedCard === "Connect to AI") {
|
||||
return <MCPDetailView onBack={() => setSelectedCard(null)} />
|
||||
}
|
||||
if (selectedCard === "Import") {
|
||||
return <XBookmarksDetailView onBack={() => setSelectedCard(null)} />
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<div className="text-center mb-6 flex flex-col items-center justify-center space-y-2">
|
||||
<h1 className="text-white text-[32px] font-medium">
|
||||
Build your personal memory
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
"text-white text-sm opacity-60 max-w-xs",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Your supermemory comes alive when you <br /> capture and connect
|
||||
what's important
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 max-w-lg w-full mb-12">
|
||||
{integrationCards.map((card) => {
|
||||
const isClickable =
|
||||
card.title === "Connect to AI" ||
|
||||
card.title === "Capture" ||
|
||||
card.title === "Import"
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<button
|
||||
key={card.title}
|
||||
type="button"
|
||||
className={cn(
|
||||
"bg-[#080B0F] relative rounded-lg p-3 hover:border-[#3374FF] hover:border-[0.1px] transition-colors duration-300 border-[0.1px] border-[#0D121A] cursor-pointer text-left w-full hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:175%_auto] hover:bg-[center_top_2rem] hover:bg-no-repeat",
|
||||
"hover:border-b-0 border-b-0",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (card.title === "Capture") {
|
||||
analytics.onboardingChromeExtensionClicked({
|
||||
source: "onboarding",
|
||||
})
|
||||
window.open(CHROME_EXTENSION_URL, "_blank")
|
||||
} else {
|
||||
analytics.onboardingIntegrationClicked({
|
||||
integration: card.title,
|
||||
})
|
||||
if (card.title === "Connect to AI") {
|
||||
analytics.onboardingMcpDetailOpened()
|
||||
} else if (card.title === "Import") {
|
||||
analytics.onboardingXBookmarksDetailOpened()
|
||||
}
|
||||
setSelectedCard(card.title)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 mt-10">
|
||||
<h3 className="text-white text-sm font-medium">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-xs leading-relaxed",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0">{card.icon}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.title}
|
||||
className={cn(
|
||||
"bg-[#080B0F] relative rounded-lg p-3 hover:border-[#3374FF] hover:border-[0.1px] transition-colors duration-300 border-[0.1px] border-[#0D121A] hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:175%_auto] hover:bg-[center_top_2rem] hover:bg-no-repeat",
|
||||
"hover:border-b-0 border-b-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 mt-10">
|
||||
<h3 className="text-white text-sm font-medium">{card.title}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-xs leading-relaxed",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0">{card.icon}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end w-full max-w-4xl">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
|
||||
onClick={handleContinue}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { motion, type Variants } from "motion/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ProfileStep } from "./profile-step"
|
||||
import { continueVariants, contentVariants } from "@/lib/variants"
|
||||
|
||||
type OnboardingView = "continue" | "features" | "memories"
|
||||
|
||||
interface OnboardingContentStepProps {
|
||||
currentView?: OnboardingView
|
||||
onSubmit?: (data: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function OnboardingContentStep({
|
||||
currentView = "continue",
|
||||
onSubmit,
|
||||
}: OnboardingContentStepProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/old/onboarding/welcome?step=features")
|
||||
}
|
||||
|
||||
const handleAddMemories = () => {
|
||||
router.push("/old/onboarding/welcome?step=memories")
|
||||
}
|
||||
|
||||
const isContinue = currentView === "continue"
|
||||
const isFeatures = currentView === "features"
|
||||
const isMemories = currentView === "memories"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
className="text-center relative"
|
||||
>
|
||||
{/* Continue content */}
|
||||
<motion.div
|
||||
variants={continueVariants}
|
||||
animate={isContinue ? "visible" : "hidden"}
|
||||
initial="visible"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center max-w-88",
|
||||
!isContinue && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8A8A8A] text-sm mb-6 max-w-sm",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
I'm built with Supermemory's super fast memory API,
|
||||
<br /> so you never have to worry about forgetting <br /> what matters
|
||||
across your AI apps.
|
||||
</p>
|
||||
<Button
|
||||
variant="onboarding"
|
||||
onClick={handleContinue}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
width: "147px",
|
||||
}}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Features content */}
|
||||
<motion.div
|
||||
variants={contentVariants}
|
||||
animate={isFeatures ? "visible" : "hiddenDown"}
|
||||
initial="hiddenDown"
|
||||
className={cn(
|
||||
"space-y-6 max-w-88",
|
||||
!isFeatures && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium leading-[110%]">
|
||||
What I can do for you
|
||||
</h2>
|
||||
|
||||
<div className={cn("space-y-4 mb-[24px] mx-4", dmSansClassName())}>
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/human-brain.png"
|
||||
alt="Brain icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Remember every context</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I keep track of what you've saved and shared with your
|
||||
supermemory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/search.png"
|
||||
alt="Search icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Find when you need it</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I surface the right memories inside <br /> your supermemory,
|
||||
superfast.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/plant.png"
|
||||
alt="Growth icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">
|
||||
Grow with your supermemory
|
||||
</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I learn and personalize over time, so every interaction feels
|
||||
natural.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="onboarding"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
onClick={handleAddMemories}
|
||||
>
|
||||
Add memories →
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Memories/Profile content */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
!isMemories && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{onSubmit && (
|
||||
<motion.div
|
||||
variants={contentVariants}
|
||||
animate={isMemories ? "visible" : "hiddenDown"}
|
||||
initial="hiddenDown"
|
||||
>
|
||||
<ProfileStep onSubmit={onSubmit} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
|
||||
interface GreetingStepProps {
|
||||
name: string
|
||||
}
|
||||
|
||||
export function GreetingStep({ name }: GreetingStepProps) {
|
||||
const userName = name ? `${name.split(" ")[0]}` : ""
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
layout
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium mb-2">
|
||||
Hi {userName}, I'm Nova
|
||||
</h2>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { LabeledInput } from "@ui/input/labeled-input"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
||||
interface InputStepProps {
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
handleSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export function InputStep({
|
||||
name,
|
||||
setName,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
}: InputStepProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"text-center min-w-[250px] flex flex-col",
|
||||
isSubmitting && "pointer-events-none",
|
||||
)}
|
||||
style={{ gap: "24px" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.3, ease: "easeOut" } }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||
layout
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium leading-[110%]">
|
||||
What should I call you?
|
||||
</h2>
|
||||
<div className="flex items-center w-full relative">
|
||||
<LabeledInput
|
||||
inputType="text"
|
||||
inputPlaceholder="your name"
|
||||
className="w-full flex-1"
|
||||
inputProps={{
|
||||
defaultValue: name,
|
||||
disabled: isSubmitting,
|
||||
onKeyDown: (e) => {
|
||||
if (e.key !== "Enter") return
|
||||
e.preventDefault()
|
||||
if (isSubmitting) return
|
||||
handleSubmit()
|
||||
},
|
||||
className: "!text-white placeholder:!text-[#525966] !h-[40px] pl-4",
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (isSubmitting) return
|
||||
setName((e.target as HTMLInputElement).value)
|
||||
}}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(0deg, rgba(91, 126, 245, 0.04) 0%, rgba(91, 126, 245, 0.04) 100%)",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
className={`rounded-[8px] w-8 h-8 p-2 absolute right-1 border-[0.5px] border-[#161F2C] hover:cursor-pointer hover:scale-[0.95] active:scale-[0.95] transition-transform ${
|
||||
isSubmitting ? "scale-[0.90]" : ""
|
||||
}`}
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="9"
|
||||
viewBox="0 0 12 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Next</title>
|
||||
<path
|
||||
d="M8.05099 9.60156L6.93234 8.49987L9.00014 6.44902L9.62726 6.04224L9.54251 5.788L8.79675 5.90665H0.0170898V4.31343H8.79675L9.54251 4.43207L9.62726 4.17783L9.00014 3.77105L6.93234 1.72021L8.05099 0.601562L11.9832 4.53377V5.68631L8.05099 9.60156Z"
|
||||
fill="#FAFAFA"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
parseXHandle,
|
||||
parseLinkedInHandle,
|
||||
toXProfileUrl,
|
||||
toLinkedInProfileUrl,
|
||||
normalizeUrl,
|
||||
} from "@/lib/url-helpers"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
interface ProfileStepProps {
|
||||
onSubmit: (data: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
type ValidationError = {
|
||||
twitter: string | null
|
||||
linkedin: string | null
|
||||
}
|
||||
|
||||
export function ProfileStep({ onSubmit }: ProfileStepProps) {
|
||||
const router = useRouter()
|
||||
const [otherLinks, setOtherLinks] = useState([""])
|
||||
const [twitterHandle, setTwitterHandle] = useState("")
|
||||
const [linkedinProfile, setLinkedinProfile] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [isSubmitting] = useState(false)
|
||||
const [errors, setErrors] = useState<ValidationError>({
|
||||
twitter: null,
|
||||
linkedin: null,
|
||||
})
|
||||
|
||||
const addOtherLink = () => {
|
||||
if (otherLinks.length < 3) {
|
||||
setOtherLinks([...otherLinks, ""])
|
||||
}
|
||||
}
|
||||
|
||||
const updateOtherLink = (index: number, value: string) => {
|
||||
const updated = [...otherLinks]
|
||||
updated[index] = value
|
||||
setOtherLinks(updated)
|
||||
}
|
||||
|
||||
const validateTwitterHandle = (handle: string): string | null => {
|
||||
if (!handle.trim()) return null
|
||||
|
||||
// Basic validation: handle should be alphanumeric, underscore, or hyphen
|
||||
// X/Twitter handles can contain letters, numbers, and underscores, max 15 chars
|
||||
const handlePattern = /^[a-zA-Z0-9_]{1,15}$/
|
||||
if (!handlePattern.test(handle.trim())) {
|
||||
return "Enter your handle or profile link"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const validateLinkedInHandle = (handle: string): string | null => {
|
||||
if (!handle.trim()) return null
|
||||
|
||||
// Basic validation: LinkedIn handles are typically alphanumeric with hyphens
|
||||
// They can be quite long, so we'll be lenient
|
||||
const handlePattern = /^[a-zA-Z0-9-]+$/
|
||||
if (!handlePattern.test(handle.trim())) {
|
||||
return "Enter your handle or profile link"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleTwitterChange = (value: string) => {
|
||||
setTwitterHandle(value)
|
||||
setErrors((prev) => ({ ...prev, twitter: null }))
|
||||
}
|
||||
|
||||
const handleTwitterBlur = () => {
|
||||
if (!twitterHandle.trim()) return
|
||||
const parsed = parseXHandle(twitterHandle)
|
||||
setTwitterHandle(parsed)
|
||||
const error = validateTwitterHandle(parsed)
|
||||
setErrors((prev) => ({ ...prev, twitter: error }))
|
||||
}
|
||||
|
||||
const handleLinkedInChange = (value: string) => {
|
||||
setLinkedinProfile(value)
|
||||
setErrors((prev) => ({ ...prev, linkedin: null }))
|
||||
}
|
||||
|
||||
const handleLinkedInBlur = () => {
|
||||
if (!linkedinProfile.trim()) return
|
||||
const parsed = parseLinkedInHandle(linkedinProfile)
|
||||
setLinkedinProfile(parsed)
|
||||
const error = validateLinkedInHandle(parsed)
|
||||
setErrors((prev) => ({ ...prev, linkedin: error }))
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
|
||||
className="text-center w-full "
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium mb-4 mt-[-36px]">
|
||||
Let's add your memories
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 max-w-[329px] mx-auto overflow-visible gap-4">
|
||||
<div className="text-left gap-[6px] flex flex-col" id="x-twitter-field">
|
||||
<label
|
||||
htmlFor="twitter-handle"
|
||||
className="text-white text-sm font-medium block pl-2"
|
||||
>
|
||||
X/Twitter
|
||||
</label>
|
||||
<input
|
||||
id="twitter-handle"
|
||||
type="text"
|
||||
placeholder="x.com/handle or @handle"
|
||||
value={twitterHandle}
|
||||
onChange={(e) => handleTwitterChange(e.target.value)}
|
||||
onBlur={handleTwitterBlur}
|
||||
className={`w-full px-4 py-2 bg-[#070E1B] border rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px] ${
|
||||
errors.twitter
|
||||
? "border-[#52596633] bg-[#290F0A]"
|
||||
: "border-onboarding/20"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-left gap-[6px] flex flex-col" id="linkedin-field">
|
||||
<label
|
||||
htmlFor="linkedin-profile"
|
||||
className="text-white text-sm font-medium block pl-2"
|
||||
>
|
||||
LinkedIn
|
||||
</label>
|
||||
<input
|
||||
id="linkedin-profile"
|
||||
type="text"
|
||||
placeholder="linkedin.com/in/username or username"
|
||||
value={linkedinProfile}
|
||||
onChange={(e) => handleLinkedInChange(e.target.value)}
|
||||
onBlur={handleLinkedInBlur}
|
||||
className={`w-full px-4 py-2 bg-[#070E1B] border rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px] ${
|
||||
errors.linkedin
|
||||
? "border-[#52596633] bg-[#290F0A]"
|
||||
: "border-onboarding/20"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-left gap-[6px] flex flex-col"
|
||||
id="other-links-field"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="other-links"
|
||||
className="text-white text-sm font-medium pl-2"
|
||||
>
|
||||
Other links
|
||||
</label>
|
||||
<span className="text-onboarding text-[10px]">Upto 3</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{otherLinks.map((link, index) => (
|
||||
<div
|
||||
key={`other-link-${index}`}
|
||||
className="flex items-center relative"
|
||||
>
|
||||
<input
|
||||
id={`other-links-${index}`}
|
||||
type="text"
|
||||
placeholder="Add your website, GitHub, Notion..."
|
||||
value={link}
|
||||
onChange={(e) => updateOtherLink(index, e.target.value)}
|
||||
className="flex-1 px-4 py-2 bg-[#070E1B] border border-onboarding/20 rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px]"
|
||||
/>
|
||||
{index === otherLinks.length - 1 && otherLinks.length < 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
addOtherLink()
|
||||
}}
|
||||
className="size-8 m-1 absolute right-0 top-0 bg-black border border-[#161F2C] rounded-lg flex items-center justify-center text-white hover:bg-[#161F2C] transition-colors text-xl"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-left gap-[6px] flex flex-col"
|
||||
id="description-field"
|
||||
>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="text-white text-sm font-medium block pl-2"
|
||||
>
|
||||
What do you do? What do you like?
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
placeholder="Tell me the basics in your words. A few lines about your work, interests, etc."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 bg-[#070E1B] border border-onboarding/20 rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors min-h-16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
transition={{ duration: 1, ease: "easeOut", delay: 1 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
className="mt-[24px] pb-30"
|
||||
>
|
||||
<Button
|
||||
variant="onboarding"
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
onClick={() => {
|
||||
const formData = {
|
||||
twitter: toXProfileUrl(parseXHandle(twitterHandle)),
|
||||
linkedin: toLinkedInProfileUrl(
|
||||
parseLinkedInHandle(linkedinProfile),
|
||||
),
|
||||
description: description,
|
||||
otherLinks: otherLinks
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => normalizeUrl(l.trim())),
|
||||
}
|
||||
analytics.onboardingProfileSubmitted({
|
||||
has_twitter: !!twitterHandle.trim(),
|
||||
has_linkedin: !!linkedinProfile.trim(),
|
||||
other_links_count: otherLinks.filter((l) => l.trim()).length,
|
||||
description_length: description.trim().length,
|
||||
})
|
||||
onSubmit(formData)
|
||||
router.push("/old/onboarding/setup?step=integrations")
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Fetching..." : "Remember this →"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
|
||||
export function WelcomeStep() {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
layout
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium mb-2">Welcome to...</h2>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue