supermemory/apps/web/components/onboarding-brain/company-brain-onboarding.tsx
MaheshtheDev 0695ca421b feat(web): take a card before the Company Brain trial starts (#1459)
Onboarding now opens a trial step that collects a card through Stripe checkout before the brain is enabled, with a timeline showing today's $0, the day-12 reminder, and the day-14 charge.

- Only leaves the card step once the API confirms the trial is live
- Brain home shows a setup banner and dims what the trial unlocks
- Recovers orgs that abandoned checkout instead of stranding them
- Adds the organization ID to account settings, copyable from the label
2026-08-13 06:58:47 +00:00

927 lines
26 KiB
TypeScript

"use client"
import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { cn } from "@lib/utils"
import {
ArrowRight,
Building2,
Check,
ChevronRight,
Globe,
Loader2,
} from "lucide-react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { AnimatePresence, motion } from "motion/react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import {
type ResearchEvent,
type ResearchStat,
useResearchStatus,
} from "@/hooks/use-research-status"
import {
cardSurfaceStyle,
DomainLogo,
fieldLabel,
inputBevelStyle,
inputClass,
UserAvatar,
} from "./step-about"
import { ResearchActionRail } from "./research-action-rail"
import { CHECKOUT_RETURN_PARAM, StepTrial } from "./step-trial"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { analytics } from "@/lib/analytics"
import {
type CompanyBrainConfirmResult,
type CompanyBrainOrganizationChoice,
workspaceNameFromDomain,
} from "./types"
interface CompanyBrainOnboardingProps {
name: string
avatarUrl: string | null
domain: string
submitting: boolean
onConfirm: (
domain: string,
organizationId?: string,
) => Promise<CompanyBrainConfirmResult>
onDone: () => void
onUsePersonal: () => void
}
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type Phase = "confirm" | "trial" | "research"
function normalizeDomain(input: string): string {
const host = input
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/.*$/, "")
// The confirmation card is often filled with a company name (for example,
// "Zomato") even though research needs a hostname. Preserve explicit TLDs,
// and make the common single-label case usable without a failed API round trip.
if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) {
return `${host}.com`
}
return host
}
export function CompanyBrainOnboarding({
name,
avatarUrl,
domain: initialDomain,
submitting,
onConfirm,
onDone,
onUsePersonal,
}: CompanyBrainOnboardingProps) {
const [phase, setPhase] = useState<Phase>("confirm")
const { needsSetup } = useTrialStatus()
const resumedRef = useRef(false)
useEffect(() => {
if (resumedRef.current) return
const url = new URL(window.location.href)
if (url.searchParams.get(CHECKOUT_RETURN_PARAM) !== "complete") return
resumedRef.current = true
url.searchParams.delete(CHECKOUT_RETURN_PARAM)
window.history.replaceState({}, "", `${url.pathname}${url.search}`)
setPhase("research")
}, [])
useEffect(() => {
if (resumedRef.current || !needsSetup || phase !== "confirm") return
resumedRef.current = true
setPhase("trial")
analytics.brainTrialCardViewed()
}, [needsSetup, phase])
const [domain, setDomain] = useState(initialDomain)
const [organizationChoices, setOrganizationChoices] = useState<
CompanyBrainOrganizationChoice[] | null
>(null)
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
const firstName = name.trim().split(/\s+/)[0] ?? ""
const clean = normalizeDomain(domain)
const queryClient = useQueryClient()
const { status: researchStatus } = useResearchStatus(phase === "research")
const researchDone = researchStatus === "done"
// One-shot retry: re-kick research once on a terminal error.
const retryStage = useRef<"idle" | "started" | "rerunning" | "exhausted">(
"idle",
)
const [retryUi, setRetryUi] = useState<null | "retrying" | "exhausted">(null)
const handleConfirm = async (organizationId?: string) => {
if (!clean || submitting) return
const result = await onConfirm(clean, organizationId)
if (!result.ok) {
if (result.choices?.length) setOrganizationChoices(result.choices)
return
}
setOrganizationChoices(null)
setServerSchedulesResearch(result.serverSchedulesResearch)
setPhase("trial")
analytics.brainTrialCardViewed()
}
// New-org signup schedules research after provisioning; if that hook is slow
// or fails, force-start from the client so onboarding doesn't stall.
useEffect(() => {
if (phase !== "research" || !serverSchedulesResearch || !clean) return
const timer = window.setTimeout(() => {
void (async () => {
try {
const res = await fetch(`${BACKEND}/brain/research/status`, {
credentials: "include",
headers: { "X-App-Source": "nova" },
})
if (!res.ok) return
const state = (await res.json()) as {
status?: string | null
events?: { aspect: string }[]
}
if (state.status === "done") return
// Real research aspects use ord >= 2; bail if one is already underway.
if ((state.events?.length ?? 0) >= 3) return
await fetch(`${BACKEND}/brain/research/start`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify({ domain: clean }),
})
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
} catch {}
})()
}, 40_000)
return () => window.clearTimeout(timer)
}, [phase, serverSchedulesResearch, clean, queryClient])
// "error" lingers a render after re-kicking, so only arm the second-error
// branch once the re-run is observed running.
useEffect(() => {
if (phase !== "research" || !clean) return
const stage = retryStage.current
if (researchStatus === "error" && stage === "idle") {
retryStage.current = "started"
setRetryUi("retrying")
void (async () => {
// A failed restart never reaches queued/running, and polling is off on
// error — without this the UI would say "retrying" forever.
const ok = await fetch(`${BACKEND}/brain/research/start`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify({ domain: clean }),
})
.then((res) => res.ok)
.catch(() => false)
if (!ok) {
retryStage.current = "exhausted"
setRetryUi("exhausted")
return
}
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
})()
} else if (
(researchStatus === "queued" || researchStatus === "running") &&
stage === "started"
) {
retryStage.current = "rerunning"
} else if (researchStatus === "error" && stage === "rerunning") {
retryStage.current = "exhausted"
setRetryUi("exhausted")
} else if (researchStatus === "done") {
setRetryUi(null)
}
}, [phase, clean, researchStatus, queryClient])
return (
<div
className={cn(
"relative h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
dmSansClassName(),
)}
>
<Backdrop />
<header className="relative z-10 flex items-center px-6 md:px-10 py-4">
<LogoFull className="h-5 md:h-6 text-[#fafafa]" />
</header>
<main
className={cn(
"relative z-10 flex-1 flex flex-col min-h-0",
phase === "research"
? "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14"
: "justify-center items-center px-4 md:px-10",
)}
>
{/* Persistent card: full confirm card, then morphs into a slim docked header. */}
<motion.div
layout
transition={{ type: "spring", stiffness: 260, damping: 30 }}
style={cardSurfaceStyle}
className={cn(
"w-full mx-auto rounded-[22px] bg-[#1B1F24]",
phase === "research"
? "max-w-7xl px-5 py-3 xl:max-w-[1360px]"
: phase === "trial"
? "max-w-4xl p-6 md:p-7"
: "max-w-xl p-6 md:p-8",
)}
>
<AnimatePresence mode="wait" initial={false}>
{phase === "trial" ? (
<motion.div
key="trial"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<StepTrial onActive={() => setPhase("research")} />
</motion.div>
) : phase === "confirm" ? (
<motion.div
key="confirm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{organizationChoices ? (
<OrganizationChoiceBody
organizations={organizationChoices}
submitting={submitting}
onSelect={(organizationId) => handleConfirm(organizationId)}
onBack={() => setOrganizationChoices(null)}
/>
) : (
<ConfirmBody
firstName={firstName}
name={name}
avatarUrl={avatarUrl}
domain={domain}
onDomainChange={setDomain}
onConfirm={() => handleConfirm()}
submitting={submitting}
/>
)}
</motion.div>
) : (
<motion.div
key="docked"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<DockedHeader
domain={clean}
done={researchDone}
retrying={retryUi === "retrying"}
exhausted={retryUi === "exhausted"}
onContinue={onDone}
/>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{phase === "confirm" && !organizationChoices && (
<div className="w-full max-w-xl mx-auto mt-5 flex items-center justify-between gap-4 px-1">
<button
type="button"
onClick={onUsePersonal}
disabled={submitting}
className="text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa] disabled:opacity-50"
>
Use a personal workspace instead
</button>
<Button
variant="insideOut"
onClick={() => handleConfirm()}
disabled={!clean || submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Starting
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Confirm
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
)}
<AnimatePresence>
{phase === "research" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="relative w-full max-w-7xl xl:max-w-[1360px] mx-auto flex flex-col flex-1 min-h-0 mt-4 mb-8 gap-4"
>
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-4">
<div className="flex min-h-[280px] min-w-0 flex-[1.15] flex-col lg:min-w-0">
<ResearchTranscript />
</div>
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.35, duration: 0.4 }}
className="flex min-h-[200px] min-w-0 flex-1 flex-col lg:min-w-[340px] lg:max-w-[420px]"
>
<ResearchActionRail domain={clean} />
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</div>
)
}
function OrganizationChoiceBody({
organizations,
submitting,
onSelect,
onBack,
}: {
organizations: CompanyBrainOrganizationChoice[]
submitting: boolean
onSelect: (organizationId: string) => void
onBack: () => void
}) {
return (
<>
<div className="flex items-start gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full border border-[rgba(82,89,102,0.2)] bg-[#14161A] text-[#4BA0FA]">
<Building2 className="size-5" />
</div>
<div>
<p className="text-[20px] font-semibold leading-tight text-[#FAFAFA]">
Choose your Company Brain
</p>
<p className="mt-1 text-[14px] font-medium leading-[1.4] text-[#737373]">
You already belong to more than one Company Brain workspace.
</p>
</div>
</div>
<div className="mt-6 flex flex-col gap-2">
{organizations.map((organization) => (
<button
key={organization.id}
type="button"
disabled={submitting}
onClick={() => onSelect(organization.id)}
className="flex w-full items-center gap-3 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-3 text-left transition-colors hover:bg-white/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] disabled:opacity-50"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#4BA0FA]/10 text-[#4BA0FA]">
<Building2 className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-[14px] font-medium text-[#FAFAFA]">
{organization.name}
</span>
{submitting ? (
<Loader2 className="size-4 shrink-0 animate-spin text-[#8A94A6]" />
) : (
<ChevronRight className="size-4 shrink-0 text-[#8A94A6]" />
)}
</button>
))}
</div>
<button
type="button"
disabled={submitting}
onClick={onBack}
className="mt-4 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] disabled:opacity-50"
>
Use a different domain
</button>
</>
)
}
function ConfirmBody({
firstName,
name,
avatarUrl,
domain,
onDomainChange,
onConfirm,
submitting,
}: {
firstName: string
name: string
avatarUrl: string | null
domain: string
onDomainChange: (v: string) => void
onConfirm: () => void
submitting: boolean
}) {
return (
<>
<div className="flex items-center gap-4">
<UserAvatar url={avatarUrl} name={name} className="size-12 shrink-0" />
<div className="min-w-0">
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
{firstName ? `Hey ${firstName} 👋` : "Hey there 👋"}
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.4] mt-0.5">
I'll research your company and set up its Brain.
</p>
</div>
</div>
<div className="mt-6">
<p className={fieldLabel}>Company domain</p>
<div className="relative">
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 size-9 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden"
style={inputBevelStyle}
>
<DomainLogo domain={normalizeDomain(domain) || "supermemory.ai"} />
</div>
<Input
value={domain}
onChange={(e) => onDomainChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !submitting) onConfirm()
}}
placeholder="your-team.com"
spellCheck={false}
autoComplete="off"
className={cn(inputClass, "pl-14")}
style={inputBevelStyle}
/>
</div>
</div>
</>
)
}
function DockedHeader({
domain,
done,
retrying,
exhausted,
onContinue,
}: {
domain: string
done: boolean
retrying: boolean
exhausted: boolean
onContinue: () => void
}) {
// Same key as the rail/header queries so a connect in another tab is picked up on refocus.
const { data: slack } = useQuery({
queryKey: ["brain-slack-status"],
queryFn: async (): Promise<{ connected: boolean }> => {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (!res.ok) return { connected: false }
return (await res.json()) as { connected: boolean }
},
staleTime: 30_000,
})
const slackConnected = slack?.connected ?? false
const brandName = workspaceNameFromDomain(domain) || domain
const showSpinner = !done && !exhausted
const statusLabel = done
? "Company Brain ready"
: exhausted
? "Couldn't finish you can continue"
: retrying
? "Retrying research"
: "Building your Company Brain"
const statusColor = done
? "text-[#5CD68A]"
: exhausted
? "text-[#E5A45A]"
: "text-[#737373]"
return (
<div className="flex min-w-0 items-center gap-3">
<div
className="size-8 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
style={inputBevelStyle}
>
<DomainLogo domain={domain} />
</div>
<div className="flex min-w-0 flex-1 flex-col md:flex-row md:items-center md:gap-3">
<span
title={brandName}
className="min-w-0 truncate text-[14px] font-semibold text-[#fafafa] md:flex-1"
>
{brandName}
</span>
<span
className={cn(
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
statusColor,
)}
>
{showSpinner && (
<Loader2 className="size-3 animate-spin text-[#4BA0FA]" />
)}
{statusLabel}
</span>
{slackConnected && (
<span className="hidden shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium text-[#5CD68A] sm:flex">
<Check className="size-3" />
Slack connected · free trial started
</span>
)}
</div>
{/* Never gated on research; the admin can move on while it keeps working. */}
<Button
type="button"
onClick={onContinue}
className={cn(
"ml-auto shrink-0 rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
dmSans125ClassName(),
)}
>
Continue
<ArrowRight className="size-3.5" />
</Button>
</div>
)
}
function ResearchTranscript() {
const { status, events } = useResearchStatus()
const running = status !== "done" && status !== "error"
const scrollRef = useRef<HTMLDivElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
})
}, [events.length])
return (
<div className="flex flex-1 min-h-0 flex-col gap-4">
<div
style={cardSurfaceStyle}
className="relative flex min-h-0 flex-col overflow-hidden rounded-[22px] bg-[#1B1F24]"
>
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto px-6 pt-8 pb-8"
>
<Timeline events={events} running={running} />
</div>
{/* Top-only overlay in the card color: text slides up under a solid edge. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-9 rounded-t-[22px]"
style={{
background:
"linear-gradient(to bottom, #1B1F24 0%, rgba(27,31,36,0) 100%)",
}}
/>
</div>
</div>
)
}
function Timeline({
events,
running,
}: {
events: ResearchEvent[]
running: boolean
}) {
if (events.length === 0) {
return (
<div className="flex items-center gap-3 text-[13px] font-medium text-[#737373]">
<Loader2 className="size-4 animate-spin text-[#4BA0FA]" />
Starting deep research…
</div>
)
}
return (
<ol className="flex flex-col gap-0">
<AnimatePresence initial={false}>
{events.map((e, i) => {
const isLast = i === events.length - 1
const active = running && e.status === "in_progress"
return (
<motion.li
key={e.aspect}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="relative flex gap-3.5 pb-6 last:pb-0"
>
<div className="relative flex w-3.5 shrink-0 justify-center">
<EventDot status={e.status} active={active} />
{!isLast && (
<span className="absolute left-1/2 top-[19px] -bottom-[18px] w-px -translate-x-1/2 bg-[#2E353D]" />
)}
</div>
<div className="flex flex-1 flex-col gap-2 -mt-px">
<span
className={cn(
"text-[14px] font-medium leading-[14px]",
e.status === "error"
? "text-[#E5735A]"
: active
? "text-[#fafafa]"
: "text-[#A1A1AA]",
)}
>
{e.label}
</span>
{e.detail && e.status !== "in_progress" && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-[12.5px] leading-[1.6] text-[#737373]"
>
<CitedText text={e.detail} />
</motion.p>
)}
{e.status === "complete" && e.stats.length > 0 && (
<StatStrip stats={e.stats} />
)}
{e.status === "complete" && e.highlights.length > 0 && (
<Chips items={e.highlights} />
)}
{e.status === "complete" && e.sources.length > 0 && (
<SourceChips urls={e.sources} />
)}
{active && <ThinkingLine />}
</div>
</motion.li>
)
})}
</AnimatePresence>
</ol>
)
}
// grok inlines citations as `[[1]](url)` (sometimes consecutive). Render them
// as compact superscript links and collapse any leftover `[n]` bare markers.
const CITE_RE = /\[\[(\d+)\]\]\((https?:\/\/[^)\s]+)\)/g
function CitedText({ text }: { text: string }) {
const nodes: ReactNode[] = []
let last = 0
let m: RegExpExecArray | null
CITE_RE.lastIndex = 0
// biome-ignore lint/suspicious/noAssignInExpressions: regex walk
while ((m = CITE_RE.exec(text)) !== null) {
if (m.index > last) {
nodes.push(cleanBareCites(text.slice(last, m.index)))
}
nodes.push(
<a
key={`${m.index}-${m[1]}`}
href={m[2]}
target="_blank"
rel="noreferrer"
className="mx-px align-super text-[9px] font-semibold text-[#4BA0FA] hover:underline"
>
{m[1]}
</a>,
)
last = m.index + m[0].length
}
if (last < text.length) nodes.push(cleanBareCites(text.slice(last)))
return <>{nodes}</>
}
function cleanBareCites(s: string): string {
return s.replace(/\[\[?\d+\]\]?/g, "").replace(/\s{2,}/g, " ")
}
const THINKING_PHRASES = [
"Searching the web",
"Reading sources",
"Cross-checking facts",
"Summarizing findings",
]
function ThinkingLine() {
const [i, setI] = useState(0)
useEffect(() => {
const t = setInterval(
() => setI((v) => (v + 1) % THINKING_PHRASES.length),
1600,
)
return () => clearInterval(t)
}, [])
return (
<motion.p
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-[12px] leading-[1.5] text-[#525D6E]"
>
{THINKING_PHRASES[i]}
</motion.p>
)
}
// Boxless stat strip: values over tiny labels, split by hairline dividers.
function StatStrip({ stats }: { stats: ResearchStat[] }) {
const shown = stats.slice(0, 3)
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-2.5 flex flex-wrap items-stretch gap-x-5 gap-y-3"
>
{shown.map((s, i) => (
<div
key={`${s.label}-${s.value}`}
className={cn(
"flex flex-col gap-1",
i > 0 && "border-l border-[rgba(82,89,102,0.2)] pl-5",
)}
>
<span className="text-[15px] font-semibold leading-none text-[#fafafa]">
{s.value}
</span>
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
{s.label}
</span>
</div>
))}
</motion.div>
)
}
// Quiet outline chips, short entities only (no sentences).
function Chips({ items }: { items: string[] }) {
const shown = items.filter((t) => t.length <= 42).slice(0, 4)
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-2.5 flex flex-wrap gap-1.5"
>
{shown.map((t) => (
<span
key={t}
className="rounded-md border border-[rgba(82,89,102,0.22)] px-2 py-[3px] text-[11px] font-medium text-[#8B8B8B]"
>
{t}
</span>
))}
</motion.div>
)
}
function hostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "")
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "")
}
}
function SourceFavicon({ host }: { host: string }) {
const sources = [
`https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${host}&size=32`,
`https://icons.duckduckgo.com/ip3/${host}.ico`,
]
const [idx, setIdx] = useState(0)
if (idx >= sources.length) {
return <Globe className="size-3 text-[#525D6E]" />
}
return (
<img
src={sources[idx]}
alt=""
className="size-4 rounded-[3px] object-contain"
onError={() => setIdx((i) => i + 1)}
/>
)
}
// Minimal, deduped source row: favicon + muted domain, no boxes.
function SourceChips({ urls }: { urls: string[] }) {
const seen = new Set<string>()
const hosts: { host: string; url: string }[] = []
for (const url of urls) {
const host = hostname(url)
if (seen.has(host)) continue
seen.add(host)
hosts.push({ host, url })
}
const shown = hosts.slice(0, 4)
const extra = hosts.length - shown.length
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 flex flex-wrap items-center gap-x-3.5 gap-y-1.5"
>
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
Sources
</span>
{shown.map(({ host, url }) => (
<a
key={url}
href={url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
<span className="flex size-4 items-center justify-center">
<SourceFavicon host={host} />
</span>
{host}
</a>
))}
{extra > 0 && (
<span className="text-[11px] font-medium text-[#525D6E]">+{extra}</span>
)}
</motion.div>
)
}
// All dots render in a 14px box so their centers align regardless of shape.
function EventDot({ status, active }: { status: string; active: boolean }) {
if (active) {
return <Loader2 className="size-3.5 shrink-0 animate-spin text-[#4BA0FA]" />
}
if (status === "complete") {
return (
<span className="flex size-3.5 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]">
<Check className="size-2.5 text-[#05080D]" />
</span>
)
}
return (
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span
className={cn(
"size-2.5 rounded-full",
status === "error" ? "bg-[#E5735A]" : "bg-[#4BA0FA]/60",
)}
/>
</span>
)
}
function Backdrop() {
return (
<>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
background:
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
backgroundImage:
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
backgroundSize: "28px 28px",
maskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
WebkitMaskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
}}
/>
</>
)
}