mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(web): select the correct Company Brain workspace (#1372)
## What changed
- Make `/brain` reuse the active Company Brain, switch to a single existing Company Brain, show a picker for multiple choices, or create one only when none exists.
- Wait for active-organization restoration before making that decision.
- Make Company Brain onboarding match organizations by the confirmed company domain and never offer unrelated-domain workspaces.
- Create a new Company Brain when no matching workspace exists and show an actionable workspace-limit toast when creation is blocked.
- Improve the organization picker, loading, and error states.
## Why
The previous flows could use or mutate the currently active normal organization, start research against stale Company Brain metadata, or offer unrelated Company Brain workspaces after a quota failure.
## Impact
Normal organizations are no longer silently converted. Research is scoped to the Company Brain for the confirmed domain, and users receive a clear recovery path when they reach their workspace limit.
This keeps the existing `{ domain }` research API contract; no organization-reconfiguration API is required.
## Validation
- Biome checks passed for all five changed web files
- Targeted web TypeScript diagnostics reported no errors for the changed files
- React Doctor against `origin/main`: no issues found
- `git diff --check`
No test files were added.
This commit is contained in:
parent
ac880a4dc6
commit
db7f5c3f64
5 changed files with 493 additions and 60 deletions
|
|
@ -2,13 +2,19 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { LogoFull } from "@ui/assets/Logo"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { AlertTriangle, ChevronRight, Loader2, RotateCw } from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||
import { cn } from "@lib/utils"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
type BrainEntryOrganization,
|
||||
resolveCompanyBrainEntry,
|
||||
} from "@/lib/company-brain-entry"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
detectModeFromEmail,
|
||||
generateOrgSlug,
|
||||
|
|
@ -21,22 +27,39 @@ import {
|
|||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
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)",
|
||||
}
|
||||
|
||||
// No forms: sign up → org auto-created → Slack install.
|
||||
// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits).
|
||||
export default function BrainEntryPage() {
|
||||
const router = useRouter()
|
||||
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
|
||||
useAuth()
|
||||
const {
|
||||
user,
|
||||
org,
|
||||
organizations,
|
||||
isRestoring,
|
||||
setActiveOrg,
|
||||
refetchOrganizations,
|
||||
} = useAuth()
|
||||
const { email = null } = user ?? {}
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [choices, setChoices] = useState<BrainEntryOrganization[] | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const startedRef = useRef(false)
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (organizations && organizations.length > 0) {
|
||||
const active =
|
||||
org ?? organizations.find((o) => o.slug) ?? organizations[0]
|
||||
if (!org && active?.slug) await setActiveOrg(active.slug)
|
||||
const continueWithOrganization = useCallback(
|
||||
async (organization: BrainEntryOrganization) => {
|
||||
if (org?.id !== organization.id) {
|
||||
await setActiveOrg(organization.slug)
|
||||
}
|
||||
const status = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
|
|
@ -48,9 +71,11 @@ export default function BrainEntryPage() {
|
|||
return
|
||||
}
|
||||
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
||||
return
|
||||
}
|
||||
},
|
||||
[org?.id, router, setActiveOrg],
|
||||
)
|
||||
|
||||
const createCompanyBrain = useCallback(async () => {
|
||||
// Personal email → shell org; the Slack workspace resolves identity later.
|
||||
const domain =
|
||||
detectModeFromEmail(email) === "team"
|
||||
|
|
@ -85,53 +110,206 @@ export default function BrainEntryPage() {
|
|||
has_domain: Boolean(domain),
|
||||
})
|
||||
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
||||
}, [email, org, organizations, setActiveOrg, refetchOrganizations, router])
|
||||
}, [email, refetchOrganizations, setActiveOrg])
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const organizationsWithActiveMetadata = (organizations ?? []).map(
|
||||
(organization) =>
|
||||
organization.id === org?.id
|
||||
? { ...organization, metadata: org.metadata }
|
||||
: organization,
|
||||
)
|
||||
const decision = resolveCompanyBrainEntry(
|
||||
org?.id,
|
||||
organizationsWithActiveMetadata,
|
||||
)
|
||||
|
||||
if (decision.action === "use" || decision.action === "switch") {
|
||||
await continueWithOrganization(decision.organization)
|
||||
return
|
||||
}
|
||||
if (decision.action === "choose") {
|
||||
setChoices(decision.organizations)
|
||||
return
|
||||
}
|
||||
await createCompanyBrain()
|
||||
}, [continueWithOrganization, createCompanyBrain, org, organizations])
|
||||
|
||||
const handleChoice = useCallback(
|
||||
(organization: BrainEntryOrganization) => {
|
||||
setChoices(null)
|
||||
setError(null)
|
||||
continueWithOrganization(organization).catch((e) => {
|
||||
startedRef.current = false
|
||||
console.error("Company Brain organization selection failed:", e)
|
||||
setError(e instanceof Error ? e.message : "Something went wrong.")
|
||||
})
|
||||
},
|
||||
[continueWithOrganization],
|
||||
)
|
||||
|
||||
// Sole caller of run(): the guard is only released on failure, so a dep change
|
||||
// mid-flight can't kick off a second org creation.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt retriggers the retry
|
||||
useEffect(() => {
|
||||
if (!user || organizations === null || startedRef.current) return
|
||||
if (!user || organizations === null || isRestoring || startedRef.current)
|
||||
return
|
||||
startedRef.current = true
|
||||
run().catch((e) => {
|
||||
startedRef.current = false
|
||||
console.error("Brain entry failed:", e)
|
||||
setError(e instanceof Error ? e.message : "Something went wrong.")
|
||||
})
|
||||
}, [user, organizations, run, attempt])
|
||||
}, [user, organizations, isRestoring, run, attempt])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-dvh flex-col items-center justify-center gap-4 bg-[#05080D] px-6 text-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<>
|
||||
<p className="text-[15px] font-medium text-[#FAFAFA]">
|
||||
<EntryShell>
|
||||
{choices ? (
|
||||
<section
|
||||
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[20px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Choose your Company Brain
|
||||
</p>
|
||||
<p className="mt-1.5 text-[14px] font-medium leading-[1.5] text-[#737373]">
|
||||
You're a member of more than one workspace. Pick the one to open.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{choices.map((organization) => (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
onClick={() => handleChoice(organization)}
|
||||
style={inputBevelStyle}
|
||||
className="group flex w-full items-center gap-3 rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-3 py-3 text-left transition-colors hover:bg-[#1E2228] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.15)] bg-[#0D121A] text-[13px] font-semibold uppercase text-[#A1A1AA]">
|
||||
{(organization.name.trim()[0] ?? "?").toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[14px] font-medium text-[#fafafa]">
|
||||
{organization.name}
|
||||
</span>
|
||||
{generateOrgSlug(organization.name) !== organization.slug && (
|
||||
<span className="mt-0.5 block truncate text-[12px] font-medium text-[#525D6E]">
|
||||
{organization.slug}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-[#525D6E] transition-[color,transform] group-hover:translate-x-0.5 group-hover:text-[#A1A1AA]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{email && (
|
||||
<p className="mt-5 border-t border-white/[0.06] pt-4 text-[12px] font-medium text-[#525D6E]">
|
||||
Signed in as {email}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : error ? (
|
||||
<section
|
||||
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-8 text-center"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex size-12 items-center justify-center rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<AlertTriangle className="size-5 text-[#E5A94B]" />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-5 text-[20px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Couldn't set up your Company Brain
|
||||
</p>
|
||||
<p className="max-w-sm text-[13px] text-[#8A94A6]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
<p className="mt-2 text-[14px] font-medium leading-[1.5] text-[#737373]">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setAttempt((a) => a + 1)
|
||||
}}
|
||||
className="rounded-full bg-white px-5 py-2 text-[13px] font-semibold text-[#1D1C1D] hover:bg-white/95"
|
||||
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
<RotateCw className="size-3.5" />
|
||||
Try again
|
||||
</button>
|
||||
</>
|
||||
</Button>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
|
||||
<p className="text-[14px] font-medium text-[#8A94A6]">
|
||||
Setting up your Company Brain…
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="relative flex size-14 items-center justify-center">
|
||||
<span className="absolute inset-0 animate-ping rounded-[18px] bg-[#4BA0FA]/10" />
|
||||
<span
|
||||
className="absolute inset-0 rounded-[18px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
<Loader2 className="relative size-5 animate-spin text-[#4BA0FA]" />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-6 text-[20px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Setting up your Company Brain
|
||||
</p>
|
||||
</>
|
||||
<p className="mt-2 max-w-sm text-[14px] font-medium leading-[1.5] text-[#737373]">
|
||||
Preparing your workspace, then we'll connect it to Slack.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</EntryShell>
|
||||
)
|
||||
}
|
||||
|
||||
function EntryShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-dvh overflow-hidden bg-[#05080D] text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<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%)",
|
||||
}}
|
||||
/>
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-20 px-4 py-4 md:px-10">
|
||||
<LogoFull className="h-5 text-[#fafafa] md:h-6" />
|
||||
</header>
|
||||
<main className="relative z-10 flex min-h-dvh flex-col items-center justify-center px-4 py-20 md:px-10">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useAuth } from "@lib/auth-context"
|
|||
import { authClient } from "@lib/auth"
|
||||
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry"
|
||||
import { BrainShell } from "@/components/onboarding-brain/shell"
|
||||
import {
|
||||
StepAbout,
|
||||
|
|
@ -55,6 +56,20 @@ const getErrorMessage = (error: unknown, fallback: string) => {
|
|||
return fallback
|
||||
}
|
||||
|
||||
const getWorkspaceCreationErrorCopy = (message: string) => {
|
||||
const limit = message.match(/maximum number of workspaces \((\d+)\)/i)?.[1]
|
||||
if (limit) {
|
||||
return {
|
||||
title: "Workspace limit reached",
|
||||
description: `You can own up to ${limit} workspaces. Delete one in Settings or contact support@supermemory.com for a higher limit.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: "Couldn't create workspace",
|
||||
description: message,
|
||||
}
|
||||
}
|
||||
|
||||
export default function BrainOnboardingPage() {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
|
|
@ -245,8 +260,16 @@ export default function BrainOnboardingPage() {
|
|||
const creatingOrgRef = useRef(false)
|
||||
|
||||
const ensureOrg = useCallback(
|
||||
async (domainOverride?: string): Promise<boolean> => {
|
||||
if (!forceCreate && organizations && organizations.length > 0)
|
||||
async (
|
||||
domainOverride?: string,
|
||||
createEvenIfExisting = false,
|
||||
): Promise<boolean> => {
|
||||
if (
|
||||
!createEvenIfExisting &&
|
||||
!forceCreate &&
|
||||
organizations &&
|
||||
organizations.length > 0
|
||||
)
|
||||
return false
|
||||
const name = (
|
||||
domainOverride
|
||||
|
|
@ -328,8 +351,14 @@ export default function BrainOnboardingPage() {
|
|||
analytics.onboardingWorkspaceCreateFailed({
|
||||
error: message,
|
||||
})
|
||||
toast.error("Organization was not created", {
|
||||
description: "Please try again from Settings.",
|
||||
const errorCopy = getWorkspaceCreationErrorCopy(message)
|
||||
toast.error(errorCopy.title, {
|
||||
description: errorCopy.description,
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: "Open Settings",
|
||||
onClick: () => router.push("/settings"),
|
||||
},
|
||||
})
|
||||
if (forceCreate && (organizations?.length ?? 0) > 0) {
|
||||
router.replace("/")
|
||||
|
|
@ -342,7 +371,10 @@ export default function BrainOnboardingPage() {
|
|||
const isCompanyBrain = mode === "team"
|
||||
|
||||
const handleBrainConfirm = useCallback(
|
||||
async (confirmedDomain: string): Promise<CompanyBrainConfirmResult> => {
|
||||
async (
|
||||
confirmedDomain: string,
|
||||
organizationId?: string,
|
||||
): Promise<CompanyBrainConfirmResult> => {
|
||||
if (creatingOrgRef.current) return { ok: false }
|
||||
creatingOrgRef.current = true
|
||||
setCreatingOrg(true)
|
||||
|
|
@ -353,7 +385,42 @@ export default function BrainOnboardingPage() {
|
|||
workspaceDomain: confirmedDomain,
|
||||
workspaceName: workspaceName || a.workspaceName,
|
||||
}))
|
||||
const orgCreated = await ensureOrg(confirmedDomain)
|
||||
let orgCreated = false
|
||||
if (forceCreate) {
|
||||
orgCreated = await ensureOrg(confirmedDomain, true)
|
||||
} else if (organizationId) {
|
||||
const selected = organizations?.find(
|
||||
(organization) => organization.id === organizationId,
|
||||
)
|
||||
if (!selected) return { ok: false }
|
||||
if (selected.id !== org?.id) await setActiveOrg(selected.slug)
|
||||
} else {
|
||||
const organizationsWithActiveMetadata = (organizations ?? []).map(
|
||||
(organization) =>
|
||||
organization.id === org?.id
|
||||
? { ...organization, metadata: org.metadata }
|
||||
: organization,
|
||||
)
|
||||
const decision = resolveCompanyBrainEntry(
|
||||
org?.id,
|
||||
organizationsWithActiveMetadata,
|
||||
confirmedDomain,
|
||||
)
|
||||
if (decision.action === "choose") {
|
||||
return {
|
||||
ok: false,
|
||||
choices: decision.organizations.map((organization) => ({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (decision.action === "switch") {
|
||||
await setActiveOrg(decision.organization.slug)
|
||||
} else if (decision.action === "create") {
|
||||
orgCreated = await ensureOrg(confirmedDomain, true)
|
||||
}
|
||||
}
|
||||
// Re-entering onboarding on an existing org ("Try onboarding") must
|
||||
// kick research from the client. New orgs rely on the signup hook after
|
||||
// provisioning — a duplicate /start races and can strand the DO task.
|
||||
|
|
@ -393,8 +460,14 @@ export default function BrainOnboardingPage() {
|
|||
const message = getErrorMessage(e, "Organization was not created.")
|
||||
console.error("Failed to create organization:", e)
|
||||
analytics.onboardingWorkspaceCreateFailed({ error: message })
|
||||
toast.error("Organization was not created", {
|
||||
description: "Please try again.",
|
||||
const errorCopy = getWorkspaceCreationErrorCopy(message)
|
||||
toast.error(errorCopy.title, {
|
||||
description: errorCopy.description,
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: "Open Settings",
|
||||
onClick: () => router.push("/settings"),
|
||||
},
|
||||
})
|
||||
return { ok: false }
|
||||
} finally {
|
||||
|
|
@ -402,7 +475,15 @@ export default function BrainOnboardingPage() {
|
|||
setCreatingOrg(false)
|
||||
}
|
||||
},
|
||||
[ensureOrg, queryClient],
|
||||
[
|
||||
ensureOrg,
|
||||
forceCreate,
|
||||
org,
|
||||
organizations,
|
||||
queryClient,
|
||||
setActiveOrg,
|
||||
router,
|
||||
],
|
||||
)
|
||||
|
||||
const [sendingInvites, setSendingInvites] = useState(false)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ 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, Check, Globe, Loader2 } from "lucide-react"
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
|
|
@ -25,6 +32,7 @@ import {
|
|||
import { ResearchActionRail } from "./research-action-rail"
|
||||
import {
|
||||
type CompanyBrainConfirmResult,
|
||||
type CompanyBrainOrganizationChoice,
|
||||
workspaceNameFromDomain,
|
||||
} from "./types"
|
||||
|
||||
|
|
@ -33,7 +41,10 @@ interface CompanyBrainOnboardingProps {
|
|||
avatarUrl: string | null
|
||||
domain: string
|
||||
submitting: boolean
|
||||
onConfirm: (domain: string) => Promise<CompanyBrainConfirmResult>
|
||||
onConfirm: (
|
||||
domain: string,
|
||||
organizationId?: string,
|
||||
) => Promise<CompanyBrainConfirmResult>
|
||||
onDone: () => void
|
||||
onUsePersonal: () => void
|
||||
}
|
||||
|
|
@ -72,6 +83,9 @@ export function CompanyBrainOnboarding({
|
|||
}: CompanyBrainOnboardingProps) {
|
||||
const [phase, setPhase] = useState<Phase>("confirm")
|
||||
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)
|
||||
|
|
@ -84,10 +98,14 @@ export function CompanyBrainOnboarding({
|
|||
)
|
||||
const [retryUi, setRetryUi] = useState<null | "retrying" | "exhausted">(null)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const handleConfirm = async (organizationId?: string) => {
|
||||
if (!clean || submitting) return
|
||||
const result = await onConfirm(clean)
|
||||
if (!result.ok) return
|
||||
const result = await onConfirm(clean, organizationId)
|
||||
if (!result.ok) {
|
||||
if (result.choices?.length) setOrganizationChoices(result.choices)
|
||||
return
|
||||
}
|
||||
setOrganizationChoices(null)
|
||||
setServerSchedulesResearch(result.serverSchedulesResearch)
|
||||
setPhase("research")
|
||||
}
|
||||
|
|
@ -211,15 +229,24 @@ export function CompanyBrainOnboarding({
|
|||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ConfirmBody
|
||||
firstName={firstName}
|
||||
name={name}
|
||||
avatarUrl={avatarUrl}
|
||||
domain={domain}
|
||||
onDomainChange={setDomain}
|
||||
onConfirm={handleConfirm}
|
||||
submitting={submitting}
|
||||
/>
|
||||
{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
|
||||
|
|
@ -240,7 +267,7 @@ export function CompanyBrainOnboarding({
|
|||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{phase === "confirm" && (
|
||||
{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"
|
||||
|
|
@ -252,7 +279,7 @@ export function CompanyBrainOnboarding({
|
|||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleConfirm}
|
||||
onClick={() => handleConfirm()}
|
||||
disabled={!clean || submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
|
|
@ -300,6 +327,67 @@ export function CompanyBrainOnboarding({
|
|||
)
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
export type CompanyBrainOrganizationChoice = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type CompanyBrainConfirmResult =
|
||||
| { ok: true; serverSchedulesResearch: boolean }
|
||||
| { ok: false }
|
||||
| { ok: false; choices?: CompanyBrainOrganizationChoice[] }
|
||||
|
||||
export type BrainMode = "personal" | "team"
|
||||
|
||||
|
|
|
|||
81
apps/web/lib/company-brain-entry.ts
Normal file
81
apps/web/lib/company-brain-entry.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
getBrainMode,
|
||||
getBrainWorkspaceDomain,
|
||||
getCompanyBrainOverride,
|
||||
hasCompanyBrain,
|
||||
} from "./billing-utils"
|
||||
|
||||
export type BrainEntryOrganization = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
metadata?: Record<string, unknown> | string | null
|
||||
}
|
||||
|
||||
export type CompanyBrainEntryDecision =
|
||||
| { action: "use"; organization: BrainEntryOrganization }
|
||||
| { action: "switch"; organization: BrainEntryOrganization }
|
||||
| { action: "choose"; organizations: BrainEntryOrganization[] }
|
||||
| { action: "create" }
|
||||
|
||||
export function isCompanyBrainOrganization(
|
||||
organization: BrainEntryOrganization,
|
||||
): boolean {
|
||||
const override = getCompanyBrainOverride(organization.metadata)
|
||||
if (override !== undefined) return override
|
||||
return (
|
||||
hasCompanyBrain(organization.metadata) ||
|
||||
getBrainMode(organization.metadata) === "team"
|
||||
)
|
||||
}
|
||||
|
||||
export function getCompanyBrainOrganizations(
|
||||
organizations: BrainEntryOrganization[],
|
||||
): BrainEntryOrganization[] {
|
||||
return organizations.filter(isCompanyBrainOrganization)
|
||||
}
|
||||
|
||||
function normalizeDomain(domain: string): string {
|
||||
return domain
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/\/.*$/, "")
|
||||
}
|
||||
|
||||
export function resolveCompanyBrainEntry(
|
||||
activeOrganizationId: string | null | undefined,
|
||||
organizations: BrainEntryOrganization[],
|
||||
requestedDomain?: string,
|
||||
): CompanyBrainEntryDecision {
|
||||
const normalizedRequestedDomain = requestedDomain
|
||||
? normalizeDomain(requestedDomain)
|
||||
: null
|
||||
const companyBrains = getCompanyBrainOrganizations(organizations).filter(
|
||||
(organization) => {
|
||||
if (!normalizedRequestedDomain) return true
|
||||
const organizationDomain = getBrainWorkspaceDomain(organization.metadata)
|
||||
return (
|
||||
organizationDomain !== null &&
|
||||
normalizeDomain(organizationDomain) === normalizedRequestedDomain
|
||||
)
|
||||
},
|
||||
)
|
||||
const active = organizations.find(
|
||||
(organization) => organization.id === activeOrganizationId,
|
||||
)
|
||||
if (
|
||||
active &&
|
||||
companyBrains.some((organization) => organization.id === active.id)
|
||||
) {
|
||||
return { action: "use", organization: active }
|
||||
}
|
||||
if (companyBrains.length === 1 && companyBrains[0]) {
|
||||
return { action: "switch", organization: companyBrains[0] }
|
||||
}
|
||||
if (companyBrains.length > 1) {
|
||||
return { action: "choose", organizations: companyBrains }
|
||||
}
|
||||
return { action: "create" }
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue