feat: exa inital version

This commit is contained in:
Mahesh Sanikommmu 2025-10-22 11:46:21 -07:00
parent 21d0e6391e
commit 3e20516a13
4 changed files with 363 additions and 15 deletions

View file

@ -1,8 +1,19 @@
import { motion } from "motion/react"
import { Button } from "@ui/components/button"
import Link from "next/link"
export function FeaturesStep() {
interface FeaturesStepProps {
setCurrentStep: (
step:
| "input"
| "greeting"
| "welcome"
| "username"
| "features"
| "memories",
) => void
}
export function FeaturesStep({ setCurrentStep }: FeaturesStepProps) {
return (
<motion.div
initial={{ opacity: 0, y: 40 }}
@ -83,13 +94,11 @@ export function FeaturesStep() {
>
<Button
className="rounded-xl px-6 py-3 bg-black border border-gray-800 hover:bg-gray-900 max-w-[10rem]"
asChild
onClick={() => setCurrentStep("memories")}
>
<Link href="/onboarding/setup">
<motion.button whileTap={{ scale: 0.95 }} className="w-full">
Continue setup
</motion.button>
</Link>
<motion.button whileTap={{ scale: 0.95 }} className="w-full">
Continue setup
</motion.button>
</Button>
</motion.div>
</motion.div>

View file

@ -0,0 +1,262 @@
import { motion } from "motion/react"
import { Button } from "@ui/components/button"
import { useState } from "react"
import { fetchContentFromUrls, type ExaContentResult } from "@/utils/exa-api"
export function MemoriesStep() {
const [twitterHandle, setTwitterHandle] = useState("")
const [linkedinProfile, setLinkedinProfile] = useState("")
const [otherLinks, setOtherLinks] = useState([""])
const [description, setDescription] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const addOtherLink = () => {
if (otherLinks.length < 3) {
setOtherLinks([...otherLinks, ""])
}
}
const updateOtherLink = (index: number, value: string) => {
const updated = [...otherLinks]
updated[index] = value
setOtherLinks(updated)
}
const removeOtherLink = (index: number) => {
if (otherLinks.length > 1) {
const updated = otherLinks.filter((_, i) => i !== index)
setOtherLinks(updated)
}
}
// 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 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
className="text-center max-w-[28rem] w-full"
layout
>
<h2 className="text-white text-3xl font-medium mb-4">
Let's add your memories
</h2>
<div className="space-y-6">
<div className="text-left" id="x-twitter-field">
<label
htmlFor="twitter-handle"
className="text-white text-sm font-medium mb-2 block"
>
X/Twitter
</label>
<input
id="twitter-handle"
type="text"
placeholder="x.com/yourhandle"
value={twitterHandle}
onChange={(e) => setTwitterHandle(e.target.value)}
className="w-full px-4 py-2 bg-[#070E1B] border border-[#525966]/20 rounded-xl text-white placeholder-[#8A8A8A] focus:outline-none focus:border-[#4A4A4A] transition-colors"
/>
</div>
<div className="text-left" id="linkedin-field">
<label
htmlFor="linkedin-profile"
className="text-white text-sm font-medium mb-2 block"
>
LinkedIn
</label>
<input
id="linkedin-profile"
type="text"
placeholder="linkedin.com/in/yourname"
value={linkedinProfile}
onChange={(e) => setLinkedinProfile(e.target.value)}
className="w-full px-4 py-2 bg-[#070E1B] border border-[#525966]/20 rounded-xl text-white placeholder-[#8A8A8A] focus:outline-none focus:border-[#4A4A4A] transition-colors"
/>
</div>
<div className="text-left" id="other-links-field">
<div className="flex items-center justify-between mb-2">
<label
htmlFor="other-links"
className="text-white text-sm font-medium"
>
Other links
</label>
<span className="text-[#8A8A8A] text-xs">Upto 3</span>
</div>
{otherLinks.map((link, index) => (
<div
key={`other-link-${index}-${link.length}`}
className="flex items-center space-x-2 mb-2"
>
<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-[#525966]/20 rounded-xl text-white placeholder-[#8A8A8A] focus:outline-none focus:border-[#4A4A4A] transition-colors"
/>
{otherLinks.length > 1 && (
<button
type="button"
onClick={() => removeOtherLink(index)}
className="w-10 h-10 bg-[#070E1B] border border-[#525966]/20 rounded-xl flex items-center justify-center text-white hover:bg-[#2A2A2A] transition-colors"
>
×
</button>
)}
{index === otherLinks.length - 1 && otherLinks.length < 3 && (
<button
type="button"
onClick={addOtherLink}
className="w-10 h-10 bg-[#070E1B] border border-[#525966]/20 rounded-xl flex items-center justify-center text-white hover:bg-[#2A2A2A] transition-colors"
>
+
</button>
)}
</div>
))}
</div>
<div className="text-left" id="description-field">
<label
htmlFor="description"
className="text-white text-sm font-medium mb-2 block"
>
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={4}
className="w-full px-4 py-2 bg-[#070E1B] border border-[#525966]/20 rounded-xl text-white placeholder-[#8A8A8A] focus:outline-none focus:border-[#4A4A4A] transition-colors resize-none"
/>
</div>
</div>
<motion.div
animate={{
opacity: 1,
y: 0,
}}
transition={{ duration: 1, ease: "easeOut", delay: 1 }}
initial={{ opacity: 0, y: 10 }}
className="mt-8"
>
<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)
}
}}
>
{isSubmitting ? "Fetching..." : "Remember this →"}
</Button>
</motion.div>
</motion.div>
)
}

View file

@ -9,12 +9,19 @@ 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"
type OnboardStep =
| "input"
| "greeting"
| "welcome"
| "username"
| "features"
| "memories"
function UserSupermemory({ name }: { name: string }) {
return (
@ -94,10 +101,13 @@ export default function OnboardPage() {
<motion.div
className={cn(
"relative z-10 flex flex-col items-center justify-center",
currentStep === "features" ? "mt-0" : "mt-16",
currentStep === "features" || currentStep === "memories"
? "mt-0"
: "mt-16",
)}
animate={{
gap: currentStep === "features" ? 0 : 16,
gap:
currentStep === "features" || currentStep === "memories" ? 0 : 16,
}}
transition={{
duration: 0.6,
@ -109,8 +119,16 @@ export default function OnboardPage() {
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: currentStep === "features" ? 0.7 : 1,
padding: currentStep === "features" ? 0 : 32,
scale:
currentStep === "features"
? 0.7
: currentStep === "memories"
? 0.4
: 1,
padding:
currentStep === "features" || currentStep === "memories"
? 0
: 32,
}}
transition={{
duration: 0.8,
@ -120,7 +138,7 @@ export default function OnboardPage() {
layout
className="relative"
>
<NovaOrb size={300} />
<NovaOrb size={currentStep === "memories" ? 150 : 300} />
{currentStep === "username" && <UserSupermemory name={name} />}
</motion.div>
@ -147,7 +165,13 @@ export default function OnboardPage() {
setCurrentStep={(step) => setCurrentStep(step as OnboardStep)}
/>
)}
{currentStep === "features" && <FeaturesStep key="features" />}
{currentStep === "features" && (
<FeaturesStep
key="features"
setCurrentStep={(step) => setCurrentStep(step as OnboardStep)}
/>
)}
{currentStep === "memories" && <MemoriesStep key="memories" />}
</AnimatePresence>
</motion.div>
</div>

53
apps/web/utils/exa-api.ts Normal file
View file

@ -0,0 +1,53 @@
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 []
}
}