From 55558f55628d0563f874e012be8c88df6b04534b Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 11 May 2026 14:59:15 +0530 Subject: [PATCH] fix: Implement the onboarding account lookup (#913) --- apps/web/app/(app)/onboarding/page.tsx | 292 +++++++++++++++--- .../api/onboarding/account-status/route.ts | 236 ++++++++++++++ 2 files changed, 480 insertions(+), 48 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 fb4403c1..c161a55b 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -6,7 +6,10 @@ import { useCallback, useEffect, useMemo, + type Dispatch, + type RefObject, type ReactNode, + type SetStateAction, } from "react" import { useRouter } from "next/navigation" import { useAuth } from "@lib/auth-context" @@ -30,11 +33,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 +137,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 = { @@ -352,6 +373,167 @@ function buildSpotlightCatalog( } } +function isAccountSource(source: DetectedSource): source is "x" | "linkedin" { + return source === "x" || source === "linkedin" +} + +function useSpotlightAutoRotation( + status: Status, + pauseSpotlight: boolean, + setSpotlightCategory: Dispatch>, +) { + useEffect(() => { + if (status !== "processing") return + if (pauseSpotlight) return + const n = SPOTLIGHT_CATEGORY_ORDER.length + if (n <= 1) return + const t = setInterval(() => { + setSpotlightCategory((cur) => { + const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur) + const from = i >= 0 ? i : 0 + const next = (from + 1) % n + return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur + }) + }, 8000) + return () => clearInterval(t) + }, [status, pauseSpotlight, setSpotlightCategory]) +} + +function useInitialInputFocus(inputRef: RefObject) { + useEffect(() => { + const t = setTimeout(() => inputRef.current?.focus(), 500) + return () => clearTimeout(t) + }, [inputRef]) +} + +function useAccountLookup({ + detected, + status, + value, +}: { + detected: DetectedSource + status: Status + value: string +}) { + const [accountLookup, setAccountLookup] = useState(null) + + useEffect(() => { + if (status !== "idle") return + + const source = isAccountSource(detected) ? 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 + verified?: boolean + } = 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]) + + return accountLookup +} + +function usePollingCleanup( + pollingRef: RefObject | null>, +) { + useEffect(() => { + return () => { + if (pollingRef.current) clearInterval(pollingRef.current) + } + }, [pollingRef]) +} + +function useDoneAnimation( + status: Status, + setStampLanded: Dispatch>, + setVisibleSnippets: Dispatch>, +) { + useEffect(() => { + if (status !== "done") return + setStampLanded(false) + setVisibleSnippets(0) + const t1 = setTimeout(() => setStampLanded(true), 400) + const t2 = setTimeout(() => setVisibleSnippets(1), 900) + const t3 = setTimeout(() => setVisibleSnippets(2), 1200) + const t4 = setTimeout(() => setVisibleSnippets(3), 1500) + return () => { + clearTimeout(t1) + clearTimeout(t2) + clearTimeout(t3) + clearTimeout(t4) + } + }, [status, setStampLanded, setVisibleSnippets]) +} + export default function OnboardingPage() { const router = useRouter() const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth() @@ -395,48 +577,11 @@ export default function OnboardingPage() { [spotlightCategory], ) - useEffect(() => { - if (status !== "processing") return - if (pauseSpotlight) return - const n = SPOTLIGHT_CATEGORY_ORDER.length - if (n <= 1) return - const t = setInterval(() => { - setSpotlightCategory((cur) => { - const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur) - const from = i >= 0 ? i : 0 - const next = (from + 1) % n - return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur - }) - }, 8000) - return () => clearInterval(t) - }, [status, pauseSpotlight]) - - useEffect(() => { - const t = setTimeout(() => inputRef.current?.focus(), 500) - return () => clearTimeout(t) - }, []) - - useEffect(() => { - return () => { - if (pollingRef.current) clearInterval(pollingRef.current) - } - }, []) - - useEffect(() => { - if (status !== "done") return - setStampLanded(false) - setVisibleSnippets(0) - const t1 = setTimeout(() => setStampLanded(true), 400) - const t2 = setTimeout(() => setVisibleSnippets(1), 900) - const t3 = setTimeout(() => setVisibleSnippets(2), 1200) - const t4 = setTimeout(() => setVisibleSnippets(3), 1500) - return () => { - clearTimeout(t1) - clearTimeout(t2) - clearTimeout(t3) - clearTimeout(t4) - } - }, [status]) + useSpotlightAutoRotation(status, pauseSpotlight, setSpotlightCategory) + useInitialInputFocus(inputRef) + const accountLookup = useAccountLookup({ detected, status, value }) + usePollingCleanup(pollingRef) + useDoneAnimation(status, setStampLanded, setVisibleSnippets) const handleChange = (v: string) => { setValue(v) @@ -597,7 +742,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 +861,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..3b3eac0c --- /dev/null +++ b/apps/web/app/api/onboarding/account-status/route.ts @@ -0,0 +1,236 @@ +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") + ) +} + +function linkedinFallback(account: ParsedAccount, status?: number) { + return Response.json({ + found: null, + verified: false, + reason: "unable_to_verify_linkedin", + handle: account.handle, + status, + url: account.url, + }) +} + +async function verifyXAccount(account: ParsedAccount, signal: AbortSignal) { + const oembedUrl = new URL("https://publish.twitter.com/oembed") + oembedUrl.searchParams.set("url", account.url) + + const response = await fetch(oembedUrl, { + signal, + headers: { + Accept: "application/json", + "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 }, + ) + } + + return Response.json({ + found: true, + handle: account.handle, + status: response.status, + url: account.url, + }) +} + +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 { + if (source === "x") { + return await verifyXAccount(account, controller.signal) + } + + 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) { + if (source === "linkedin") { + return linkedinFallback(account, response.status) + } + + 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") { + if (source === "linkedin") { + return linkedinFallback(account) + } + + return Response.json( + { error: "Account lookup timed out", handle: account.handle }, + { status: 504 }, + ) + } + + console.error("Account status lookup failed:", error) + if (source === "linkedin") { + return linkedinFallback(account) + } + + return Response.json( + { error: "Unable to verify account", handle: account.handle }, + { status: 502 }, + ) + } finally { + clearTimeout(timeoutId) + } +}