supermemory/apps/web/components/onboarding/welcome/profile-step.tsx
2026-02-25 18:28:13 -08:00

267 lines
7.9 KiB
TypeScript

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("/onboarding/setup?step=relatable")
}}
>
{isSubmitting ? "Fetching..." : "Remember this →"}
</Button>
</motion.div>
</motion.div>
)
}