mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): company brain onboarding redesign + connector entitlement (#1178)
- onboarding: unified About step, mode-aware Sources, Slack-focused Flows step, connect feedback (toast + connected state), auto-draft company description from domain - brain-home: Active members stat + invite, real OneDrive icon - useConnectorAccess hook so company_brain unlocks pro-tier connectors across onboarding + integrations - fix company-brain-connections crash on empty connections --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/64a15b29-0848-42d1-af4c-138c5a27136f) - Requested by: Unknown - Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
parent
e706a13877
commit
42f3aec885
12 changed files with 859 additions and 617 deletions
|
|
@ -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" && (
|
||||
<StepSources
|
||||
containerTag={containerTag}
|
||||
workspaceName={about.workspaceName || suggestedWorkspaceName}
|
||||
mode={mode}
|
||||
values={sources}
|
||||
onChange={setSources}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
|
||||
|
|
@ -309,7 +309,7 @@ interface ConnectContentProps {
|
|||
export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const { connectorAccess } = useConnectorAccess()
|
||||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(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) {
|
|||
</div>
|
||||
) : provider === "granola" ? (
|
||||
<>
|
||||
{!isProUser && (
|
||||
{!connectorAccess && (
|
||||
<span className="bg-[#0054AD] text-[#FAFAFA] text-[10px] font-bold px-1.5 py-[2px] rounded-[3px] uppercase tracking-wide">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!isProUser) {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
|
|
@ -621,7 +621,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
disabled={isUpgrading || autumn.isLoading}
|
||||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
|
||||
>
|
||||
{!isProUser
|
||||
{!connectorAccess
|
||||
? isUpgrading || autumn.isLoading
|
||||
? "Upgrading..."
|
||||
: "Upgrade"
|
||||
|
|
@ -634,7 +634,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
handleConnect(provider as ConnectorProvider)
|
||||
}
|
||||
disabled={
|
||||
!isProUser ||
|
||||
!connectorAccess ||
|
||||
isConnecting ||
|
||||
addConnectionMutation.isPending
|
||||
}
|
||||
|
|
@ -681,7 +681,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isProUser || isAnyConnecting}
|
||||
disabled={!connectorAccess || isAnyConnecting}
|
||||
className="flex shrink-0 items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors"
|
||||
>
|
||||
{isAnyConnecting ? (
|
||||
|
|
@ -792,7 +792,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<DropdownMenuItem
|
||||
disabled={isUpgrading || autumn.isLoading}
|
||||
onClick={() => {
|
||||
if (!isProUser) {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
|
|
@ -804,14 +804,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="flex items-center gap-1.5 text-[14px] font-medium text-[#FAFAFA] leading-tight">
|
||||
Granola
|
||||
{!isProUser && (
|
||||
{!connectorAccess && (
|
||||
<span className="bg-[#0054AD] text-[#FAFAFA] text-[9px] font-bold px-1 py-px rounded-[3px] uppercase tracking-wide">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#737373] leading-tight">
|
||||
{isProUser
|
||||
{connectorAccess
|
||||
? "Meeting notes & transcripts"
|
||||
: "Upgrade to Pro"}
|
||||
</span>
|
||||
|
|
@ -866,7 +866,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
|
||||
>
|
||||
<Zap className="size-6 text-[#737373] mb-3" />
|
||||
{!isProUser ? (
|
||||
{!connectorAccess ? (
|
||||
<>
|
||||
<p className="text-[14px] text-[#737373] mb-4 text-center">
|
||||
{isUpgrading || autumn.isLoading ? (
|
||||
|
|
@ -945,8 +945,8 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
/>
|
||||
|
||||
<GranolaConnectModal
|
||||
open={isProUser && granolaModalOpen}
|
||||
onOpenChange={(open) => setGranolaModalOpen(open && isProUser)}
|
||||
open={connectorAccess && granolaModalOpen}
|
||||
onOpenChange={(open) => setGranolaModalOpen(open && connectorAccess)}
|
||||
containerTags={[selectedProject]}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { $fetch } from "@lib/api"
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ArrowRight, Check, FileText, Loader2 } from "lucide-react"
|
||||
import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { ConnectionsBoard } from "./connections-board"
|
||||
|
||||
|
|
@ -103,11 +105,17 @@ function useBrainOverview() {
|
|||
(brain.data?.slack ? 1 : 0) +
|
||||
(connectors.data?.length ?? 0)
|
||||
|
||||
const currentRole = org?.members
|
||||
?.find((m) => m.userId === user?.id)
|
||||
?.role?.toLowerCase()
|
||||
|
||||
return {
|
||||
loading: docs.isPending,
|
||||
recentDocs: docs.data?.documents ?? [],
|
||||
memoriesCount,
|
||||
connectedCount,
|
||||
membersCount: org?.members?.length ?? 0,
|
||||
canInvite: currentRole === "owner" || currentRole === "admin",
|
||||
hasSource: connectedCount > 0,
|
||||
hasAgent: mcp.data ?? false,
|
||||
hasMemory: memoriesCount > 0,
|
||||
|
|
@ -125,6 +133,8 @@ export function BrainHomeView() {
|
|||
<StatsRow
|
||||
memories={o.memoriesCount}
|
||||
connected={o.connectedCount}
|
||||
members={o.membersCount}
|
||||
canInvite={o.canInvite}
|
||||
setupDone={stepsDone}
|
||||
/>
|
||||
<ConnectionsBoard />
|
||||
|
|
@ -143,27 +153,60 @@ export function BrainHomeView() {
|
|||
function StatsRow({
|
||||
memories,
|
||||
connected,
|
||||
members,
|
||||
canInvite,
|
||||
setupDone,
|
||||
}: {
|
||||
memories: number
|
||||
connected: number
|
||||
members: number
|
||||
canInvite: boolean
|
||||
setupDone: number
|
||||
}) {
|
||||
const tiles = [
|
||||
const { openSettings } = useSettingsModal()
|
||||
const [, setInvite] = useQueryState("invite")
|
||||
|
||||
const onInvite = () => {
|
||||
setInvite("1")
|
||||
openSettings("account")
|
||||
}
|
||||
|
||||
const tiles: {
|
||||
label: string
|
||||
value: string
|
||||
action?: React.ReactNode
|
||||
}[] = [
|
||||
{ label: "Memories", value: memories.toLocaleString() },
|
||||
{ label: "Connected sources", value: String(connected) },
|
||||
{
|
||||
label: "Active members",
|
||||
value: String(members),
|
||||
action: canInvite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInvite}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
<UserPlus className="size-3" />
|
||||
Invite
|
||||
</button>
|
||||
) : undefined,
|
||||
},
|
||||
{ label: "Setup", value: `${setupDone}/3` },
|
||||
]
|
||||
return (
|
||||
<section
|
||||
className="grid grid-cols-3 divide-x divide-white/[0.04] rounded-[16px] bg-[#1B1F24]"
|
||||
className="grid grid-cols-2 divide-white/[0.04] rounded-[16px] bg-[#1B1F24] sm:grid-cols-4 sm:divide-x"
|
||||
style={cardStyle}
|
||||
>
|
||||
{tiles.map((t) => (
|
||||
<div key={t.label} className="px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
|
||||
{t.label}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
|
||||
{t.label}
|
||||
</p>
|
||||
{t.action}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { $fetch } from "@lib/api"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Notion } from "@ui/assets/icons"
|
||||
import { Cloud, ExternalLink, Loader2 } from "lucide-react"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { ExternalLink, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -167,7 +167,7 @@ export function ConnectionsBoard() {
|
|||
onConnect={() => connectConnector("notion")}
|
||||
/>
|
||||
<AppCard
|
||||
icon={<Cloud className="size-5 text-[#0F6CBD]" />}
|
||||
icon={<OneDrive className="size-5" />}
|
||||
name="OneDrive"
|
||||
subtitle="Files from Microsoft 365."
|
||||
connected={connectorConnected("onedrive")}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
|
|
@ -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({
|
|||
</Dialog>
|
||||
|
||||
<GranolaConnectModal
|
||||
open={hasProProduct && granolaModalOpen}
|
||||
open={connectorAccess && granolaModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setGranolaModalOpen(open && hasProProduct)
|
||||
setGranolaModalOpen(open && connectorAccess)
|
||||
if (!open) void setConnectTarget(null)
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<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 =
|
||||
!teamGated &&
|
||||
values.name.trim().length > 0 &&
|
||||
values.workspaceName.trim().length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid md:grid-cols-[1.6fr_1fr] gap-4 items-stretch">
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
|
||||
style={cardSurfaceStyle}
|
||||
>
|
||||
<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-14 mb-5"
|
||||
className="size-12 shrink-0"
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[20px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Tell us about you
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
|
||||
So your brain sounds like yours, not the docs.
|
||||
</p>
|
||||
<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-5">
|
||||
<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
|
||||
|
|
@ -125,54 +207,147 @@ export function StepAbout({
|
|||
/>
|
||||
</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>
|
||||
|
||||
{!teamGated && (
|
||||
<div>
|
||||
<p className={fieldLabel}>
|
||||
What are you here for?{" "}
|
||||
<span className="text-[#525D6E] font-medium">(optional)</span>
|
||||
</p>
|
||||
<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={values.about}
|
||||
onChange={(e) => onChange({ ...values, about: e.target.value })}
|
||||
placeholder="A sentence or two — what you do, what you're hoping the brain helps with."
|
||||
rows={4}
|
||||
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>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7 flex flex-col"
|
||||
style={cardSurfaceStyle}
|
||||
>
|
||||
<ModeToggle mode={mode} onChange={onModeChange} />
|
||||
|
||||
<div className="mt-6">
|
||||
{teamGated ? (
|
||||
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
|
||||
) : mode === "team" ? (
|
||||
<TeamWorkspaceCard
|
||||
domain={values.workspaceDomain || domain || ""}
|
||||
onDomainChange={(d) =>
|
||||
onChange({ ...values, workspaceDomain: d })
|
||||
}
|
||||
value={values.workspaceName}
|
||||
onChange={(w) => onChange({ ...values, workspaceName: w })}
|
||||
suggested={suggestedWorkspaceName}
|
||||
/>
|
||||
) : (
|
||||
<PersonalWorkspaceCard
|
||||
value={values.workspaceName}
|
||||
onChange={(w) => onChange({ ...values, workspaceName: w })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{teamGated ? (
|
||||
<motion.div
|
||||
key="gate"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="mt-6"
|
||||
>
|
||||
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
|
||||
</motion.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
|
||||
|
|
@ -198,88 +373,41 @@ export function StepAbout({
|
|||
)
|
||||
}
|
||||
|
||||
function TeamWorkspaceCard({
|
||||
domain,
|
||||
onDomainChange,
|
||||
value,
|
||||
onChange,
|
||||
suggested,
|
||||
}: {
|
||||
domain: string
|
||||
onDomainChange: (d: string) => void
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
suggested: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
{domain ? (
|
||||
<DomainLogo domain={domain} />
|
||||
) : (
|
||||
<Building2 className="size-5 text-[#737373]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={domain}
|
||||
onChange={(e) => onDomainChange(e.target.value.trim())}
|
||||
placeholder="your-team.com"
|
||||
className="w-full bg-transparent text-[18px] text-[#fafafa] font-semibold leading-tight outline-none border-b border-transparent hover:border-[rgba(115,115,115,0.2)] focus:border-[rgba(115,115,115,0.4)] transition-colors px-0 py-0.5"
|
||||
/>
|
||||
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
Team workspace
|
||||
</div>
|
||||
</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.",
|
||||
},
|
||||
]
|
||||
|
||||
<div className="mt-6">
|
||||
<p className={fieldLabel}>
|
||||
Workspace name{" "}
|
||||
<span className="text-[#525D6E] font-medium">
|
||||
(rename if you'd like)
|
||||
</span>
|
||||
</p>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={suggested || "Acme"}
|
||||
className={inputClass}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PerksList
|
||||
heading="What this unlocks"
|
||||
perks={[
|
||||
{
|
||||
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.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
|
||||
Not your team? Switch above.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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.",
|
||||
},
|
||||
]
|
||||
|
||||
function DomainLogo({ domain }: { domain: string }) {
|
||||
const sources = [
|
||||
|
|
@ -301,71 +429,6 @@ function DomainLogo({ domain }: { domain: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function PersonalWorkspaceCard({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<User2 className="size-5 text-[#737373]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[18px] text-[#fafafa] font-semibold">
|
||||
Just you, for now
|
||||
</div>
|
||||
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
Personal workspace
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className={fieldLabel}>Workspace nickname</p>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="My brain"
|
||||
className={inputClass}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PerksList
|
||||
heading="What this unlocks"
|
||||
perks={[
|
||||
{
|
||||
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.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
|
||||
Working with a team? Switch above.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TeamBetaGate({ onUsePersonal }: { onUsePersonal: () => void }) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -435,6 +498,13 @@ function ModeToggle({
|
|||
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 (
|
||||
|
|
@ -443,28 +513,13 @@ function ModeToggle({
|
|||
type="button"
|
||||
onClick={() => onChange(item.id)}
|
||||
className={cn(
|
||||
"relative h-8 rounded-full text-center transition-colors",
|
||||
"relative z-10 h-8 rounded-full text-center transition-colors duration-200",
|
||||
isActive
|
||||
? "text-[#fafafa]"
|
||||
: "text-[#737373] hover:text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId="brain-mode-pill"
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: "#00173C",
|
||||
border: "1px solid #2261CA66",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 34,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{item.label}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
@ -511,22 +566,18 @@ function UserAvatar({
|
|||
|
||||
type Perk = { icon: React.ReactNode; title: string; blurb: string }
|
||||
|
||||
function PerksList({ heading, perks }: { heading: string; perks: Perk[] }) {
|
||||
function PerksFooter({ perks }: { perks: Perk[] }) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold mb-3 pl-1">
|
||||
{heading}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{perks.map((p) => (
|
||||
<li key={p.title} className="flex items-center gap-2.5 pl-1">
|
||||
<span className="shrink-0">{p.icon}</span>
|
||||
<span className="text-[13px] text-[#fafafa] font-medium">
|
||||
{p.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ type FlowTool = {
|
|||
}
|
||||
|
||||
const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
|
||||
// Team/company-brain onboarding is focused: just get Supermemory into Slack.
|
||||
// Coding agents live under "More tools (full catalog)".
|
||||
team: [
|
||||
{
|
||||
id: "slack",
|
||||
|
|
@ -46,20 +48,6 @@ const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
|
|||
kind: "slack",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
blurb: "Shared context in your terminal.",
|
||||
kind: "plugin",
|
||||
pluginId: "claude_code",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
blurb: "OpenAI's coding agent.",
|
||||
kind: "plugin",
|
||||
pluginId: "codex",
|
||||
},
|
||||
],
|
||||
personal: [
|
||||
{
|
||||
|
|
@ -126,8 +114,8 @@ export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[900px] pb-10">
|
||||
<section className="relative py-4">
|
||||
<div className="mx-auto w-full max-w-[680px] pb-10">
|
||||
<section className="py-4">
|
||||
<div className="mb-6 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -139,80 +127,76 @@ export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
|
|||
</p>
|
||||
<p className="mt-1.5 text-[15px] font-medium leading-[1.4] text-[#737373]">
|
||||
{mode === "team"
|
||||
? "Pick where your team asks questions, then set it up."
|
||||
? "Add Supermemory to Slack so your team can ask in any channel."
|
||||
: "Pick the tool you open every day — about 60 seconds."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[250px_minmax(0,1fr)]">
|
||||
{/* Left rail: pick a tool */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{tools.map((tool) => (
|
||||
<FlowToolRow
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
active={selected === tool.id}
|
||||
onSelect={() => {
|
||||
analytics.onboardingAgentSelected({ agent: tool.id })
|
||||
setSelected(tool.id)
|
||||
<div
|
||||
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{tools.length > 1 ? (
|
||||
<>
|
||||
<FlowTabs
|
||||
tools={tools}
|
||||
selected={selected}
|
||||
onSelect={(id) => {
|
||||
analytics.onboardingAgentSelected({ agent: id })
|
||||
setSelected(id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="mt-1 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
More tools
|
||||
<span className="text-[#525D6E]">(full catalog)</span>
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right pane: setup detail */}
|
||||
<div
|
||||
className="relative flex min-h-[300px] flex-col overflow-hidden rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-10 left-10 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.4), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={activeTool.id} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[18px] font-semibold text-[#fafafa]">
|
||||
Set up {activeTool.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
{activeTool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-5 mb-4 px-1 text-[13px] font-medium text-[#737373]">
|
||||
{activeTool.blurb}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="mb-5 flex items-center gap-3 px-1">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={activeTool.id} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold text-[#fafafa]">
|
||||
Set up {activeTool.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
{activeTool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeTool.kind === "slack" ? (
|
||||
<SlackSetupPanel />
|
||||
) : activeTool.kind === "mcp" ? (
|
||||
<McpGenericSetup mcpUrl={mcpUrl} />
|
||||
) : activeTool.pluginId ? (
|
||||
<PluginSetup
|
||||
key={activeTool.pluginId}
|
||||
pluginId={activeTool.pluginId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
|
||||
{activeTool.kind === "slack" ? (
|
||||
<SlackSetupPanel />
|
||||
) : activeTool.kind === "mcp" ? (
|
||||
<McpGenericSetup mcpUrl={mcpUrl} />
|
||||
) : activeTool.pluginId ? (
|
||||
<PluginSetup
|
||||
key={activeTool.pluginId}
|
||||
pluginId={activeTool.pluginId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-6 flex items-center gap-3 border-t border-white/[0.06] pt-5",
|
||||
tools.length > 1 ? "justify-between" : "justify-end",
|
||||
)}
|
||||
>
|
||||
{tools.length > 1 && (
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
More tools
|
||||
<span className="text-[#525D6E]">(full catalog)</span>
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-center gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
|
|
@ -253,77 +237,47 @@ function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function FlowToolRow({
|
||||
tool,
|
||||
active,
|
||||
function FlowTabs({
|
||||
tools,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
tool: FlowTool
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
tools: readonly FlowTool[]
|
||||
selected: FlowToolId
|
||||
onSelect: (id: FlowToolId) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center gap-3 overflow-hidden rounded-[14px] p-3 text-left transition-all duration-150",
|
||||
active
|
||||
? "bg-[#10151D] ring-2 ring-[#4BA0FA]/45"
|
||||
: "bg-[#1B1F24] ring-1 ring-white/[0.05] hover:ring-white/[0.12]",
|
||||
)}
|
||||
style={modalCardStyle}
|
||||
<div
|
||||
className="grid gap-1 rounded-[14px] border border-[rgba(115,115,115,0.2)] bg-[#0D121A] p-1"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${tools.length}, minmax(0,1fr))`,
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-5 left-5 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.55), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] border bg-[#14161A] transition-colors",
|
||||
active ? "border-[#2261CA]/45" : "border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
|
||||
{tool.label}
|
||||
</p>
|
||||
{tool.recommended && (
|
||||
<span className="shrink-0 rounded-full bg-[#4BA0FA]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
|
||||
{tool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-[18px] shrink-0 items-center justify-center rounded-full border transition-colors",
|
||||
active
|
||||
? "border-[#4BA0FA] bg-[#4BA0FA]"
|
||||
: "border-[rgba(82,89,102,0.4)] group-hover:border-[rgba(115,115,115,0.5)]",
|
||||
)}
|
||||
>
|
||||
{active && <Check className="size-3 text-white" />}
|
||||
</span>
|
||||
</button>
|
||||
{tools.map((tool) => {
|
||||
const active = tool.id === selected
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(tool.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"relative flex h-10 items-center justify-center gap-2 rounded-[10px] border border-transparent px-2 text-[13px] font-medium transition-colors",
|
||||
active ? "text-[#fafafa]" : "text-[#737373] hover:text-[#fafafa]",
|
||||
)}
|
||||
style={
|
||||
active
|
||||
? { background: "#00173C", borderColor: "#2261CA66" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ToolIcon id={tool.id} className="size-4 shrink-0" />
|
||||
<span className="truncate">{tool.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -332,26 +286,36 @@ function StepRow({
|
|||
title,
|
||||
done,
|
||||
children,
|
||||
last,
|
||||
}: {
|
||||
index: number
|
||||
title: ReactNode
|
||||
done?: boolean
|
||||
children?: ReactNode
|
||||
last?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
|
||||
done
|
||||
? "bg-[#4BA0FA] text-white"
|
||||
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
|
||||
done
|
||||
? "bg-[#4BA0FA] text-white"
|
||||
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3" /> : index}
|
||||
</span>
|
||||
{!last && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-1.5 w-px flex-1 bg-[rgba(82,89,102,0.3)]"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3" /> : index}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
</div>
|
||||
<div className={cn("min-w-0 flex-1 pt-0.5", last ? "pb-0" : "pb-6")}>
|
||||
<div className="text-[13px] font-medium leading-[1.5] text-[#fafafa]">
|
||||
{title}
|
||||
</div>
|
||||
|
|
@ -370,7 +334,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
{steps.map((step, i) => (
|
||||
<StepRow key={step.title} index={i + 1} title={step.title}>
|
||||
{step.description ? (
|
||||
|
|
@ -381,7 +345,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
|
|||
{step.code ? <CopyCodeBlock code={step.code} /> : null}
|
||||
</StepRow>
|
||||
))}
|
||||
<StepRow index={steps.length + 1} title="Ask your brain to test it">
|
||||
<StepRow index={steps.length + 1} last title="Ask your brain to test it">
|
||||
<CopyCodeBlock code={TEST_PROMPT} />
|
||||
</StepRow>
|
||||
</div>
|
||||
|
|
@ -390,7 +354,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
|
|||
|
||||
function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<StepRow index={1} title="Copy your universal MCP URL">
|
||||
<McpUrlRow url={mcpUrl} />
|
||||
</StepRow>
|
||||
|
|
@ -403,7 +367,7 @@ function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
|
|||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</StepRow>
|
||||
<StepRow index={3} title="Ask your brain to test it">
|
||||
<StepRow index={3} last title="Ask your brain to test it">
|
||||
<CopyCodeBlock code={TEST_PROMPT} />
|
||||
</StepRow>
|
||||
</div>
|
||||
|
|
@ -444,35 +408,36 @@ function SlackSetupPanel() {
|
|||
const connected = status?.connected ?? false
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<StepRow
|
||||
index={1}
|
||||
done={connected}
|
||||
title={
|
||||
connected
|
||||
? `Connected to ${status?.teamName ?? "your workspace"}`
|
||||
: "Add Supermemory to your Slack workspace"
|
||||
}
|
||||
>
|
||||
{!connected &&
|
||||
(loading ? (
|
||||
<span className="inline-flex items-center gap-2 text-[12px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</span>
|
||||
connected ? (
|
||||
`Connected to ${status?.teamName ?? "your workspace"}`
|
||||
) : (
|
||||
<Button
|
||||
variant="insideOut"
|
||||
asChild
|
||||
className="h-9 gap-2 rounded-full px-4 text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
<a href={`${BACKEND}/brain/slack/oauth/install`}>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</StepRow>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="pt-1">
|
||||
Add Supermemory to your Slack workspace
|
||||
</span>
|
||||
{loading ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-2 text-[12px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StepRow
|
||||
index={2}
|
||||
title={
|
||||
|
|
@ -482,7 +447,7 @@ function SlackSetupPanel() {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
<StepRow index={3} title="Ask your brain to test it">
|
||||
<StepRow index={3} last title="Ask your brain to test it">
|
||||
<CopyCodeBlock code="@supermemory what do we know about [topic]?" />
|
||||
</StepRow>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useQueryState } from "nuqs"
|
||||
import Image from "next/image"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Dialog, DialogClose, DialogContent } from "@ui/components/dialog"
|
||||
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -98,9 +99,11 @@ import {
|
|||
type PlanType,
|
||||
useTokenUsage,
|
||||
} from "@/hooks/use-token-usage"
|
||||
import { GranolaConnectModal } from "@/components/granola-connect-modal"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
||||
import {
|
||||
ADD_MEMORY_SHORTCUT_URL,
|
||||
CHROME_EXTENSION_URL,
|
||||
|
|
@ -117,6 +120,7 @@ type SourceId =
|
|||
| "gmail"
|
||||
| "github"
|
||||
| "onedrive"
|
||||
| "granola"
|
||||
| "bookmarks"
|
||||
| "chatapps"
|
||||
| "chrome"
|
||||
|
|
@ -126,6 +130,20 @@ type SourceState = "idle" | "connecting" | "connected" | "waitlist"
|
|||
type DriveScope = "selective" | "full"
|
||||
type RequiredPlan = "pro" | "max"
|
||||
|
||||
const PROVIDER_TO_SOURCE: Record<string, SourceId> = {
|
||||
"google-drive": "drive",
|
||||
notion: "notion",
|
||||
onedrive: "onedrive",
|
||||
granola: "granola",
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Partial<Record<SourceId, string>> = {
|
||||
drive: "Google Drive",
|
||||
notion: "Notion",
|
||||
onedrive: "OneDrive",
|
||||
granola: "Granola",
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<RequiredPlan, string> = {
|
||||
pro: "Pro",
|
||||
max: "Max",
|
||||
|
|
@ -224,7 +242,6 @@ export interface SourcesValues {
|
|||
|
||||
interface Props {
|
||||
containerTag: string
|
||||
workspaceName: string
|
||||
mode: BrainMode
|
||||
values: SourcesValues
|
||||
onChange: (next: SourcesValues) => void
|
||||
|
|
@ -281,7 +298,6 @@ function ChatAppsIconCluster() {
|
|||
|
||||
export function StepSources({
|
||||
containerTag,
|
||||
workspaceName,
|
||||
mode,
|
||||
values,
|
||||
onChange,
|
||||
|
|
@ -289,20 +305,99 @@ export function StepSources({
|
|||
}: Props) {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [plansOpen, setPlansOpen] = useState(false)
|
||||
const [granolaOpen, setGranolaOpen] = useState(false)
|
||||
const [requestedPlan, setRequestedPlan] = useState<RequiredPlan>("pro")
|
||||
const [requestedConnector, setRequestedConnector] = useState("This connector")
|
||||
const autumn = useCustomer()
|
||||
const hasPro = hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const hasMax = hasActivePlan(autumn.data?.subscriptions, "api_max")
|
||||
const planLoading = autumn.isLoading
|
||||
const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
|
||||
const { org, isRestoring } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
setMoreOpen(false)
|
||||
}, [])
|
||||
|
||||
const valuesRef = useRef(values)
|
||||
valuesRef.current = values
|
||||
// dedupe toasts across the param + reconcile paths
|
||||
const announced = useRef(new Set<SourceId>())
|
||||
const seeded = useRef(false)
|
||||
|
||||
const markConnected = (ids: SourceId[]) => {
|
||||
const current = valuesRef.current
|
||||
const updates: Partial<Record<SourceId, SourceState>> = {}
|
||||
for (const id of ids) {
|
||||
if (current.connected[id] !== "connected") updates[id] = "connected"
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
onChange({
|
||||
...current,
|
||||
connected: { ...current.connected, ...updates },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// keyed by org so it re-runs once the active org restores on reload
|
||||
const { data: liveConnections, refetch: refetchConnections } = useQuery({
|
||||
queryKey: ["onboarding-connections", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: [] },
|
||||
})
|
||||
if (res.error) return [] as Array<{ provider?: string }>
|
||||
return (res.data ?? []) as Array<{ provider?: string }>
|
||||
},
|
||||
enabled: !isRestoring && !!org?.id,
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reconcile on fetched connections only
|
||||
useEffect(() => {
|
||||
if (!liveConnections) return
|
||||
const ids = liveConnections
|
||||
.map((c) => (c.provider ? PROVIDER_TO_SOURCE[c.provider] : undefined))
|
||||
.filter((id): id is SourceId => Boolean(id))
|
||||
markConnected(ids)
|
||||
// first load: seed without toasting pre-existing connections
|
||||
if (!seeded.current) {
|
||||
seeded.current = true
|
||||
for (const id of ids) announced.current.add(id)
|
||||
return
|
||||
}
|
||||
for (const id of ids) {
|
||||
if (!announced.current.has(id)) {
|
||||
announced.current.add(id)
|
||||
toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
|
||||
}
|
||||
}
|
||||
}, [liveConnections])
|
||||
|
||||
// Post-OAuth redirect lands with ?connected=<provider> — confirm it instantly.
|
||||
const [connectedParam, setConnectedParam] = useQueryState("connected")
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: run when the param arrives
|
||||
useEffect(() => {
|
||||
if (!connectedParam) return
|
||||
const id = PROVIDER_TO_SOURCE[connectedParam]
|
||||
if (id) {
|
||||
markConnected([id])
|
||||
if (!announced.current.has(id)) {
|
||||
announced.current.add(id)
|
||||
toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
|
||||
}
|
||||
}
|
||||
setConnectedParam(null)
|
||||
const t1 = setTimeout(() => void refetchConnections(), 1500)
|
||||
const t2 = setTimeout(() => void refetchConnections(), 4000)
|
||||
return () => {
|
||||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
}
|
||||
}, [connectedParam])
|
||||
|
||||
// company_brain unlocks pro connectors; max stays gated
|
||||
const isLocked = (plan?: RequiredPlan) => {
|
||||
if (!plan || planLoading) return false
|
||||
return plan === "max" ? !hasMax : !hasPro
|
||||
if (plan === "max") return !hasMax
|
||||
return !connectorAccess
|
||||
}
|
||||
|
||||
const setState = (id: SourceId, state: SourceState) => {
|
||||
|
|
@ -382,71 +477,91 @@ export function StepSources({
|
|||
<div className="mx-auto w-full max-w-[1400px] pb-10">
|
||||
<section className="relative min-h-[calc(100dvh-136px)] py-4">
|
||||
<div className="absolute inset-x-0 top-[46%] -translate-y-1/2">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 mb-6 px-1">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[22px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{mode === "personal"
|
||||
? "Bring your context together"
|
||||
: "Connect your team's signals"}
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
|
||||
Start with the sources that carry the most context. Add more
|
||||
anytime.
|
||||
</p>
|
||||
</div>
|
||||
<RoutingChip workspaceName={workspaceName} />
|
||||
<div className="mb-6 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[22px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{mode === "personal"
|
||||
? "Bring your context together"
|
||||
: "Connect your team's signals"}
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
|
||||
Start with the sources that carry the most context. Add more
|
||||
anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<SourceCard
|
||||
title="Import bookmarks"
|
||||
blurb="One-shot import of your saved tweets."
|
||||
icon={<XBookmarksIcon className="size-6 text-[#fafafa]" />}
|
||||
state={values.connected.bookmarks ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
doneLabel="Opened"
|
||||
perks={[
|
||||
"Bookmarks become searchable memories",
|
||||
"One-click import from the X bookmarks tab",
|
||||
"Works via the Chrome extension",
|
||||
]}
|
||||
onConnect={() => openExternal("bookmarks", CHROME_EXTENSION_URL)}
|
||||
/>
|
||||
<SourceCard
|
||||
title="Import from AI chat apps"
|
||||
blurb="Bring memories from ChatGPT, Claude, Grok & more."
|
||||
icon={<ChatAppsIconCluster />}
|
||||
bareIconFrame
|
||||
state={values.connected.chatapps ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
doneLabel="Opened"
|
||||
perks={[
|
||||
"Sync your ChatGPT memories",
|
||||
"Carry context across every assistant",
|
||||
"Import once, recall anywhere",
|
||||
]}
|
||||
onConnect={() => openExternal("chatapps", CHROME_EXTENSION_URL)}
|
||||
/>
|
||||
{mode === "personal" ? (
|
||||
<NotionSourceCard
|
||||
values={values}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
<>
|
||||
<SourceCard
|
||||
title="Import bookmarks"
|
||||
blurb="One-shot import of your saved tweets."
|
||||
icon={<XBookmarksIcon className="size-6 text-[#fafafa]" />}
|
||||
state={values.connected.bookmarks ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
doneLabel="Opened"
|
||||
perks={[
|
||||
"Bookmarks become searchable memories",
|
||||
"One-click import from the X bookmarks tab",
|
||||
"Works via the Chrome extension",
|
||||
]}
|
||||
onConnect={() =>
|
||||
openExternal("bookmarks", CHROME_EXTENSION_URL)
|
||||
}
|
||||
/>
|
||||
<SourceCard
|
||||
title="Import from AI chat apps"
|
||||
blurb="Bring memories from ChatGPT, Claude, Grok & more."
|
||||
icon={<ChatAppsIconCluster />}
|
||||
bareIconFrame
|
||||
state={values.connected.chatapps ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
doneLabel="Opened"
|
||||
perks={[
|
||||
"Sync your ChatGPT memories",
|
||||
"Carry context across every assistant",
|
||||
"Import once, recall anywhere",
|
||||
]}
|
||||
onConnect={() =>
|
||||
openExternal("chatapps", CHROME_EXTENSION_URL)
|
||||
}
|
||||
/>
|
||||
<NotionSourceCard
|
||||
mode={mode}
|
||||
values={values}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<GoogleDriveSourceCard
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
<>
|
||||
<NotionSourceCard
|
||||
mode={mode}
|
||||
values={values}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
<GranolaSourceCard
|
||||
state={values.connected.granola ?? "idle"}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
onOpen={() => setGranolaOpen(true)}
|
||||
/>
|
||||
<GoogleDriveSourceCard
|
||||
mode={mode}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -465,7 +580,7 @@ export function StepSources({
|
|||
/>
|
||||
More integrations
|
||||
<span className="text-[#525D6E]">
|
||||
(Notion, Gmail, GitHub, OneDrive…)
|
||||
(Gmail, GitHub, OneDrive…)
|
||||
</span>
|
||||
</button>
|
||||
<SourceActions
|
||||
|
|
@ -486,6 +601,7 @@ export function StepSources({
|
|||
openExternal={openExternal}
|
||||
requestWaitlist={requestWaitlist}
|
||||
connectRealProvider={connectRealProvider}
|
||||
onOpenGranola={() => setGranolaOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -500,6 +616,15 @@ export function StepSources({
|
|||
requestedConnector={requestedConnector}
|
||||
requestedPlan={requestedPlan}
|
||||
/>
|
||||
<GranolaConnectModal
|
||||
open={granolaOpen}
|
||||
onOpenChange={setGranolaOpen}
|
||||
containerTags={[containerTag]}
|
||||
onSuccess={() => {
|
||||
announced.current.add("granola")
|
||||
markConnected(["granola"])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -832,12 +957,14 @@ function SourceActions({
|
|||
}
|
||||
|
||||
function GoogleDriveSourceCard({
|
||||
mode,
|
||||
values,
|
||||
onChange,
|
||||
isLocked,
|
||||
guard,
|
||||
connectRealProvider,
|
||||
}: {
|
||||
mode: BrainMode
|
||||
values: SourcesValues
|
||||
onChange: (next: SourcesValues) => void
|
||||
isLocked: (plan?: RequiredPlan) => boolean
|
||||
|
|
@ -882,17 +1009,21 @@ function GoogleDriveSourceCard({
|
|||
onChange={(s) => onChange({ ...values, driveScope: s })}
|
||||
/>
|
||||
}
|
||||
footerRight={<SpaceChip name="My Drive" />}
|
||||
footerRight={
|
||||
<SpaceChip name={mode === "team" ? "Team Brain" : "My Brain"} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NotionSourceCard({
|
||||
mode,
|
||||
values,
|
||||
isLocked,
|
||||
guard,
|
||||
connectRealProvider,
|
||||
}: {
|
||||
mode: BrainMode
|
||||
values: SourcesValues
|
||||
isLocked: (plan?: RequiredPlan) => boolean
|
||||
guard: (
|
||||
|
|
@ -922,7 +1053,46 @@ function NotionSourceCard({
|
|||
onConnect={guard("pro", "Notion", () =>
|
||||
connectRealProvider("notion", "notion"),
|
||||
)}
|
||||
footerRight={<SpaceChip name="Company Notion" />}
|
||||
footerRight={
|
||||
<SpaceChip name={mode === "team" ? "Team Brain" : "My Brain"} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GranolaSourceCard({
|
||||
state,
|
||||
isLocked,
|
||||
guard,
|
||||
onOpen,
|
||||
}: {
|
||||
state: SourceState
|
||||
isLocked: (plan?: RequiredPlan) => boolean
|
||||
guard: (
|
||||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
) => () => void
|
||||
onOpen: () => void
|
||||
}) {
|
||||
return (
|
||||
<SourceCard
|
||||
title="Granola"
|
||||
blurb="Meeting notes into searchable decisions."
|
||||
icon={<Granola className="size-6" />}
|
||||
state={state}
|
||||
ctaLabel="Connect"
|
||||
locked={isLocked("pro")}
|
||||
requiredPlan="pro"
|
||||
perks={[
|
||||
"Meeting notes auto-captured",
|
||||
"Decisions and action items extracted",
|
||||
"Synced after every meeting",
|
||||
]}
|
||||
onConnect={guard("pro", "Granola", () => {
|
||||
analytics.onboardingIntegrationClicked({ integration: "granola" })
|
||||
onOpen()
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -936,6 +1106,7 @@ function MoreSourcesGrid({
|
|||
openExternal,
|
||||
requestWaitlist,
|
||||
connectRealProvider,
|
||||
onOpenGranola,
|
||||
}: {
|
||||
mode: BrainMode
|
||||
values: SourcesValues
|
||||
|
|
@ -952,6 +1123,7 @@ function MoreSourcesGrid({
|
|||
provider: "google-drive" | "notion" | "onedrive",
|
||||
id: SourceId,
|
||||
) => void
|
||||
onOpenGranola: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -999,20 +1171,14 @@ function MoreSourcesGrid({
|
|||
/>
|
||||
{mode === "personal" ? (
|
||||
<GoogleDriveSourceCard
|
||||
mode={mode}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
) : (
|
||||
<NotionSourceCard
|
||||
values={values}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<SourceCard
|
||||
title="OneDrive"
|
||||
blurb="Office docs from OneDrive."
|
||||
|
|
@ -1060,46 +1226,18 @@ function MoreSourcesGrid({
|
|||
]}
|
||||
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
|
||||
/>
|
||||
<SourceCard
|
||||
title="Granola"
|
||||
blurb="Meeting notes into searchable decisions."
|
||||
icon={<Granola className="size-6" />}
|
||||
state="idle"
|
||||
ctaLabel="Connect"
|
||||
locked={isLocked("max")}
|
||||
requiredPlan="max"
|
||||
perks={[
|
||||
"Meeting notes auto-captured",
|
||||
"Decisions and action items extracted",
|
||||
"Synced after every meeting",
|
||||
]}
|
||||
onConnect={guard("max", "Granola", () => {
|
||||
analytics.onboardingIntegrationClicked({ integration: "granola" })
|
||||
toast.info("Granola is coming soon.")
|
||||
})}
|
||||
/>
|
||||
{mode === "personal" ? (
|
||||
<GranolaSourceCard
|
||||
state={values.connected.granola ?? "idle"}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
onOpen={onOpenGranola}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RoutingChip({ workspaceName }: { workspaceName: string }) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[12px]"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
title="All sources route to your brain. You can carve out spaces after setup."
|
||||
>
|
||||
<Logo className="size-3.5 text-[#8B8B8B]" />
|
||||
<span className="text-[#737373] font-medium">Routing to</span>
|
||||
<span className="text-[#fafafa] font-semibold">
|
||||
{workspaceName || "your brain"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceCard({
|
||||
title,
|
||||
blurb,
|
||||
|
|
@ -1167,8 +1305,8 @@ function SourceCard({
|
|||
{headerNote}
|
||||
</div>
|
||||
{isDone ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-[#4BA0FA] font-semibold uppercase tracking-[0.08em] shrink-0 mt-1">
|
||||
<Check className="size-3" />
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
|
||||
<Check className="size-3.5" />
|
||||
{state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export function StepTeam({
|
|||
const count = values.invites.length
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-5">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
|
||||
style={modalCardStyle}
|
||||
|
|
@ -294,38 +294,38 @@ export function StepTeam({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-end gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip ?? onContinue}
|
||||
disabled={submitting}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{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>
|
||||
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip ?? onContinue}
|
||||
disabled={submitting}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
Tag,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
|
|
@ -174,6 +175,10 @@ export default function Account() {
|
|||
setIsEditingOrgName(false)
|
||||
}, [org?.name])
|
||||
|
||||
// Deep link: ?invite=1 (e.g. from the dashboard) opens the invite dialog.
|
||||
// Consumed below, once the role is known and only for admins/owners.
|
||||
const [inviteParam, setInviteParam] = useQueryState("invite")
|
||||
|
||||
const activeMemberRoleQuery = useQuery({
|
||||
queryKey: ["organization", org?.id, "active-member-role"],
|
||||
queryFn: async () => {
|
||||
|
|
@ -209,6 +214,18 @@ export default function Account() {
|
|||
const canManageTeam = currentRole === "owner" || currentRole === "admin"
|
||||
const isOwner = currentRole === "owner"
|
||||
|
||||
// Consume ?invite=1 only after the role resolves, and only for managers.
|
||||
useEffect(() => {
|
||||
if (inviteParam !== "1" || activeMemberRoleQuery.isLoading) return
|
||||
if (canManageTeam) setInviteDialogOpen(true)
|
||||
setInviteParam(null)
|
||||
}, [
|
||||
inviteParam,
|
||||
activeMemberRoleQuery.isLoading,
|
||||
canManageTeam,
|
||||
setInviteParam,
|
||||
])
|
||||
|
||||
const pendingInvitations = useMemo(
|
||||
() => (org?.invitations ?? []).filter(isPendingInvitation),
|
||||
[org?.invitations],
|
||||
|
|
|
|||
|
|
@ -273,7 +273,8 @@ export default function CompanyBrainConnections() {
|
|||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
if (connRes.ok) {
|
||||
setRows(((await connRes.json()) as { toolkits: ConnRow[] }).toolkits)
|
||||
const data = (await connRes.json()) as { toolkits?: ConnRow[] }
|
||||
setRows(Array.isArray(data.toolkits) ? data.toolkits : [])
|
||||
} else {
|
||||
setRows([])
|
||||
toast.error("Couldn't load connections.")
|
||||
|
|
|
|||
19
apps/web/hooks/use-connector-access.ts
Normal file
19
apps/web/hooks/use-connector-access.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
||||
// Connector entitlement (pro tier or company_brain) — mirrors backend canAccessConnector. Not for plugins.
|
||||
export function useConnectorAccess(opts?: { enabled?: boolean }) {
|
||||
const enabled = opts?.enabled ?? true
|
||||
const autumn = useCustomer({ queryOptions: { enabled } })
|
||||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
|
||||
return {
|
||||
hasPro,
|
||||
hasMax,
|
||||
hasCompanyBrain,
|
||||
connectorAccess: hasPro || hasCompanyBrain,
|
||||
loading: enabled && autumn.isLoading,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue