fix: Implement the onboarding account lookup (#913)

This commit is contained in:
Ishaan Gupta 2026-05-11 14:59:15 +05:30 committed by GitHub
parent 9cae1959c8
commit 55558f5562
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 480 additions and 48 deletions

View file

@ -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<SetStateAction<SpotlightCategoryId>>,
) {
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<HTMLInputElement | null>) {
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<AccountLookup | null>(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<ReturnType<typeof setInterval> | null>,
) {
useEffect(() => {
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [pollingRef])
}
function useDoneAnimation(
status: Status,
setStampLanded: Dispatch<SetStateAction<boolean>>,
setVisibleSnippets: Dispatch<SetStateAction<number>>,
) {
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() {
)}
/>
<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 +899,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,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)
}
}