From ee558304810745dbb557f6ef8857dfb4dfaf971b Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 9 May 2026 20:20:41 +0530 Subject: [PATCH] fix: Implemented the onboarding account lookup UX --- apps/web/app/(app)/onboarding/page.tsx | 163 ++++++++++++++++- .../api/onboarding/account-status/route.ts | 167 ++++++++++++++++++ 2 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 apps/web/app/api/onboarding/account-status/route.ts diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index ac554055..1b2e1f2f 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -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(null) + const [accountLookup, setAccountLookup] = useState(null) const [resumeFile, setResumeFile] = useState(null) const [isDragging, setIsDragging] = useState(false) const [status, setStatus] = useState("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() { )} /> + + {isCheckingAccount && ( + + + + )} + + {canSubmit && ( - {SOURCE_LABEL[detected as "x" | "linkedin"]} + {currentAccountLookup?.status === "found" && ( + + )} + {currentAccountLookup?.status === "not_found" && ( + + )} + {currentAccountLookup?.status === "error" && ( + + )} + {isCheckingAccount && ( + + )} + + {currentAccountLookup?.message ?? + SOURCE_LABEL[detected as "x" | "linkedin"]} + )} diff --git a/apps/web/app/api/onboarding/account-status/route.ts b/apps/web/app/api/onboarding/account-status/route.ts new file mode 100644 index 00000000..65106592 --- /dev/null +++ b/apps/web/app/api/onboarding/account-status/route.ts @@ -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) + } +}