mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
Removes stale promo cards, banners, and the setup modal. Simplifies the onboarding entry so all new workspaces go through one flow.
459 lines
13 KiB
TypeScript
459 lines
13 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
|
|
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,
|
|
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}
|
|
>
|
|
<div className="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)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|