supermemory/apps/web/components/onboarding-brain/step-about.tsx
MaheshtheDev 3c3accaab9 feat(web): open company brain signup to everyone (#1342)
- Drop the company-brain-beta PostHog flag gate; Team mode is available to all users.
- Remove the invite-only gate card and the effectiveMode downgrade that silently wrote brainMode: "personal" on team signups.
- Add a "Use a personal workspace instead" link to the domain-confirm step, which had no way back once the gate stopped catching users.

Fixes ENG-1106
2026-07-22 21:05:53 +00:00

510 lines
14 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { Textarea } from "@ui/components/textarea"
import {
ArrowRight,
Brain,
Building2,
LayoutGrid,
Loader2,
Plug,
Terminal,
UserPlus,
Users2,
Wand2,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import type { BrainMode } from "./types"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export interface AboutValues {
name: string
about: string
workspaceName: string
workspaceDomain: string
}
interface Props {
mode: BrainMode
onModeChange: (m: BrainMode) => void
domain: string | null
suggestedWorkspaceName: string
defaultName: string
avatarUrl: string | null
values: AboutValues
onChange: (next: AboutValues) => void
onContinue: () => void
submitting?: boolean
}
export const cardSurfaceStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
export const inputBevelStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
export const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
export const inputClass =
"bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 px-4 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
export function StepAbout({
mode,
onModeChange,
domain,
suggestedWorkspaceName,
defaultName,
avatarUrl,
values,
onChange,
onContinue,
submitting,
}: Props) {
// biome-ignore lint/correctness/useExhaustiveDependencies: one-time initialization when defaults become available
useEffect(() => {
const patch: Partial<AboutValues> = {}
if (!values.name && defaultName) patch.name = defaultName
if (!values.workspaceName && suggestedWorkspaceName) {
patch.workspaceName = suggestedWorkspaceName
}
if (!values.workspaceDomain && domain) {
patch.workspaceDomain = domain
}
if (Object.keys(patch).length > 0) onChange({ ...values, ...patch })
}, [defaultName, suggestedWorkspaceName, domain])
const isTeam = mode === "team"
// Default the workspace name per mode: name-derived for Personal (field hidden),
// email-derived for Team unless the user has typed their own.
const workspaceTouched = useRef(false)
// biome-ignore lint/correctness/useExhaustiveDependencies: derive on mode/name changes only
useEffect(() => {
if (mode === "personal") {
const first = values.name.trim().split(/\s+/)[0]
const derived = first
? `${first}'s Brain`
: suggestedWorkspaceName || "My brain"
if (values.workspaceName !== derived) {
onChange({ ...values, workspaceName: derived })
}
} else if (!workspaceTouched.current) {
const derived = suggestedWorkspaceName || ""
if (values.workspaceName !== derived) {
onChange({ ...values, workspaceName: derived })
}
}
}, [mode, values.name, suggestedWorkspaceName])
// Auto-draft the "what does your company do" blurb from the domain.
const valuesRef = useRef(values)
valuesRef.current = values
const aboutTouched = useRef(false)
const summarizedDomain = useRef<string | null>(null)
const latestDraftDomain = useRef<string | null>(null)
const [drafting, setDrafting] = useState(false)
const [drafted, setDrafted] = useState(false)
const draftCompany = async (rawDomain: string) => {
const d = rawDomain.trim().toLowerCase()
if (!d || summarizedDomain.current === d) return
if (aboutTouched.current && valuesRef.current.about.trim()) return
summarizedDomain.current = d
latestDraftDomain.current = d
setDrafting(true)
try {
const res = await fetch(`${BACKEND}/brain/company-summary`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ domain: d }),
})
// Ignore a stale response if the domain changed mid-flight.
if (latestDraftDomain.current !== d) return
if (!res.ok) return
const data = (await res.json()) as { summary?: string | null }
if (
data.summary &&
!aboutTouched.current &&
latestDraftDomain.current === d
) {
onChange({ ...valuesRef.current, about: data.summary })
setDrafted(true)
}
} catch {
if (latestDraftDomain.current === d) summarizedDomain.current = null
} finally {
if (latestDraftDomain.current === d) setDrafting(false)
}
}
// Draft once when entering Team with a domain already filled (e.g. from email).
// biome-ignore lint/correctness/useExhaustiveDependencies: run on team-entry only
useEffect(() => {
if (!isTeam) return
const d = (values.workspaceDomain || domain || "").trim()
if (d && !values.about.trim()) draftCompany(d)
}, [isTeam])
const canContinue =
values.name.trim().length > 0 && values.workspaceName.trim().length > 0
return (
<div className="max-w-xl mx-auto space-y-5">
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-8"
style={cardSurfaceStyle}
>
<ModeToggle mode={mode} onChange={onModeChange} />
<div className="mt-7 flex items-center gap-4">
<UserAvatar
url={avatarUrl}
name={values.name || defaultName}
className="size-12 shrink-0"
/>
<div className="min-w-0">
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
Tell us about you
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.4] mt-0.5">
So your brain sounds like yours, not the docs.
</p>
</div>
</div>
<div className="mt-6 space-y-4">
<div className={cn("grid gap-4", isTeam && "sm:grid-cols-2")}>
<div>
<p className={fieldLabel}>Your name</p>
<Input
value={values.name}
onChange={(e) => onChange({ ...values, name: e.target.value })}
placeholder="e.g. Mahesh"
className={inputClass}
style={inputBevelStyle}
/>
</div>
{isTeam && (
<div>
<p className={fieldLabel}>Workspace name</p>
<Input
value={values.workspaceName}
onChange={(e) => {
workspaceTouched.current = true
onChange({ ...values, workspaceName: e.target.value })
}}
placeholder={suggestedWorkspaceName || "Acme"}
className={inputClass}
style={inputBevelStyle}
/>
</div>
)}
</div>
{/* Company domain slides in only for Team mode. */}
<AnimatePresence initial={false}>
{isTeam && (
<motion.div
key="domain"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="overflow-hidden"
>
<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}
>
{values.workspaceDomain || domain ? (
<DomainLogo
domain={values.workspaceDomain || domain || ""}
/>
) : (
<Building2 className="size-4 text-[#737373]" />
)}
</div>
<Input
value={values.workspaceDomain}
onChange={(e) =>
onChange({ ...values, workspaceDomain: e.target.value })
}
onBlur={(e) => {
const d = e.target.value.trim()
if (d !== values.workspaceDomain) {
onChange({ ...values, workspaceDomain: d })
}
draftCompany(d)
}}
placeholder="your-team.com"
className={cn(inputClass, "pl-14")}
style={inputBevelStyle}
/>
</div>
</motion.div>
)}
</AnimatePresence>
<div>
<div className="mb-2 flex items-center justify-between gap-2 pl-2">
<p className="font-semibold text-[14px] text-[#737373]">
{isTeam ? (
"What does your company do?"
) : (
<>
What are you here for?{" "}
<span className="text-[#525D6E] font-medium">
(optional)
</span>
</>
)}
</p>
{isTeam &&
(drafting ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-[#737373]">
<Loader2 className="size-3 animate-spin" />
Drafting from your site
</span>
) : drafted ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-[#525D6E]">
<Wand2 className="size-3" />
Drafted from your site · edit anything
</span>
) : null)}
</div>
<Textarea
value={drafting ? "" : values.about}
onChange={(e) => {
aboutTouched.current = true
setDrafted(false)
onChange({ ...values, about: e.target.value })
}}
placeholder={
drafting
? ""
: isTeam
? "A sentence or two — what your company builds, who you serve, how the team works."
: "A sentence or two — what you do, what you're hoping the brain helps with."
}
rows={3}
disabled={drafting}
className={cn(
inputClass,
"h-auto resize-none py-3 leading-[1.5]",
drafting && "opacity-60",
)}
style={inputBevelStyle}
/>
</div>
</div>
<motion.div
key={`perks-${mode}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<PerksFooter perks={mode === "team" ? TEAM_PERKS : PERSONAL_PERKS} />
</motion.div>
</section>
<div className="flex items-center justify-end gap-[22px] px-1">
<Button
variant="insideOut"
onClick={onContinue}
disabled={!canContinue || submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Creating
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Continue
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
</div>
)
}
const PERSONAL_PERKS: Perk[] = [
{
icon: <Brain className="size-4 text-[#8B8B8B]" />,
title: "Your own brain",
blurb: "Notes, docs, bookmarks — all searchable in one place.",
},
{
icon: <Plug className="size-4 text-[#8B8B8B]" />,
title: "Plug into your AI tools",
blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
},
{
icon: <Users2 className="size-4 text-[#8B8B8B]" />,
title: "Switch to a team anytime",
blurb: "Invite teammates whenever you're ready.",
},
]
const TEAM_PERKS: Perk[] = [
{
icon: <UserPlus className="size-4 text-[#8B8B8B]" />,
title: "Invite teammates",
blurb: "Everyone contributes to the same brain.",
},
{
icon: <Terminal className="size-4 text-[#8B8B8B]" />,
title: "Shared coding agent context",
blurb: "Claude, Cursor, MCP — same brain across the team.",
},
{
icon: <LayoutGrid className="size-4 text-[#8B8B8B]" />,
title: "Org-wide spaces",
blurb: "Carve out sales, eng, design with their own access.",
},
]
export function DomainLogo({ domain }: { domain: string }) {
const sources = [
`https://logo.clearbit.com/${domain}`,
`https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${domain}&size=64`,
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
]
const [idx, setIdx] = useState(0)
if (idx >= sources.length) {
return <Building2 className="size-5 text-[#737373]" />
}
return (
<img
src={sources[idx]}
alt={domain}
className="size-6 object-contain"
onError={() => setIdx((i) => i + 1)}
/>
)
}
function ModeToggle({
mode,
onChange,
}: {
mode: BrainMode
onChange: (m: BrainMode) => void
}) {
const items: { id: BrainMode; label: string }[] = [
{ id: "personal", label: "Personal" },
{ id: "team", label: "Team" },
]
return (
<div
className="relative grid grid-cols-2 items-center rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] p-1 text-[13px] font-medium w-full"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<motion.span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full"
style={{ background: "#00173C", border: "1px solid #2261CA66" }}
animate={{ x: mode === "team" ? "100%" : "0%" }}
transition={{ type: "spring", stiffness: 420, damping: 38, mass: 0.8 }}
/>
{items.map((item) => {
const isActive = mode === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={cn(
"relative z-10 h-8 rounded-full text-center transition-colors duration-200",
isActive
? "text-[#fafafa]"
: "text-[#737373] hover:text-[#fafafa]",
)}
>
{item.label}
</button>
)
})}
</div>
)
}
export function UserAvatar({
url,
name,
className,
}: {
url: string | null
name: string
className?: string
}) {
const [errored, setErrored] = useState(false)
const initial = (name?.trim()?.[0] ?? "?").toUpperCase()
const hasImage = url && !errored
return (
<div
className={cn(
"rounded-full overflow-hidden bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center",
className,
)}
style={inputBevelStyle}
>
{hasImage ? (
<img
src={url}
alt={name}
className="size-full object-cover"
onError={() => setErrored(true)}
/>
) : (
<span className="text-[20px] font-semibold text-[#fafafa]">
{initial}
</span>
)}
</div>
)
}
type Perk = { icon: React.ReactNode; title: string; blurb: string }
function PerksFooter({ perks }: { perks: Perk[] }) {
return (
<div className="mt-7 flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
{perks.map((p) => (
<span
key={p.title}
className="inline-flex items-center gap-1.5 text-[12px] text-[#A1A1AA] font-medium"
>
<span className="shrink-0">{p.icon}</span>
{p.title}
</span>
))}
</div>
)
}