fix: Implemented the onboarding account lookup UX

This commit is contained in:
Ishaan Gupta 2026-05-09 20:20:41 +05:30
parent 47bd9805ef
commit ee55830481
2 changed files with 324 additions and 6 deletions

View file

@ -30,11 +30,24 @@ import {
RaycastIcon,
} from "@/components/integration-icons"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { Sparkles, ChevronLeft, ChevronRight } from "lucide-react"
import {
Sparkles,
ChevronLeft,
ChevronRight,
AlertCircle,
CheckCircle2,
Loader2,
} from "lucide-react"
import { analytics } from "@/lib/analytics"
type DetectedSource = "x" | "linkedin" | "resume" | null
type Status = "idle" | "processing" | "done" | "error"
type AccountLookupStatus = "checking" | "found" | "not_found" | "error"
type AccountLookup = {
source: "x" | "linkedin"
status: AccountLookupStatus
message: string
}
type DocStatus =
| "unknown"
| "queued"
@ -121,8 +134,13 @@ const SOURCE_ICON: Record<
}
const SOURCE_LABEL: Record<"x" | "linkedin", string> = {
x: "X profile detected — press Enter to continue",
linkedin: "LinkedIn profile detected — press Enter to continue",
x: "X profile detected - checking account",
linkedin: "LinkedIn profile detected - checking account",
}
const SOURCE_NAME: Record<"x" | "linkedin", string> = {
x: "X",
linkedin: "LinkedIn",
}
type SpotlightItem = {
@ -358,6 +376,7 @@ export default function OnboardingPage() {
const [value, setValue] = useState("")
const [detected, setDetected] = useState<DetectedSource>(null)
const [accountLookup, setAccountLookup] = useState<AccountLookup | null>(null)
const [resumeFile, setResumeFile] = useState<File | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [status, setStatus] = useState<Status>("idle")
@ -416,6 +435,87 @@ export default function OnboardingPage() {
return () => clearTimeout(t)
}, [])
useEffect(() => {
if (status !== "idle") return
const source = detected === "x" || detected === "linkedin" ? detected : null
const trimmedValue = value.trim()
if (!source || !trimmedValue) {
setAccountLookup(null)
return
}
const controller = new AbortController()
setAccountLookup({
source,
status: "checking",
message: SOURCE_LABEL[source],
})
const timeout = setTimeout(async () => {
try {
const params = new URLSearchParams({
source,
value: trimmedValue,
})
const response = await fetch(
`/api/onboarding/account-status?${params.toString()}`,
{ signal: controller.signal },
)
const data: {
found?: boolean
handle?: string
reason?: string
} = await response.json().catch(() => ({}))
if (controller.signal.aborted) return
if (response.ok && data.found === true) {
const account =
source === "x" && data.handle ? ` @${data.handle}` : ""
setAccountLookup({
source,
status: "found",
message: `${SOURCE_NAME[source]} account${account} found - press Enter to continue`,
})
return
}
if (
(response.ok && data.found === false) ||
data.reason === "invalid"
) {
setAccountLookup({
source,
status: "not_found",
message: `${SOURCE_NAME[source]} account not found. Check the link and try again.`,
})
return
}
setAccountLookup({
source,
status: "error",
message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`,
})
} catch (err) {
if (controller.signal.aborted) return
console.error(err)
setAccountLookup({
source,
status: "error",
message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`,
})
}
}, 450)
return () => {
clearTimeout(timeout)
controller.abort()
}
}, [detected, status, value])
useEffect(() => {
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
@ -597,7 +697,18 @@ export default function OnboardingPage() {
}
}
const canSubmit = detected && detected !== "resume"
const hasDetectedAccount = detected === "x" || detected === "linkedin"
const currentAccountLookup =
accountLookup?.source === detected ? accountLookup : null
const isCheckingAccount =
hasDetectedAccount &&
(!currentAccountLookup || currentAccountLookup.status === "checking")
const canSubmit = Boolean(
hasDetectedAccount &&
currentAccountLookup &&
currentAccountLookup.status !== "checking" &&
currentAccountLookup.status !== "not_found",
)
return (
// biome-ignore lint/a11y/noStaticElementInteractions: full-surface drag-and-drop for resume PDF
@ -705,6 +816,20 @@ export default function OnboardingPage() {
)}
/>
<AnimatePresence>
{isCheckingAccount && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
className="absolute right-3 flex items-center pointer-events-none"
>
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
</motion.span>
)}
</AnimatePresence>
{canSubmit && (
<motion.button
type="button"
@ -729,9 +854,35 @@ export default function OnboardingPage() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="text-xs text-[#6BB0FF] pl-1"
className={cn(
"flex items-center gap-1.5 text-xs pl-1",
currentAccountLookup?.status === "found" &&
"text-[#65D08C]",
currentAccountLookup?.status === "not_found" &&
"text-[#FF8A8A]",
currentAccountLookup?.status === "error" &&
"text-[#F0B86A]",
(!currentAccountLookup ||
currentAccountLookup.status === "checking") &&
"text-[#6BB0FF]",
)}
>
{SOURCE_LABEL[detected as "x" | "linkedin"]}
{currentAccountLookup?.status === "found" && (
<CheckCircle2 className="size-3.5 shrink-0" />
)}
{currentAccountLookup?.status === "not_found" && (
<AlertCircle className="size-3.5 shrink-0" />
)}
{currentAccountLookup?.status === "error" && (
<AlertCircle className="size-3.5 shrink-0" />
)}
{isCheckingAccount && (
<Loader2 className="size-3.5 shrink-0 animate-spin" />
)}
<span>
{currentAccountLookup?.message ??
SOURCE_LABEL[detected as "x" | "linkedin"]}
</span>
</motion.p>
)}
</AnimatePresence>

View file

@ -0,0 +1,167 @@
type AccountSource = "x" | "linkedin"
type ParsedAccount = {
handle: string
url: string
}
function parseXAccount(value: string): ParsedAccount | null {
const trimmed = value.trim()
if (!trimmed) return null
let handle = trimmed.replace(/^@/, "")
const lowerValue = handle.toLowerCase()
if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) {
try {
const url = new URL(
handle.startsWith("http://") || handle.startsWith("https://")
? handle
: `https://${handle}`,
)
handle = url.pathname.split("/").filter(Boolean)[0] ?? ""
} catch {
handle = handle.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i)?.[1] ?? ""
}
}
handle = handle.replace(/^@/, "").split(/[/?#]/)[0] ?? ""
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null
return { handle, url: `https://x.com/${handle}` }
}
function parseLinkedInAccount(value: string): ParsedAccount | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
const url = new URL(
trimmed.startsWith("http://") || trimmed.startsWith("https://")
? trimmed
: `https://${trimmed}`,
)
const match = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i)
const handle = match?.[2]
if (!handle) return null
return {
handle,
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
}
} catch {
const match = trimmed.match(/linkedin\.com\/(in|pub)\/([^/\s?#]+)/i)
const handle = match?.[2]
if (!handle) return null
return {
handle,
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
}
}
}
function parseAccount(
source: AccountSource,
value: string,
): ParsedAccount | null {
return source === "x" ? parseXAccount(value) : parseLinkedInAccount(value)
}
function looksUnavailable(source: AccountSource, html: string) {
const lowerHtml = html.toLowerCase()
if (source === "x") {
return (
lowerHtml.includes("this account doesn") ||
lowerHtml.includes("account suspended") ||
lowerHtml.includes("profile not found")
)
}
return (
lowerHtml.includes("profile not found") ||
lowerHtml.includes("page not found") ||
lowerHtml.includes("this linkedin profile is unavailable")
)
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const source = searchParams.get("source")
const value = searchParams.get("value")
if (source !== "x" && source !== "linkedin") {
return Response.json({ error: "Invalid account source" }, { status: 400 })
}
if (!value?.trim()) {
return Response.json({ error: "Missing account value" }, { status: 400 })
}
const account = parseAccount(source, value)
if (!account) {
return Response.json({ found: false, reason: "invalid" }, { status: 400 })
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 7000)
try {
const response = await fetch(account.url, {
signal: controller.signal,
redirect: "follow",
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
},
})
if (response.status === 404 || response.status === 410) {
return Response.json({
found: false,
handle: account.handle,
status: response.status,
url: account.url,
})
}
if (!response.ok) {
return Response.json(
{
error: "Unable to verify account",
handle: account.handle,
status: response.status,
url: account.url,
},
{ status: 502 },
)
}
const html = await response.text()
const found = !looksUnavailable(source, html)
return Response.json({
found,
handle: account.handle,
status: response.status,
url: account.url,
})
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return Response.json(
{ error: "Account lookup timed out", handle: account.handle },
{ status: 504 },
)
}
console.error("Account status lookup failed:", error)
return Response.json(
{ error: "Unable to verify account", handle: account.handle },
{ status: 502 },
)
} finally {
clearTimeout(timeoutId)
}
}