mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-09 03:18:04 +00:00
chore: update the flow of onboarding structure
This commit is contained in:
parent
3e20516a13
commit
aa2c1adf49
13 changed files with 537 additions and 431 deletions
61
apps/web/app/api/exa/fetch-content/route.ts
Normal file
61
apps/web/app/api/exa/fetch-content/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
export interface ExaContentResult {
|
||||
url: string
|
||||
text: string
|
||||
title: string
|
||||
author?: string
|
||||
}
|
||||
|
||||
interface ExaApiResponse {
|
||||
results: ExaContentResult[]
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { urls } = await request.json()
|
||||
|
||||
if (!Array.isArray(urls) || urls.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "Invalid input: urls must be a non-empty array" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!urls.every((url) => typeof url === "string" && url.trim())) {
|
||||
return Response.json(
|
||||
{ error: "Invalid input: all urls must be non-empty strings" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.exa.ai/contents", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": process.env.EXA_API_KEY ?? "",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
urls,
|
||||
text: true,
|
||||
livecrawl: "fallback",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"Exa API request failed:",
|
||||
response.status,
|
||||
response.statusText,
|
||||
)
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch content from Exa API" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const data: ExaApiResponse = await response.json()
|
||||
return Response.json({ results: data.results })
|
||||
} catch (error) {
|
||||
console.error("Exa API request error:", error)
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
252
apps/web/app/onboarding/page.tsx
Normal file
252
apps/web/app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
import { InputStep } from "./welcome/input-step"
|
||||
import { GreetingStep } from "./welcome/greeting-step"
|
||||
import { WelcomeStep } from "./welcome/welcome-step"
|
||||
import { ContinueStep } from "./welcome/continue-step"
|
||||
import { FeaturesStep } from "./welcome/features-step"
|
||||
import { MemoriesStep } from "./welcome/memories-step"
|
||||
import { RelatableQuestion } from "./setup/relatable-question"
|
||||
import { IntegrationsStep } from "./setup/integrations-step"
|
||||
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { SetupHeader } from "./setup/header"
|
||||
import { ChatSidebar } from "./setup/chat-sidebar"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { AnimatedGradientBackground } from "./setup/page"
|
||||
|
||||
function UserSupermemory({ name }: { name: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
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-xl leading-none">{name}'s</p>
|
||||
<p className="text-white font-bold text-4xl leading-none -mt-2">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const { user } = useAuth()
|
||||
|
||||
const flow = searchParams.get("flow") as "welcome" | "setup" | null
|
||||
const step = searchParams.get("step") as string | null
|
||||
|
||||
const [name, setName] = useState(user?.name ?? "")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [memoryFormData, setMemoryFormData] = useState<{
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
} | null>(null)
|
||||
|
||||
const currentFlow = flow || "welcome"
|
||||
const currentStep = step || "input"
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.name) {
|
||||
setName(user.name)
|
||||
localStorage.setItem("username", user.name)
|
||||
}
|
||||
}, [user?.name])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFlow !== "welcome") return
|
||||
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
|
||||
switch (currentStep) {
|
||||
case "greeting":
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
// Auto-advance to welcome step
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/onboarding?flow=welcome&step=welcome",
|
||||
)
|
||||
}, 2000),
|
||||
)
|
||||
break
|
||||
case "welcome":
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
// Auto-advance to username step
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/onboarding?flow=welcome&step=username",
|
||||
)
|
||||
}, 2000),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return () => {
|
||||
timers.forEach(clearTimeout)
|
||||
}
|
||||
}, [currentStep, currentFlow])
|
||||
|
||||
const handleSubmit = () => {
|
||||
localStorage.setItem("username", name)
|
||||
if (name.trim()) {
|
||||
setIsSubmitting(true)
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/onboarding?flow=welcome&step=greeting",
|
||||
)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderWelcomeStep = () => {
|
||||
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":
|
||||
return <ContinueStep key="username" />
|
||||
case "features":
|
||||
return <FeaturesStep key="features" />
|
||||
case "memories":
|
||||
return <MemoriesStep key="memories" onSubmit={setMemoryFormData} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const renderSetupStep = () => {
|
||||
switch (currentStep) {
|
||||
case "relatable":
|
||||
return <RelatableQuestion key="relatable" />
|
||||
case "integrations":
|
||||
return <IntegrationsStep key="integrations" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isWelcomeFlow = currentFlow === "welcome"
|
||||
const isSetupFlow = currentFlow === "setup"
|
||||
|
||||
const minimizeNovaOrb =
|
||||
isWelcomeFlow && ["features", "memories"].includes(currentStep)
|
||||
const novaSize = currentStep === "memories" ? 150 : 300
|
||||
|
||||
const showUserSupermemory = isWelcomeFlow && currentStep === "username"
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
{isWelcomeFlow && (
|
||||
<InitialHeader
|
||||
showUserSupermemory={currentStep === "features"}
|
||||
name={name}
|
||||
/>
|
||||
)}
|
||||
{isSetupFlow && <SetupHeader />}
|
||||
|
||||
<AnimatedGradientBackground />
|
||||
|
||||
{isWelcomeFlow && (
|
||||
<div className="flex flex-col items-center justify-start h-[calc(100vh-86px)] relative">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative z-10 flex flex-col items-center justify-center",
|
||||
minimizeNovaOrb ? "mt-0" : "mt-16",
|
||||
)}
|
||||
animate={{
|
||||
gap: minimizeNovaOrb ? 0 : 16,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
layout
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale:
|
||||
currentStep === "features"
|
||||
? 0.7
|
||||
: currentStep === "memories"
|
||||
? 0.4
|
||||
: 1,
|
||||
padding: minimizeNovaOrb ? 0 : 32,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
delay: 0.2,
|
||||
}}
|
||||
layout
|
||||
className="relative"
|
||||
>
|
||||
<NovaOrb size={novaSize} />
|
||||
{showUserSupermemory && <UserSupermemory name={name} />}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">{renderWelcomeStep()}</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSetupFlow && (
|
||||
<main className="relative min-h-screen">
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-row h-[calc(100vh-90px)] relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-start p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{renderSetupStep()}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar formData={memoryFormData} />
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { PanelRightCloseIcon, SendIcon } from "lucide-react"
|
||||
import { collectValidUrls } from "@/utils/url-helpers"
|
||||
|
||||
export function ChatSidebar() {
|
||||
interface ChatSidebarProps {
|
||||
formData: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
export function ChatSidebar({ formData }: ChatSidebarProps) {
|
||||
const [message, setMessage] = useState("")
|
||||
const [isChatOpen, setIsChatOpen] = useState(true)
|
||||
const [messages, setMessages] = useState<
|
||||
{
|
||||
url: string
|
||||
title?: string
|
||||
description: string
|
||||
fullContent: string
|
||||
}[]
|
||||
>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSend = () => {
|
||||
console.log("Message:", message)
|
||||
|
|
@ -26,6 +45,53 @@ export function ChatSidebar() {
|
|||
setIsChatOpen(!isChatOpen)
|
||||
}
|
||||
|
||||
// Helper function to truncate text to 2 lines
|
||||
const truncateText = (text: string, maxLength = 120) => {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.slice(0, maxLength).trim() + "..."
|
||||
}
|
||||
|
||||
// Fetch content when formData is available
|
||||
useEffect(() => {
|
||||
if (!formData) return
|
||||
|
||||
const fetchContent = async () => {
|
||||
setIsLoading(true)
|
||||
const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
|
||||
|
||||
if (urls.length > 0) {
|
||||
try {
|
||||
const response = await fetch("/api/exa/fetch-content", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ urls }),
|
||||
})
|
||||
const { results } = await response.json()
|
||||
|
||||
const newMessages = results.map(
|
||||
(result: {
|
||||
url: string
|
||||
title?: string
|
||||
text?: string
|
||||
description?: string
|
||||
}) => ({
|
||||
url: result.url,
|
||||
title: result.title,
|
||||
description: result.text || result.description || "",
|
||||
fullContent: result.text || result.description || "",
|
||||
}),
|
||||
)
|
||||
setMessages(newMessages)
|
||||
} catch (error) {
|
||||
console.warn("Error fetching content:", error)
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
fetchContent()
|
||||
}, [formData])
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{!isChatOpen ? (
|
||||
|
|
@ -64,19 +130,41 @@ export function ChatSidebar() {
|
|||
<PanelRightCloseIcon className="size-4" />
|
||||
Close chat
|
||||
</motion.button>
|
||||
<div className="flex-1 flex items-end justify-start px-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<motion.div
|
||||
className="flex items-center gap-2 text-foreground/70"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<div className="flex-1 flex flex-col justify-end px-4 py-6 space-y-3">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex items-center gap-2 text-foreground/70">
|
||||
<NovaOrb size={24} />
|
||||
|
||||
<span className="text-sm">Waiting for your input</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="text-sm text-foreground/50">
|
||||
Fetching your memories...
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={`message-${i}-${msg.url}`}
|
||||
className="bg-[#0D121A] rounded-lg p-4 space-y-2"
|
||||
>
|
||||
{msg.title && (
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{msg.title}
|
||||
</h3>
|
||||
)}
|
||||
<a
|
||||
href={msg.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline break-all"
|
||||
>
|
||||
{msg.url}
|
||||
</a>
|
||||
<p className="text-sm text-foreground/70 line-clamp-2">
|
||||
{truncateText(msg.description)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from "react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { MCPDetailView } from "@/components/mcp-detail-view"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
const integrationCards = [
|
||||
{
|
||||
|
|
@ -52,11 +53,8 @@ const integrationCards = [
|
|||
},
|
||||
]
|
||||
|
||||
interface IntegrationsStepProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function IntegrationsStep({ onBack }: IntegrationsStepProps) {
|
||||
export function IntegrationsStep() {
|
||||
const router = useRouter()
|
||||
const [selectedCard, setSelectedCard] = useState<string | null>(null)
|
||||
|
||||
if (selectedCard === "Connect to AI") {
|
||||
|
|
@ -120,7 +118,7 @@ export function IntegrationsStep({ onBack }: IntegrationsStepProps) {
|
|||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300"
|
||||
onClick={onBack}
|
||||
onClick={() => router.push("/onboarding?flow=setup&step=relatable")}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
"use client"
|
||||
|
||||
import { SetupHeader } from "./header"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { RelatableQuestion } from "./relatable-question"
|
||||
import { IntegrationsStep } from "./integrations-step"
|
||||
import { ChatSidebar } from "./chat-sidebar"
|
||||
import { useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
export function AnimatedGradientBackground() {
|
||||
return (
|
||||
|
|
@ -55,40 +48,3 @@ export function AnimatedGradientBackground() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const [currentStep, setCurrentStep] = useState<"relatable" | "integrations">(
|
||||
"relatable",
|
||||
)
|
||||
|
||||
const handleContinueOrSkip = () => {
|
||||
setCurrentStep("integrations")
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep("relatable")
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen bg-[#080A0D]">
|
||||
<AnimatedGradientBackground />
|
||||
<div className="relative z-10">
|
||||
<SetupHeader />
|
||||
<div className="flex flex-row h-[calc(100vh-90px)] relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-start p-8">
|
||||
{currentStep === "relatable" && (
|
||||
<RelatableQuestion onContinueOrSkip={handleContinueOrSkip} />
|
||||
)}
|
||||
{currentStep === "integrations" && (
|
||||
<IntegrationsStep onBack={handleBack} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar />
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
const relatableOptions = [
|
||||
{
|
||||
|
|
@ -27,15 +28,14 @@ const relatableOptions = [
|
|||
},
|
||||
]
|
||||
|
||||
interface RelatableQuestionProps {
|
||||
onContinueOrSkip: () => void
|
||||
}
|
||||
|
||||
export function RelatableQuestion({
|
||||
onContinueOrSkip,
|
||||
}: RelatableQuestionProps) {
|
||||
export function RelatableQuestion() {
|
||||
const router = useRouter()
|
||||
const [selectedOptions, setSelectedOptions] = useState<number[]>([])
|
||||
|
||||
const handleContinueOrSkip = () => {
|
||||
router.push("/onboarding?flow=setup&step=integrations")
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center h-full"
|
||||
|
|
@ -86,7 +86,7 @@ export function RelatableQuestion({
|
|||
>
|
||||
<div className="flex flex-col items-start justify-between space-y-8">
|
||||
<span className="text-2xl">{option.emoji}</span>
|
||||
<p className="text-white text-xs leading-relaxed max-w-[115px] align-bottom text-left">
|
||||
<p className="text-white text-xs leading-relaxed max-w-[130px] align-bottom text-left">
|
||||
{option.text}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -99,7 +99,7 @@ export function RelatableQuestion({
|
|||
className="text-foreground font-medium"
|
||||
variant="link"
|
||||
size="lg"
|
||||
onClick={onContinueOrSkip}
|
||||
onClick={handleContinueOrSkip}
|
||||
>
|
||||
{selectedOptions.length === 0 ? "Skip for now →" : "Continue →"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { Button } from "@ui/components/button"
|
||||
import { motion } from "motion/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface ContinueStepProps {
|
||||
setCurrentStep: (
|
||||
step: "input" | "greeting" | "welcome" | "username" | "features",
|
||||
) => void
|
||||
}
|
||||
export function ContinueStep() {
|
||||
const router = useRouter()
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/onboarding?flow=welcome&step=features")
|
||||
}
|
||||
|
||||
export function ContinueStep({ setCurrentStep }: ContinueStepProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
|
|
@ -23,7 +24,7 @@ export function ContinueStep({ setCurrentStep }: ContinueStepProps) {
|
|||
</p>
|
||||
<Button
|
||||
className="rounded-xl px-6 py-3 bg-black border border-gray-800 hover:bg-gray-900"
|
||||
onClick={() => setCurrentStep("features")}
|
||||
onClick={handleContinue}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
import { motion } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface FeaturesStepProps {
|
||||
setCurrentStep: (
|
||||
step:
|
||||
| "input"
|
||||
| "greeting"
|
||||
| "welcome"
|
||||
| "username"
|
||||
| "features"
|
||||
| "memories",
|
||||
) => void
|
||||
}
|
||||
export function FeaturesStep() {
|
||||
const router = useRouter()
|
||||
|
||||
export function FeaturesStep({ setCurrentStep }: FeaturesStepProps) {
|
||||
const handleContinue = () => {
|
||||
router.push("/onboarding?flow=welcome&step=memories")
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
|
|
@ -94,7 +88,7 @@ export function FeaturesStep({ setCurrentStep }: FeaturesStepProps) {
|
|||
>
|
||||
<Button
|
||||
className="rounded-xl px-6 py-3 bg-black border border-gray-800 hover:bg-gray-900 max-w-[10rem]"
|
||||
onClick={() => setCurrentStep("memories")}
|
||||
onClick={handleContinue}
|
||||
>
|
||||
<motion.button whileTap={{ scale: 0.95 }} className="w-full">
|
||||
Continue setup →
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import { motion } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useState } from "react"
|
||||
import { fetchContentFromUrls, type ExaContentResult } from "@/utils/exa-api"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export function MemoriesStep() {
|
||||
interface MemoriesStepProps {
|
||||
onSubmit: (data: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
export function MemoriesStep({ onSubmit }: MemoriesStepProps) {
|
||||
const router = useRouter()
|
||||
const [otherLinks, setOtherLinks] = useState([""])
|
||||
const [twitterHandle, setTwitterHandle] = useState("")
|
||||
const [linkedinProfile, setLinkedinProfile] = useState("")
|
||||
const [otherLinks, setOtherLinks] = useState([""])
|
||||
const [description, setDescription] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmitting] = useState(false)
|
||||
|
||||
const addOtherLink = () => {
|
||||
if (otherLinks.length < 3) {
|
||||
|
|
@ -29,66 +39,6 @@ export function MemoriesStep() {
|
|||
}
|
||||
}
|
||||
|
||||
// URL validation and filtering helpers
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeUrl = (url: string): string => {
|
||||
if (!url.trim()) return ""
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return url
|
||||
}
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
const isTwitterUrl = (url: string): boolean => {
|
||||
const normalizedUrl = url.toLowerCase()
|
||||
return (
|
||||
normalizedUrl.includes("twitter.com") || normalizedUrl.includes("x.com")
|
||||
)
|
||||
}
|
||||
|
||||
const isLinkedInProfileUrl = (url: string): boolean => {
|
||||
const normalizedUrl = url.toLowerCase()
|
||||
return (
|
||||
normalizedUrl.includes("linkedin.com/in/") &&
|
||||
!normalizedUrl.includes("linkedin.com/company/")
|
||||
)
|
||||
}
|
||||
|
||||
const collectValidUrls = (): string[] => {
|
||||
const urls: string[] = []
|
||||
|
||||
// Add LinkedIn profile if valid and not empty
|
||||
if (linkedinProfile.trim()) {
|
||||
const normalizedLinkedIn = normalizeUrl(linkedinProfile.trim())
|
||||
if (
|
||||
isValidUrl(normalizedLinkedIn) &&
|
||||
isLinkedInProfileUrl(normalizedLinkedIn)
|
||||
) {
|
||||
urls.push(normalizedLinkedIn)
|
||||
}
|
||||
}
|
||||
|
||||
// Add other links if valid and not empty, excluding Twitter
|
||||
otherLinks
|
||||
.filter((link) => link.trim())
|
||||
.forEach((link) => {
|
||||
const normalizedLink = normalizeUrl(link.trim())
|
||||
if (isValidUrl(normalizedLink) && !isTwitterUrl(normalizedLink)) {
|
||||
urls.push(normalizedLink)
|
||||
}
|
||||
})
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
|
|
@ -211,47 +161,15 @@ export function MemoriesStep() {
|
|||
<Button
|
||||
className="rounded-xl px-6 py-3 bg-[#070E1B] border border-[#525966]/20 hover:bg-[#2A2A2A] max-w-[12rem] text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSubmitting}
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// Collect valid URLs for fetching
|
||||
const urlsToFetch = collectValidUrls()
|
||||
|
||||
// Fetch content from URLs using Exa API
|
||||
let fetchedData: Record<string, ExaContentResult> = {}
|
||||
if (urlsToFetch.length > 0) {
|
||||
const contentResults = await fetchContentFromUrls(urlsToFetch)
|
||||
fetchedData = Object.fromEntries(
|
||||
contentResults.map((result) => [result.url, result]),
|
||||
)
|
||||
}
|
||||
|
||||
// Log complete form data including fetched content
|
||||
console.log("Form submitted:", {
|
||||
twitterHandle,
|
||||
linkedinProfile,
|
||||
otherLinks: otherLinks.filter((link) => link.trim()),
|
||||
description,
|
||||
fetchedContent: fetchedData,
|
||||
})
|
||||
|
||||
// Navigate to setup page after form submission
|
||||
window.location.href = "/onboarding/setup"
|
||||
} catch (error) {
|
||||
console.warn("Error during form submission:", error)
|
||||
// Continue with form submission even if API fails
|
||||
console.log("Form submitted (without fetched content):", {
|
||||
twitterHandle,
|
||||
linkedinProfile,
|
||||
otherLinks: otherLinks.filter((link) => link.trim()),
|
||||
description,
|
||||
fetchedContent: {},
|
||||
})
|
||||
window.location.href = "/onboarding/setup"
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
onClick={() => {
|
||||
const formData = {
|
||||
twitter: twitterHandle,
|
||||
linkedin: linkedinProfile,
|
||||
description: description,
|
||||
otherLinks: otherLinks.filter((l) => l.trim()),
|
||||
}
|
||||
onSubmit(formData)
|
||||
router.push("/onboarding?flow=setup&step=relatable")
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Fetching..." : "Remember this →"}
|
||||
|
|
|
|||
|
|
@ -1,180 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { InputStep } from "./input-step"
|
||||
import { GreetingStep } from "./greeting-step"
|
||||
import { WelcomeStep } from "./welcome-step"
|
||||
import { ContinueStep } from "./continue-step"
|
||||
import { FeaturesStep } from "./features-step"
|
||||
import { MemoriesStep } from "./memories-step"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { cn } from "@lib/utils"
|
||||
import { AnimatedGradientBackground } from "../setup/page"
|
||||
|
||||
type OnboardStep =
|
||||
| "input"
|
||||
| "greeting"
|
||||
| "welcome"
|
||||
| "username"
|
||||
| "features"
|
||||
| "memories"
|
||||
|
||||
function UserSupermemory({ name }: { name: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
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-xl leading-none">{name}'s</p>
|
||||
<p className="text-white font-bold text-4xl leading-none -mt-2">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OnboardPage() {
|
||||
const { user } = useAuth()
|
||||
const [name, setName] = useState(user?.name ?? "")
|
||||
const [currentStep, setCurrentStep] = useState<OnboardStep>("input")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
localStorage.setItem("username", name)
|
||||
if (name.trim()) {
|
||||
setIsSubmitting(true)
|
||||
setCurrentStep("greeting")
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.name) {
|
||||
setName(user.name)
|
||||
localStorage.setItem("username", user.name)
|
||||
}
|
||||
}, [user?.name])
|
||||
|
||||
useEffect(() => {
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
|
||||
switch (currentStep) {
|
||||
case "greeting":
|
||||
timers.push(setTimeout(() => setCurrentStep("welcome"), 2000))
|
||||
break
|
||||
case "welcome":
|
||||
timers.push(setTimeout(() => setCurrentStep("username"), 2000))
|
||||
break
|
||||
}
|
||||
|
||||
return () => {
|
||||
timers.forEach(clearTimeout)
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
<InitialHeader
|
||||
showUserSupermemory={currentStep === "features"}
|
||||
name={name}
|
||||
/>
|
||||
<AnimatedGradientBackground />
|
||||
<div className="flex flex-col items-center justify-start h-[calc(100vh-86px)] relative">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative z-10 flex flex-col items-center justify-center",
|
||||
currentStep === "features" || currentStep === "memories"
|
||||
? "mt-0"
|
||||
: "mt-16",
|
||||
)}
|
||||
animate={{
|
||||
gap:
|
||||
currentStep === "features" || currentStep === "memories" ? 0 : 16,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
layout
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale:
|
||||
currentStep === "features"
|
||||
? 0.7
|
||||
: currentStep === "memories"
|
||||
? 0.4
|
||||
: 1,
|
||||
padding:
|
||||
currentStep === "features" || currentStep === "memories"
|
||||
? 0
|
||||
: 32,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
delay: 0.2,
|
||||
}}
|
||||
layout
|
||||
className="relative"
|
||||
>
|
||||
<NovaOrb size={currentStep === "memories" ? 150 : 300} />
|
||||
{currentStep === "username" && <UserSupermemory name={name} />}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{currentStep === "input" && (
|
||||
<InputStep
|
||||
key="input"
|
||||
name={name}
|
||||
setName={setName}
|
||||
handleSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "greeting" && (
|
||||
<GreetingStep key="greeting" name={name} />
|
||||
)}
|
||||
|
||||
{currentStep === "welcome" && <WelcomeStep key="welcome" />}
|
||||
|
||||
{currentStep === "username" && (
|
||||
<ContinueStep
|
||||
key="username"
|
||||
setCurrentStep={(step) => setCurrentStep(step as OnboardStep)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === "features" && (
|
||||
<FeaturesStep
|
||||
key="features"
|
||||
setCurrentStep={(step) => setCurrentStep(step as OnboardStep)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === "memories" && <MemoriesStep key="memories" />}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,6 +18,18 @@ export default async function proxy(request: Request) {
|
|||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
if (!sessionCookie) {
|
||||
console.debug("[MIDDLEWARE] API route without session, returning 401")
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
console.debug("[MIDDLEWARE] API route with session, allowing access")
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// If no session cookie and not on a public path, redirect to login
|
||||
if (!sessionCookie) {
|
||||
console.debug(
|
||||
|
|
@ -54,6 +66,6 @@ export default async function proxy(request: Request) {
|
|||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|api|login|api/emails).*)",
|
||||
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails).*)",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
export interface ExaContentResult {
|
||||
url: string
|
||||
text: string
|
||||
title: string
|
||||
author?: string
|
||||
}
|
||||
|
||||
interface ExaApiResponse {
|
||||
results: ExaContentResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches content from URLs using the Exa API
|
||||
* @param urls Array of URLs to fetch content from
|
||||
* @returns Promise resolving to array of content results
|
||||
*/
|
||||
export async function fetchContentFromUrls(
|
||||
urls: string[],
|
||||
): Promise<ExaContentResult[]> {
|
||||
if (urls.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.exa.ai/contents", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": process.env.EXA_API_KEY ?? "",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
urls,
|
||||
text: true,
|
||||
livecrawl: "fallback",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
"Exa API request failed:",
|
||||
response.status,
|
||||
response.statusText,
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
const data: ExaApiResponse = await response.json()
|
||||
return data.results
|
||||
} catch (error) {
|
||||
console.warn("Exa API request error:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
59
apps/web/utils/url-helpers.ts
Normal file
59
apps/web/utils/url-helpers.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export const isValidUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeUrl = (url: string): string => {
|
||||
if (!url.trim()) return ""
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return url
|
||||
}
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
export const isTwitterUrl = (url: string): boolean => {
|
||||
const normalizedUrl = url.toLowerCase()
|
||||
return (
|
||||
normalizedUrl.includes("twitter.com") || normalizedUrl.includes("x.com")
|
||||
)
|
||||
}
|
||||
|
||||
export const isLinkedInProfileUrl = (url: string): boolean => {
|
||||
const normalizedUrl = url.toLowerCase()
|
||||
return (
|
||||
normalizedUrl.includes("linkedin.com/in/") &&
|
||||
!normalizedUrl.includes("linkedin.com/company/")
|
||||
)
|
||||
}
|
||||
|
||||
export const collectValidUrls = (
|
||||
linkedinProfile: string,
|
||||
otherLinks: string[],
|
||||
): string[] => {
|
||||
const urls: string[] = []
|
||||
|
||||
if (linkedinProfile.trim()) {
|
||||
const normalizedLinkedIn = normalizeUrl(linkedinProfile.trim())
|
||||
if (
|
||||
isValidUrl(normalizedLinkedIn) &&
|
||||
isLinkedInProfileUrl(normalizedLinkedIn)
|
||||
) {
|
||||
urls.push(normalizedLinkedIn)
|
||||
}
|
||||
}
|
||||
|
||||
otherLinks
|
||||
.filter((link) => link.trim())
|
||||
.forEach((link) => {
|
||||
const normalizedLink = normalizeUrl(link.trim())
|
||||
if (isValidUrl(normalizedLink) && !isTwitterUrl(normalizedLink)) {
|
||||
urls.push(normalizedLink)
|
||||
}
|
||||
})
|
||||
|
||||
return urls
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue