mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-07 02:58:11 +00:00
267 lines
7.9 KiB
TypeScript
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>
|
|
)
|
|
}
|