diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index c819d0f8..27bd853d 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation" import { toast } from "sonner" 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 { BrainShell } from "@/components/onboarding-brain/shell" import { @@ -167,13 +168,17 @@ export default function BrainOnboardingPage() { [router], ) + // Team/company-brain routes everything into the shared Team Brain that + // provisioning creates (sm_org_shared) — not a workspace-name slug space. const containerTag = useMemo( () => - containerTagFromWorkspace( - about.workspaceName || suggestedWorkspaceName, - mode, - ), - [about.workspaceName, suggestedWorkspaceName, mode], + allowTeam && mode === "team" + ? SHARED_TEAM_BRAIN_TAG + : containerTagFromWorkspace( + about.workspaceName || suggestedWorkspaceName, + mode, + ), + [allowTeam, about.workspaceName, suggestedWorkspaceName, mode], ) const isScale = useMemo(() => { @@ -397,7 +402,6 @@ export default function BrainOnboardingPage() { {step === "sources" && ( (null) const [granolaModalOpen, setGranolaModalOpen] = useState(false) @@ -398,7 +398,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { provider: ConnectorProvider syncScope?: GDriveSyncScope }) => { - if (!canAddConnection && !isProUser) { + if (!canAddConnection && !connectorAccess) { throw new Error( "Free plan doesn't include connections. Upgrade to Pro for unlimited connections.", ) @@ -557,7 +557,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { type="button" onClick={() => handleConnect("google-drive")} disabled={ - !isProUser || + !connectorAccess || isConnecting || addConnectionMutation.isPending } @@ -605,14 +605,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { ) : provider === "granola" ? ( <> - {!isProUser && ( + {!connectorAccess && ( Pro )} + ) : undefined, + }, { label: "Setup", value: `${setupDone}/3` }, ] return (
{tiles.map((t) => (
-

- {t.label} -

+
+

+ {t.label} +

+ {t.action} +

connectConnector("notion")} /> } + icon={} name="OneDrive" subtitle="Files from Microsoft 365." connected={connectorConnected("onedrive")} diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 9263bbe5..ed04a456 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -4,7 +4,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" -import { hasActivePlan } from "@lib/queries" import { $fetch } from "@lib/api" import { authClient } from "@lib/auth" import { useAuth } from "@lib/auth-context" @@ -44,6 +43,7 @@ import { Zap, } from "lucide-react" import { formatRelativeTime } from "@/components/settings/sync-utils" +import { useConnectorAccess } from "@/hooks/use-connector-access" import { useConnectionHealth } from "@/hooks/use-connection-health" import { useContainerTags } from "@/hooks/use-container-tags" import { DEFAULT_PROJECT_ID } from "@lib/constants" @@ -2555,8 +2555,11 @@ export function IntegrationsView({ const { allProjects } = useContainerTags() const shortcutsConnect = useShortcutsConnect() const autumn = useCustomer({ queryOptions: { enabled: !publicMode } }) - const hasProProduct = - !publicMode && hasActivePlan(autumn.data?.subscriptions, "api_pro") + // connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins + // stay on hasProProduct. See useConnectorAccess. + const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({ + enabled: !publicMode, + }) const isAutumnLoading = !publicMode && autumn.isLoading const [connectingPlugin, setConnectingPlugin] = useState(null) @@ -2601,7 +2604,7 @@ export function IntegrationsView({ return response.data as Connection[] }, staleTime: 30 * 1000, - enabled: !publicMode && hasProProduct, + enabled: !publicMode && connectorAccess, }) const { @@ -2915,7 +2918,7 @@ export function IntegrationsView({ } if (target === "granola") { - if (!hasProProduct) { + if (!connectorAccess) { void setConnectTarget(null) handleUpgrade("api_pro") } else { @@ -2939,6 +2942,7 @@ export function IntegrationsView({ connectTarget, isAutumnLoading, hasProProduct, + connectorAccess, publicMode, redirectToLogin, setConnectTarget, @@ -3397,7 +3401,7 @@ export function IntegrationsView({ case "connector": { const count = connectionsByProvider[item.provider].length const isGranola = item.provider === "granola" - const needsPlanUpgrade = !isAutumnLoading && !hasProProduct + const needsPlanUpgrade = !isAutumnLoading && !connectorAccess if (count > 0) { return (

@@ -3408,7 +3412,7 @@ export function IntegrationsView({ onClick={() => { trackCard(item) if (isGranola) { - if (!hasProProduct) { + if (!connectorAccess) { handleUpgrade("api_pro") return } @@ -3440,7 +3444,7 @@ export function IntegrationsView({ onClick={() => { trackCard(item) if (isGranola) { - if (!hasProProduct) { + if (!connectorAccess) { handleUpgrade("api_pro") return } @@ -4260,9 +4264,9 @@ export function IntegrationsView({ { - setGranolaModalOpen(open && hasProProduct) + setGranolaModalOpen(open && connectorAccess) if (!open) void setConnectTarget(null) }} /> diff --git a/apps/web/components/onboarding-brain/step-about.tsx b/apps/web/components/onboarding-brain/step-about.tsx index 7da4ffd1..eedb2de8 100644 --- a/apps/web/components/onboarding-brain/step-about.tsx +++ b/apps/web/components/onboarding-brain/step-about.tsx @@ -1,7 +1,7 @@ "use client" -import { useEffect, useState } from "react" -import { motion } from "motion/react" +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" @@ -14,14 +14,17 @@ import { Mail, Plug, Terminal, - User2, 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 @@ -84,36 +87,115 @@ export function StepAbout({ }, [defaultName, suggestedWorkspaceName, domain]) const teamGated = mode === "team" && !allowTeam + const isTeam = mode === "team" && !teamGated + + // 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(null) + const latestDraftDomain = useRef(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 = !teamGated && values.name.trim().length > 0 && values.workspaceName.trim().length > 0 return ( -
-
-
+
+
+ + +
-

- Tell us about you -

-

- So your brain sounds like yours, not the docs. -

+
+

+ Tell us about you +

+

+ So your brain sounds like yours, not the docs. +

+
+
-
+
+

Your name

+ {isTeam && ( +
+

Workspace name

+ { + workspaceTouched.current = true + onChange({ ...values, workspaceName: e.target.value }) + }} + placeholder={suggestedWorkspaceName || "Acme"} + className={inputClass} + style={inputBevelStyle} + /> +
+ )} +
+ + {/* Company domain slides in only for Team mode. */} + + {isTeam && ( + +

Company domain

+
+
+ {values.workspaceDomain || domain ? ( + + ) : ( + + )} +
+ + 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} + /> +
+
+ )} +
+ + {!teamGated && (
-

- What are you here for?{" "} - (optional) -

+
+

+ {isTeam ? ( + "What does your company do?" + ) : ( + <> + What are you here for?{" "} + + (optional) + + + )} +

+ {isTeam && + (drafting ? ( + + + Drafting from your site… + + ) : drafted ? ( + + + Drafted from your site · edit anything + + ) : null)} +