supermemory/apps/web/components/onboarding-brain/step-team.tsx
ishaanxgupta 8d926332ab company brain responsiveness (#1239)
## Summary

- Improves the Company Brain header and stats cards on mobile without changing the desktop layout intent.
- Tightens the mobile Slack banner so the Slack logo is not duplicated and the card stays compact.
- Centers the onboarding step flow and card as one stack while keeping the logo independent.

before:
<img width="388" height="864" alt="image" src="https://github.com/user-attachments/assets/f02890e4-78d1-45cf-a819-97306df20d5c" />

after:
<img width="193" height="431" alt="image" src="https://github.com/user-attachments/assets/1b5d6907-1dc1-4d95-9b4f-2b40defecda5" />
<img width="391" height="865" alt="image" src="https://github.com/user-attachments/assets/67fb6009-bace-4c78-9b72-b4dfcdd79b90" />
<img width="385" height="865" alt="image" src="https://github.com/user-attachments/assets/f3c3d785-762e-49c2-92bb-461358eda418" />
<img width="394" height="865" alt="image" src="https://github.com/user-attachments/assets/f5943779-a141-467d-a350-80678f3a2513" />
2026-07-16 01:57:51 +00:00

331 lines
10 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import { ArrowRight, Loader2, Mail, Plus, Trash2, Users } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
const modalCardStyle = {
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",
}
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)",
}
const inputClass =
"bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
export interface TeamValues {
invites: { email: string; role: "admin" | "member" }[]
visibility: "team-private" | "org-shared"
suggestChanges: boolean
}
interface Props {
mode: "personal" | "team"
isScale: boolean
inviteDomain: string | null
values: TeamValues
onChange: (next: TeamValues) => void
onContinue: () => void
onSkip?: () => void
onUpgrade: () => void
submitting?: boolean
}
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
export function StepTeam({
mode,
inviteDomain,
values,
onChange,
onContinue,
onSkip,
submitting,
}: Props) {
const [draft, setDraft] = useState("")
const domainOrFallback = (inviteDomain || "acme.com").trim().toLowerCase()
const addInvites = (text: string) => {
const found = text.match(EMAIL_RE) ?? []
if (found.length === 0) return
const existing = new Set(values.invites.map((i) => i.email.toLowerCase()))
const next: { email: string; role: "admin" | "member" }[] = []
for (const raw of found) {
const email = raw.trim().toLowerCase()
if (!email || existing.has(email)) continue
existing.add(email)
next.push({ email, role: "member" })
}
if (next.length === 0) {
setDraft("")
return
}
onChange({ ...values, invites: [...values.invites, ...next] })
setDraft("")
}
const removeInvite = (email: string) => {
onChange({
...values,
invites: values.invites.filter((i) => i.email !== email),
})
}
const setRole = (email: string, role: "admin" | "member") => {
onChange({
...values,
invites: values.invites.map((i) =>
i.email === email ? { ...i, role } : i,
),
})
}
const domainBreakdown = useMemo(() => {
const counts = new Map<string, number>()
for (const inv of values.invites) {
const at = inv.email.lastIndexOf("@")
if (at < 0) continue
const domain = inv.email.slice(at + 1).toLowerCase()
counts.set(domain, (counts.get(domain) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}, [values.invites])
if (mode === "personal") {
return (
<div
className="max-w-xl mx-auto rounded-[22px] bg-[#1B1F24] p-10 text-center"
style={modalCardStyle}
>
<div
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center mx-auto"
style={inputBevelStyle}
>
<Users className="size-5 text-[#737373]" />
</div>
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa] mt-5",
dmSans125ClassName(),
)}
>
Going solo for now
</p>
<p className="text-[14px] text-[#737373] mt-2 leading-[1.5] font-medium">
You're in Personal mode, so there's no team step. Switch to Team in
the top bar anytime to invite others.
</p>
<Button
variant="insideOut"
onClick={onContinue}
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue
<ArrowRight className="size-3.5" />
</Button>
</div>
)
}
const count = values.invites.length
return (
<div className="max-w-2xl mx-auto pb-24 md:pb-0">
<section
className="rounded-[22px] bg-[#1B1F24] p-5 md:p-8"
style={modalCardStyle}
>
<div className="flex items-start gap-3 md:gap-4">
<div
className="size-11 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0 md:size-12"
style={inputBevelStyle}
>
<Users className="size-5 text-[#fafafa]" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-[19px] font-semibold text-[#fafafa] md:text-[20px]",
dmSans125ClassName(),
)}
>
Invite your team
</p>
<p className="text-[13px] text-[#737373] mt-1 leading-[1.45] font-medium md:text-[14px] md:leading-[1.5]">
A brain gets sharper as more people contribute. You can also do
this later.
</p>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault()
addInvites(draft)
}}
className="mt-5 flex min-w-0 gap-2 md:mt-6"
>
<div className="relative min-w-0 flex-1">
<Mail className="size-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-[#737373]" />
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => {
const pasted = e.clipboardData.getData("text")
if (pasted && EMAIL_RE.test(pasted)) {
e.preventDefault()
addInvites(pasted)
}
}}
placeholder={`alex@${domainOrFallback}`}
className={cn(inputClass, "min-w-0 pl-10")}
style={inputBevelStyle}
/>
</div>
<Button
type="submit"
variant="insideOut"
disabled={!draft.trim()}
className="h-12 shrink-0 rounded-full px-3.5 text-[13px] font-medium text-[#fafafa] md:px-4"
>
<Plus className="size-4" />
Add
</Button>
</form>
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-1 md:pl-2">
Paste multiple emails at once we'll split them for you.
</p>
{count === 0 ? (
<div className="mt-5 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-4 py-7 text-center md:mt-6 md:px-5 md:py-8">
<p className="text-[13px] text-[#737373] font-medium">
No invites yet.
</p>
<p className="text-[12px] text-[#525D6E] font-medium mt-1">
Try{" "}
<span className="text-[#A1A1AA]">alex@{domainOrFallback}</span>,{" "}
<span className="text-[#A1A1AA]">sam@{domainOrFallback}</span>,
etc.
</p>
</div>
) : (
<>
<div className="mt-6 flex items-center justify-between gap-3 px-1">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
{count} invite{count === 1 ? "" : "s"}
</p>
{domainBreakdown.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-[#737373] font-medium">
{domainBreakdown.slice(0, 3).map(([d, n]) => (
<span
key={d}
className="px-2 py-0.5 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)]"
>
{d} · {n}
</span>
))}
</div>
)}
</div>
<div className="mt-3 space-y-1.5 max-h-[240px] overflow-y-auto scrollbar-thin pr-1 md:max-h-[280px]">
{values.invites.map((inv) => (
<div
key={inv.email}
className="flex flex-wrap items-center gap-2 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2 sm:flex-nowrap md:gap-3"
style={inputBevelStyle}
>
<div className="size-7 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] flex items-center justify-center shrink-0">
<span className="text-[11px] font-semibold text-[#A1A1AA] uppercase">
{(inv.email[0] ?? "?").toUpperCase()}
</span>
</div>
<span className="text-[13px] text-[#fafafa] flex-1 font-medium truncate">
{inv.email}
</span>
<Select
value={inv.role}
onValueChange={(r) =>
setRole(inv.email, r as "admin" | "member")
}
>
<SelectTrigger className="h-7 w-24 bg-transparent border border-[rgba(82,89,102,0.2)] rounded-full text-[#A1A1AA] text-[11px] font-medium px-3 shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#14161A] border-[rgba(82,89,102,0.2)] rounded-[12px]">
<SelectItem
value="member"
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
>
Member
</SelectItem>
<SelectItem
value="admin"
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
>
Admin
</SelectItem>
</SelectContent>
</Select>
<button
type="button"
onClick={() => removeInvite(inv.email)}
className="text-[#737373] hover:text-[#fafafa] p-1 transition-colors"
aria-label={`Remove ${inv.email}`}
>
<Trash2 className="size-4" />
</button>
</div>
))}
</div>
</>
)}
<div className="mt-5 flex items-center justify-end gap-3 border-t border-white/[0.06] pt-4 md:mt-6 md:gap-[22px] md:pt-5">
<button
type="button"
onClick={onSkip ?? onContinue}
disabled={submitting}
className="text-[#737373] font-medium text-[13px] transition-colors hover:text-[#999] disabled:opacity-50 md:text-[14px]"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
disabled={submitting}
className="rounded-full px-4 py-2 text-[13px] font-medium text-[#fafafa] md:px-5 md:py-[10px]"
>
{submitting ? (
<>
Sending
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
{count > 0
? `Send ${count} invite${count === 1 ? "" : "s"}`
: "Continue"}
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
</section>
</div>
)
}