chore: update the flow of onboarding structure

This commit is contained in:
Mahesh Sanikommmu 2025-10-23 13:04:12 -07:00
parent 3e20516a13
commit aa2c1adf49
13 changed files with 537 additions and 431 deletions

View 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 })
}
}

View 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>
)
}

View file

@ -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">

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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>

View file

@ -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 →

View file

@ -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 →"}

View file

@ -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>
)
}

View file

@ -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).*)",
],
}

View file

@ -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 []
}
}

View 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
}