mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat: enhanced the integration page for more conversion (#899)
This commit is contained in:
parent
a605909191
commit
805cf3cb93
13 changed files with 580 additions and 625 deletions
|
|
@ -172,7 +172,7 @@ function buildSpotlightCatalog(
|
|||
analytics.onboardingIntegrationClicked({ integration })
|
||||
|
||||
const openPluginsPanel = () => {
|
||||
void router.push("/?view=integrations&plugins=true")
|
||||
void router.push("/?view=plugins")
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -229,7 +229,7 @@ function buildSpotlightCatalog(
|
|||
pro: true,
|
||||
onOpen: () => {
|
||||
track("connections")
|
||||
void router.push("/?view=integrations&integration=connections")
|
||||
void router.push("/?view=connections")
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -272,7 +272,7 @@ function buildSpotlightCatalog(
|
|||
icon: <img src="/onboarding/x.png" alt="X" className="size-10" />,
|
||||
onOpen: () => {
|
||||
track("import_x")
|
||||
void router.push("/?view=integrations&integration=import")
|
||||
void router.push("/?view=import")
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -10,12 +10,19 @@ import {
|
|||
} from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Header } from "@/components/header"
|
||||
import { Header, PublicHeader } from "@/components/header"
|
||||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
import { IntegrationsView } from "@/components/integrations-view"
|
||||
import { IntegrationsView, DetailWrapper } from "@/components/integrations-view"
|
||||
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
|
||||
import { ChromeDetail } from "@/components/integrations/chrome-detail"
|
||||
import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail"
|
||||
import { RaycastDetail } from "@/components/integrations/raycast-detail"
|
||||
import { ConnectionsDetail } from "@/components/integrations/connections-detail"
|
||||
import { PluginsDetail } from "@/components/integrations/plugins-detail"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { AddDocumentModal } from "@/components/add-document"
|
||||
import { DocumentModal } from "@/components/document-modal"
|
||||
|
|
@ -50,8 +57,6 @@ import {
|
|||
qParam,
|
||||
docParam,
|
||||
fullscreenParam,
|
||||
integrationParam,
|
||||
pluginsPanelParam,
|
||||
type IntegrationParamValue,
|
||||
} from "@/lib/search-params"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
|
|
@ -148,20 +153,6 @@ export default function NewPage() {
|
|||
"fullscreen",
|
||||
fullscreenParam,
|
||||
)
|
||||
const [integrationFromUrl, setIntegration] = useQueryState(
|
||||
"integration",
|
||||
integrationParam,
|
||||
)
|
||||
const [pluginsPanelFromUrl, setPluginsPanel] = useQueryState(
|
||||
"plugins",
|
||||
pluginsPanelParam,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (integrationFromUrl || pluginsPanelFromUrl === true) {
|
||||
void setViewMode("integrations")
|
||||
}
|
||||
}, [integrationFromUrl, pluginsPanelFromUrl, setViewMode])
|
||||
|
||||
// Ephemeral local state (not worth URL-encoding)
|
||||
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
|
||||
|
|
@ -494,20 +485,14 @@ export default function NewPage() {
|
|||
|
||||
const handleOpenIntegrations = useCallback(
|
||||
(integration?: IntegrationParamValue) => {
|
||||
setViewMode("integrations")
|
||||
if (integration) {
|
||||
setIntegration(integration)
|
||||
} else {
|
||||
setIntegration(null)
|
||||
}
|
||||
void setViewMode(integration ?? "integrations")
|
||||
},
|
||||
[setViewMode, setIntegration],
|
||||
[setViewMode],
|
||||
)
|
||||
|
||||
const handleOpenPlugins = useCallback(() => {
|
||||
void setViewMode("integrations")
|
||||
void setPluginsPanel(true)
|
||||
}, [setViewMode, setPluginsPanel])
|
||||
void setViewMode("plugins")
|
||||
}, [setViewMode])
|
||||
|
||||
const handleAddMemory = useCallback(
|
||||
(tab: "note" | "link") => {
|
||||
|
|
@ -564,16 +549,20 @@ export default function NewPage() {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
<Header
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
{!session && viewMode === "mcp" ? (
|
||||
<PublicHeader />
|
||||
) : (
|
||||
<Header
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={`main-container-${viewMode}`}
|
||||
|
|
@ -611,6 +600,44 @@ export default function NewPage() {
|
|||
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
|
||||
<IntegrationsView />
|
||||
</div>
|
||||
) : viewMode === "mcp" ? (
|
||||
<MCPDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "plugins" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<PluginsDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "chrome" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<ChromeDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "shortcuts" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<ShortcutsDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "raycast" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<RaycastDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "connections" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<ConnectionsDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "import" ? (
|
||||
<XBookmarksDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "graph" && !isMobile ? (
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<GraphLayoutView />
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ function AuthConnectContent() {
|
|||
</button>
|
||||
|
||||
<a
|
||||
href="https://app.supermemory.ai/?plugins=true"
|
||||
href="https://app.supermemory.ai/?view=plugins"
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { session, organizations, isRestoring } = useAuth()
|
||||
|
||||
const isMcpPublicPage = searchParams.get("view") === "mcp"
|
||||
|
||||
useEffect(() => {
|
||||
if (isMcpPublicPage) return
|
||||
if (isRestoring) return
|
||||
if (!session) {
|
||||
router.replace(
|
||||
|
|
@ -21,7 +25,7 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
if (organizations.length > 0) return
|
||||
if (pathname.startsWith("/onboarding")) return
|
||||
router.replace("/onboarding")
|
||||
}, [session, organizations, isRestoring, pathname, router])
|
||||
}, [session, organizations, isRestoring, pathname, router, isMcpPublicPage])
|
||||
|
||||
return children
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,11 +187,37 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === mode}
|
||||
aria-selected={
|
||||
mode === "integrations"
|
||||
? [
|
||||
"integrations",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
].includes(viewMode)
|
||||
: viewMode === mode
|
||||
}
|
||||
onClick={() => void setViewMode(mode)}
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
|
||||
viewMode === mode
|
||||
(
|
||||
mode === "integrations"
|
||||
? [
|
||||
"integrations",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
].includes(viewMode)
|
||||
: viewMode === mode
|
||||
)
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "text-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
|
|
@ -375,3 +401,56 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicHeader() {
|
||||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-2 p-2.5 md:p-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Logo className="h-6 md:h-7" />
|
||||
<div className="hidden sm:flex flex-col items-start">
|
||||
<p className="text-[10px] leading-tight text-[#6B6B6B]">Your AI</p>
|
||||
<p className="-mt-0.5 text-base leading-none font-medium text-white/90">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"hidden md:block text-[13px] text-[#4B5563]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Connect your tools, search everything.
|
||||
</p>
|
||||
<Link href="/login">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-[13px] font-medium text-[#8B8B8B] hover:text-white transition-colors px-3 h-8 cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/login/new">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-[13px] font-medium text-white",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 rounded-full px-4 h-8 transition-colors cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Get started free
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
|
||||
import { ChromeDetail } from "@/components/integrations/chrome-detail"
|
||||
import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail"
|
||||
import { RaycastDetail } from "@/components/integrations/raycast-detail"
|
||||
import { ConnectionsDetail } from "@/components/integrations/connections-detail"
|
||||
import { PluginsDetail } from "@/components/integrations/plugins-detail"
|
||||
import {
|
||||
ChromeIcon,
|
||||
AppleShortcutsIcon,
|
||||
|
|
@ -19,13 +18,14 @@ import {
|
|||
} from "@/components/integration-icons"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { ArrowLeft, Sun } from "lucide-react"
|
||||
import {
|
||||
integrationParam,
|
||||
pluginsPanelParam,
|
||||
type IntegrationParamValue,
|
||||
} from "@/lib/search-params"
|
||||
import { CHROME_EXTENSION_URL } from "@repo/lib/constants"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import Image from "next/image"
|
||||
import { IntegrationGridCard } from "@/components/integrations/integration-grid-card"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import type { ViewParamValue } from "@/lib/search-params"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
type CardId =
|
||||
| "mcp"
|
||||
|
|
@ -42,6 +42,7 @@ interface IntegrationCardDef {
|
|||
description: string
|
||||
icon: React.ReactNode
|
||||
pro?: boolean
|
||||
externalHref?: string
|
||||
}
|
||||
|
||||
const cards: IntegrationCardDef[] = [
|
||||
|
|
@ -109,6 +110,7 @@ const cards: IntegrationCardDef[] = [
|
|||
title: "Chrome Extension",
|
||||
description: "Save any webpage, import bookmarks, sync ChatGPT memories",
|
||||
icon: <ChromeIcon className="size-14" />,
|
||||
externalHref: CHROME_EXTENSION_URL,
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
|
|
@ -130,7 +132,7 @@ const cards: IntegrationCardDef[] = [
|
|||
},
|
||||
]
|
||||
|
||||
function DetailWrapper({
|
||||
export function DetailWrapper({
|
||||
onBack,
|
||||
children,
|
||||
}: {
|
||||
|
|
@ -154,74 +156,91 @@ function DetailWrapper({
|
|||
)
|
||||
}
|
||||
|
||||
const INTEGRATION_TO_CARD: Record<IntegrationParamValue, CardId> = {
|
||||
import: "import",
|
||||
chrome: "chrome",
|
||||
connections: "connections",
|
||||
}
|
||||
const CARD_GROUPS: Array<{ label: string; ids: CardId[] }> = [
|
||||
{ label: "AI tools", ids: ["plugins", "mcp"] },
|
||||
{
|
||||
label: "Apps & extensions",
|
||||
ids: ["connections", "chrome", "shortcuts", "raycast", "import"],
|
||||
},
|
||||
]
|
||||
|
||||
export function IntegrationsView() {
|
||||
const [integration, setIntegration] = useQueryState(
|
||||
"integration",
|
||||
integrationParam,
|
||||
)
|
||||
const [pluginsPanel, setPluginsPanel] = useQueryState(
|
||||
"plugins",
|
||||
pluginsPanelParam,
|
||||
)
|
||||
const [selectedCard, setSelectedCard] = useState<CardId | null>(null)
|
||||
const { setViewMode } = useViewMode()
|
||||
const { org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const hasProProduct = hasActivePlan(autumn.customer?.products, "api_pro")
|
||||
|
||||
useEffect(() => {
|
||||
if (pluginsPanel === true) {
|
||||
setSelectedCard("plugins")
|
||||
return
|
||||
const { data: connections = [] } = useQuery({
|
||||
queryKey: ["connections"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: [] },
|
||||
})
|
||||
if (response.error)
|
||||
throw new Error(response.error?.message || "Failed to load connections")
|
||||
return response.data as Connection[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
enabled: hasProProduct,
|
||||
})
|
||||
|
||||
const { data: facetsData } = useQuery({
|
||||
queryKey: ["document-facets", []],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/documents/documents/facets", {
|
||||
body: { containerTags: [] },
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error)
|
||||
throw new Error(response.error?.message || "Failed to fetch facets")
|
||||
return response.data as {
|
||||
facets: Array<{ category: string; count: number }>
|
||||
total: number
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
type ApiKey = { metadata: Record<string, unknown> | null }
|
||||
const { data: apiKeys = [] } = useQuery({
|
||||
queryKey: ["api-keys", org?.id],
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const data = (await authClient.apiKey.list({
|
||||
fetchOptions: { query: { metadata: { organizationId: org.id } } },
|
||||
})) as unknown as ApiKey[]
|
||||
return data.filter((key) => key.metadata?.organizationId === org.id)
|
||||
},
|
||||
enabled: !!org?.id,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const connectedPluginCount = apiKeys.filter(
|
||||
(key) => key.metadata?.sm_type === "plugin_auth",
|
||||
).length
|
||||
|
||||
const tweetCount =
|
||||
facetsData?.facets.find((f) => f.category === "tweet")?.count ?? 0
|
||||
|
||||
const getStatusLabel = (
|
||||
id: CardId,
|
||||
): { label: string; variant: "connected" | "neutral" } | undefined => {
|
||||
if (id === "connections" && hasProProduct) {
|
||||
return connections.length > 0
|
||||
? { label: `${connections.length} connected`, variant: "connected" }
|
||||
: { label: "Not connected", variant: "neutral" }
|
||||
}
|
||||
if (integration && INTEGRATION_TO_CARD[integration]) {
|
||||
setSelectedCard(INTEGRATION_TO_CARD[integration])
|
||||
if (id === "import") {
|
||||
return tweetCount > 0
|
||||
? { label: `${tweetCount} tweets imported`, variant: "connected" }
|
||||
: undefined
|
||||
}
|
||||
}, [integration, pluginsPanel])
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedCard(null)
|
||||
setIntegration(null)
|
||||
void setPluginsPanel(null)
|
||||
}
|
||||
|
||||
switch (selectedCard) {
|
||||
case "mcp":
|
||||
return <MCPDetailView onBack={handleBack} />
|
||||
case "import":
|
||||
return <XBookmarksDetailView onBack={handleBack} />
|
||||
case "chrome":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<ChromeDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
case "shortcuts":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<ShortcutsDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
case "raycast":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<RaycastDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
case "connections":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<ConnectionsDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
case "plugins":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<PluginsDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
if (id === "plugins") {
|
||||
return connectedPluginCount > 0
|
||||
? { label: `${connectedPluginCount} connected`, variant: "connected" }
|
||||
: undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -237,17 +256,51 @@ export function IntegrationsView() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{cards.map((card) => (
|
||||
<IntegrationGridCard
|
||||
key={card.id}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
icon={card.icon}
|
||||
pro={card.pro}
|
||||
onClick={() => setSelectedCard(card.id)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-6">
|
||||
{CARD_GROUPS.map((group) => {
|
||||
const groupCards = cards.filter((c) => group.ids.includes(c.id))
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455] shrink-0">
|
||||
{group.label}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-[#0F1621]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{groupCards.map((card) => {
|
||||
const status = getStatusLabel(card.id)
|
||||
return (
|
||||
<IntegrationGridCard
|
||||
key={card.id}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
icon={card.icon}
|
||||
pro={card.pro}
|
||||
statusLabel={status?.label}
|
||||
statusVariant={status?.variant}
|
||||
isExternal={!!card.externalHref}
|
||||
onClick={() => {
|
||||
if (card.externalHref) {
|
||||
window.open(
|
||||
card.externalHref,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
analytics.onboardingChromeExtensionClicked({
|
||||
source: "integrations",
|
||||
})
|
||||
} else {
|
||||
void setViewMode(card.id as ViewParamValue)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { hasActivePlan } from "@lib/queries"
|
|||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Check, Plus, Trash2, Zap } from "lucide-react"
|
||||
import { Check, Clock, FolderOpen, Plus, Trash2, Zap } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQueryState } from "nuqs"
|
||||
|
|
@ -68,10 +68,10 @@ function ConnectionRow({
|
|||
}
|
||||
|
||||
const getProjectName = (tag: string): string => {
|
||||
if (tag === DEFAULT_PROJECT_ID) return "Default Project"
|
||||
if (tag === DEFAULT_PROJECT_ID) return "Default"
|
||||
return (
|
||||
projects.find((p) => p.containerTag === tag)?.name ??
|
||||
tag.replace(/^sm_project_/, "")
|
||||
tag.replace(/^sm_project_/, "").replace(/_/g, " ")
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -136,31 +136,48 @@ function ConnectionRow({
|
|||
<Trash2 className="size-[22px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{projectName && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 pt-2.5 border-t border-[rgba(82,89,102,0.12)]">
|
||||
<div className="flex items-center gap-2 flex-1 flex-wrap">
|
||||
{projectName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpen className="size-3 text-[#4B5563]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373] capitalize",
|
||||
)}
|
||||
>
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="size-3 text-[#4B5563]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] text-[#737373]",
|
||||
"text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Project: {projectName}
|
||||
{formatRelativeTime(connection.createdAt)}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
|
||||
>
|
||||
Added: {formatRelativeTime(connection.createdAt)}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
|
||||
>
|
||||
{documentCount} {config.documentLabel} connected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{documentCount}
|
||||
</span>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
>
|
||||
{config.documentLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,18 +3,25 @@
|
|||
import type { ReactNode } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
|
||||
export function IntegrationGridCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
pro,
|
||||
statusLabel,
|
||||
statusVariant = "neutral",
|
||||
isExternal,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: ReactNode
|
||||
pro?: boolean
|
||||
statusLabel?: string
|
||||
statusVariant?: "connected" | "neutral"
|
||||
isExternal?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -35,6 +42,9 @@ export function IntegrationGridCard({
|
|||
PRO
|
||||
</span>
|
||||
) : null}
|
||||
{isExternal ? (
|
||||
<ExternalLink className="absolute top-3 left-3 size-3 text-[#3A4455] opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
) : null}
|
||||
<div className="absolute top-2 right-2 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
{icon}
|
||||
</div>
|
||||
|
|
@ -48,6 +58,18 @@ export function IntegrationGridCard({
|
|||
>
|
||||
{description}
|
||||
</p>
|
||||
{statusLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block mt-2 text-[10px] font-medium px-1.5 py-0.5 rounded-full",
|
||||
statusVariant === "connected"
|
||||
? "bg-[#00AC3F]/10 text-[#00AC3F]"
|
||||
: "bg-[#737373]/10 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,12 +10,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Check,
|
||||
CheckCircle,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Key,
|
||||
Loader,
|
||||
Trash2,
|
||||
Zap,
|
||||
|
|
@ -30,7 +28,6 @@ import {
|
|||
DialogTitle,
|
||||
DialogPortal,
|
||||
} from "@ui/components/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs"
|
||||
|
||||
/** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */
|
||||
function isFreeTierPlugin(pluginId: string): boolean {
|
||||
|
|
@ -52,11 +49,11 @@ const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
id: "claude_code",
|
||||
name: "Claude Code",
|
||||
description:
|
||||
"Persistent memory for Claude Code. Remembers your coding context, patterns, and decisions across sessions.",
|
||||
"Claude Code remembers your conventions, past decisions, and project context across every session — no re-explaining yourself.",
|
||||
features: [
|
||||
"Auto-recalls relevant context at session start",
|
||||
"Captures important observations from tool usage",
|
||||
"Builds persistent user profile from interactions",
|
||||
"Picks up where you left off at session start",
|
||||
"Captures decisions and patterns from tool usage",
|
||||
"Builds a persistent profile of how you work",
|
||||
],
|
||||
icon: "/images/plugins/claude-code.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
|
||||
|
|
@ -66,11 +63,11 @@ const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description:
|
||||
"Memory layer for OpenCode. Enhances your coding assistant with long-term memory capabilities.",
|
||||
"Gives OpenCode persistent memory — your patterns, preferences, and decisions carry forward automatically, session to session.",
|
||||
features: [
|
||||
"Semantic search across previous sessions",
|
||||
"Auto-capture of coding decisions",
|
||||
"Context injection before each prompt",
|
||||
"Context injected before each prompt",
|
||||
],
|
||||
icon: "/images/plugins/opencode.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/opencode",
|
||||
|
|
@ -79,11 +76,11 @@ const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
description:
|
||||
"Multi-platform memory for OpenClaw. Works across Telegram, WhatsApp, Discord, Slack and more.",
|
||||
"Persists memory across Telegram, WhatsApp, Discord, and Slack. OpenClaw knows who users are and what they talked about before.",
|
||||
features: [
|
||||
"Cross-channel memory persistence",
|
||||
"Cross-channel memory that follows the user",
|
||||
"Automatic conversation capture",
|
||||
"User profile building across platforms",
|
||||
"User profiles built across every platform",
|
||||
],
|
||||
icon: "/images/plugins/openclaw.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/openclaw",
|
||||
|
|
@ -92,11 +89,12 @@ const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
hermes: {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
description: "Memory layer for Hermes agent",
|
||||
description:
|
||||
"Hermes never forgets. Conversations, user profiles, and context persist so every session feels like a continuation, not a cold start.",
|
||||
features: [
|
||||
"Semantic search across previous sessions",
|
||||
"Auto-capture of conversation context",
|
||||
"Builds persistent user profile from interactions",
|
||||
"Persistent user profile built over time",
|
||||
],
|
||||
icon: "/images/plugins/hermes.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/hermes",
|
||||
|
|
@ -113,119 +111,27 @@ interface ConnectedPlugin {
|
|||
keyStart?: string | null
|
||||
}
|
||||
|
||||
function ProUpgradeBanner({ onUpgrade }: { onUpgrade: () => void }) {
|
||||
function ProUpgradeNudge({ onUpgrade }: { onUpgrade: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-gradient-to-br from-[#0D121A] to-[#14161A] rounded-[14px] p-6 border border-[#4BA0FA]/20 mb-6",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#4BA0FA]/10 shrink-0">
|
||||
<Zap className="size-6 text-[#4BA0FA]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Unlock Pro plugins
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] text-[#737373] mt-1",
|
||||
)}
|
||||
>
|
||||
Connect Claude Code, OpenCode, OpenClaw, Cursor, and more with a
|
||||
Pro plan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Brain,
|
||||
title: "Context Retention",
|
||||
desc: "AI remembers your preferences across sessions",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Instant Recall",
|
||||
desc: "Past decisions surface automatically when relevant",
|
||||
},
|
||||
{
|
||||
icon: Key,
|
||||
title: "Secure & Private",
|
||||
desc: "Your data stays yours with encrypted storage",
|
||||
},
|
||||
].map(({ icon: Icon, title, desc }) => (
|
||||
<div key={title} className="flex items-start gap-2.5">
|
||||
<Icon className="mt-0.5 size-4 text-[#4BA0FA] shrink-0" />
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{Object.values(PLUGIN_CATALOG)
|
||||
.filter((p) => !isFreeTierPlugin(p.id))
|
||||
.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]"
|
||||
>
|
||||
<Image
|
||||
alt={plugin.name}
|
||||
className="size-5"
|
||||
height={20}
|
||||
src={plugin.icon}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
>
|
||||
Claude Code, OpenCode, OpenClaw & more
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
className={cn(
|
||||
"w-full sm:w-auto flex items-center justify-center gap-2",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
|
||||
"rounded-full h-11 px-6 font-medium text-sm transition-colors cursor-pointer",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-3 bg-[#4BA0FA]/5 border border-[#4BA0FA]/20 rounded-xl px-4 py-3 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="size-4 text-[#4BA0FA] shrink-0" />
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#8B8B8B]")}>
|
||||
Unlock Claude Code, OpenCode, OpenClaw and more with{" "}
|
||||
<span className="text-white font-medium">Pro</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
className={cn(
|
||||
"shrink-0 flex items-center gap-1.5 text-[12px] font-medium text-white",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 rounded-full px-3 h-7 transition-colors cursor-pointer",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -321,11 +227,20 @@ function PluginCard({
|
|||
<div
|
||||
className={cn(
|
||||
"bg-[#0D121A] rounded-[12px] p-4 flex flex-col gap-3 border",
|
||||
isConnected ? "border-[#4BA0FA]/30" : "border-[rgba(82,89,102,0.2)]",
|
||||
isConnected
|
||||
? "border-[#4BA0FA]/30"
|
||||
: needsProUpgrade
|
||||
? "border-[rgba(82,89,102,0.12)]"
|
||||
: "border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]",
|
||||
needsProUpgrade && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
alt={plugin.name}
|
||||
className="size-6"
|
||||
|
|
@ -339,7 +254,8 @@ function PluginCard({
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] text-[#FAFAFA]",
|
||||
"font-medium text-[14px]",
|
||||
needsProUpgrade ? "text-[#737373]" : "text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{plugin.name}
|
||||
|
|
@ -349,11 +265,17 @@ function PluginCard({
|
|||
<CheckCircle className="size-2.5" /> Connected
|
||||
</span>
|
||||
)}
|
||||
{needsProUpgrade && (
|
||||
<span className="text-[10px] font-bold text-[#00171A] bg-[#4BA0FA] px-1.5 py-0.5 rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373] mt-0.5",
|
||||
"text-[12px] mt-0.5",
|
||||
needsProUpgrade ? "text-[#4B5563]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{plugin.description}
|
||||
|
|
@ -361,7 +283,7 @@ function PluginCard({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
<ul className={cn("space-y-1.5", needsProUpgrade && "opacity-40")}>
|
||||
{plugin.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<ArrowRight className="mt-0.5 size-3 shrink-0 text-[#4BA0FA]" />
|
||||
|
|
@ -524,16 +446,6 @@ export function PluginsDetail() {
|
|||
[connectedPlugins],
|
||||
)
|
||||
|
||||
const freeConnected = useMemo(
|
||||
() => connectedPlugins.filter((p) => isFreeTierPlugin(p.pluginId)),
|
||||
[connectedPlugins],
|
||||
)
|
||||
|
||||
const proConnected = useMemo(
|
||||
() => connectedPlugins.filter((p) => !isFreeTierPlugin(p.pluginId)),
|
||||
[connectedPlugins],
|
||||
)
|
||||
|
||||
const createPluginKeyMutation = useMutation({
|
||||
mutationFn: async (pluginId: string) => {
|
||||
const API_URL =
|
||||
|
|
@ -607,31 +519,11 @@ export function PluginsDetail() {
|
|||
const isLoading = autumn.isLoading
|
||||
const availablePlugins = pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG)
|
||||
|
||||
const freePluginIds = useMemo(() => {
|
||||
const ids = new Set(
|
||||
availablePlugins.filter(
|
||||
(id) => PLUGIN_CATALOG[id] && isFreeTierPlugin(id),
|
||||
),
|
||||
)
|
||||
if (PLUGIN_CATALOG.hermes) ids.add("hermes")
|
||||
return [...ids]
|
||||
}, [availablePlugins])
|
||||
|
||||
const proPluginIds = useMemo(
|
||||
() =>
|
||||
availablePlugins.filter(
|
||||
(id) => PLUGIN_CATALOG[id] && !isFreeTierPlugin(id),
|
||||
),
|
||||
[availablePlugins],
|
||||
)
|
||||
|
||||
const allCatalogPluginIds = useMemo(
|
||||
() => availablePlugins.filter((id) => PLUGIN_CATALOG[id]),
|
||||
[availablePlugins],
|
||||
)
|
||||
|
||||
const showPaidAllInOne = !isLoading && hasProProduct
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -640,221 +532,66 @@ export function PluginsDetail() {
|
|||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{showPaidAllInOne ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
{connectedPlugins.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
{connectedPlugins.map((plugin) => (
|
||||
<ConnectedPluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
info={PLUGIN_CATALOG[plugin.pluginId]}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!hasProProduct && !isLoading && (
|
||||
<ProUpgradeNudge onUpgrade={handleUpgrade} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{connectedPlugins.length > 0
|
||||
? "Add more plugins"
|
||||
: "Available plugins"}
|
||||
</span>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{allCatalogPluginIds.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const isConnected = connectedPluginIds.includes(pluginId)
|
||||
const isCurrentlyConnecting = connectingPlugin === pluginId
|
||||
return (
|
||||
<PluginCard
|
||||
key={pluginId}
|
||||
plugin={plugin}
|
||||
pluginId={pluginId}
|
||||
isConnected={isConnected}
|
||||
isCurrentlyConnecting={isCurrentlyConnecting}
|
||||
connectingPlugin={connectingPlugin}
|
||||
needsProUpgrade={false}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="free" className="gap-0">
|
||||
<TabsList
|
||||
{connectedPlugins.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-auto w-full grid-cols-2 gap-0 rounded-none border-0 border-b border-[#252a33] bg-transparent p-0",
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger
|
||||
value="free"
|
||||
className={cn(
|
||||
"relative flex min-h-12 w-full min-w-0 cursor-pointer items-center justify-center rounded-none border-0 border-transparent bg-transparent px-3 py-3 text-[15px] font-medium shadow-none",
|
||||
"text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
"data-[state=active]:bg-transparent data-[state=active]:text-[#FAFAFA] data-[state=active]:shadow-none",
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-t-[1px] after:bg-[#4BA0FA] after:opacity-0 data-[state=active]:after:opacity-100",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Free plugins
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="pro"
|
||||
className={cn(
|
||||
"relative flex min-h-12 w-full min-w-0 cursor-pointer items-center justify-center rounded-none border-0 border-transparent bg-transparent px-3 py-3 text-[15px] font-medium shadow-none",
|
||||
"text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
"data-[state=active]:bg-transparent data-[state=active]:text-[#FAFAFA] data-[state=active]:shadow-none",
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-t-[1px] after:bg-[#4BA0FA] after:opacity-0 data-[state=active]:after:opacity-100",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Pro plugins
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="free" className="mt-5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] text-[#737373] mb-4",
|
||||
)}
|
||||
>
|
||||
Included on every plan — connect with no upgrade.
|
||||
</p>
|
||||
|
||||
{freeConnected.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
{freeConnected.map((plugin) => (
|
||||
<ConnectedPluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
info={PLUGIN_CATALOG[plugin.pluginId]}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{freeConnected.length > 0 ? "Add or manage" : "Available"}
|
||||
</span>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{freePluginIds.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const isConnected = connectedPluginIds.includes(pluginId)
|
||||
const isCurrentlyConnecting = connectingPlugin === pluginId
|
||||
return (
|
||||
<PluginCard
|
||||
key={pluginId}
|
||||
plugin={plugin}
|
||||
pluginId={pluginId}
|
||||
isConnected={isConnected}
|
||||
isCurrentlyConnecting={isCurrentlyConnecting}
|
||||
connectingPlugin={connectingPlugin}
|
||||
needsProUpgrade={false}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pro" className="mt-5">
|
||||
{!hasProProduct && !isLoading && (
|
||||
<ProUpgradeBanner onUpgrade={handleUpgrade} />
|
||||
)}
|
||||
|
||||
{proConnected.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
{proConnected.map((plugin) => (
|
||||
<ConnectedPluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
info={PLUGIN_CATALOG[plugin.pluginId]}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{proConnected.length > 0 ? "Add more" : "Available plugins"}
|
||||
</span>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{proPluginIds.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const isConnected = connectedPluginIds.includes(pluginId)
|
||||
const isCurrentlyConnecting = connectingPlugin === pluginId
|
||||
const needsProUpgrade = !hasProProduct
|
||||
return (
|
||||
<PluginCard
|
||||
key={pluginId}
|
||||
plugin={plugin}
|
||||
pluginId={pluginId}
|
||||
isConnected={isConnected}
|
||||
isCurrentlyConnecting={isCurrentlyConnecting}
|
||||
connectingPlugin={connectingPlugin}
|
||||
needsProUpgrade={needsProUpgrade}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
Connected
|
||||
</span>
|
||||
{connectedPlugins.map((plugin) => (
|
||||
<ConnectedPluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
info={PLUGIN_CATALOG[plugin.pluginId]}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{connectedPlugins.length > 0
|
||||
? "Add more plugins"
|
||||
: "Available plugins"}
|
||||
</span>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{allCatalogPluginIds.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const isConnected = connectedPluginIds.includes(pluginId)
|
||||
const isCurrentlyConnecting = connectingPlugin === pluginId
|
||||
const needsProUpgrade =
|
||||
!isLoading && !hasProProduct && !isFreeTierPlugin(pluginId)
|
||||
return (
|
||||
<PluginCard
|
||||
key={pluginId}
|
||||
plugin={plugin}
|
||||
pluginId={pluginId}
|
||||
isConnected={isConnected}
|
||||
isCurrentlyConnecting={isCurrentlyConnecting}
|
||||
connectingPlugin={connectingPlugin}
|
||||
needsProUpgrade={needsProUpgrade}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -652,31 +652,33 @@ interface MCPDetailViewProps {
|
|||
|
||||
export function MCPDetailView({ onBack }: MCPDetailViewProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6 md:p-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto self-start p-0 text-[#FAFAFA] hover:text-[#BFBFBF] hover:no-underline"
|
||||
onClick={onBack}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 w-full flex-col p-6 md:p-8">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto self-start p-0 text-[#FAFAFA] hover:text-[#BFBFBF] hover:no-underline"
|
||||
onClick={onBack}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-start">
|
||||
<h1
|
||||
className={cn(
|
||||
"mb-1 text-2xl font-semibold tracking-[-0.02em] text-[#FAFAFA]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Connect Supermemory MCP to your AI Tools
|
||||
</h1>
|
||||
<p className={cn("mb-4 text-sm text-[#737373]", dmSansClassName())}>
|
||||
Connect Cursor, Claude, VS Code, and more via MCP.
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col items-center justify-start">
|
||||
<h1
|
||||
className={cn(
|
||||
"mb-1 text-2xl font-semibold tracking-[-0.02em] text-[#FAFAFA]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Connect Supermemory MCP to your AI Tools
|
||||
</h1>
|
||||
<p className={cn("mb-4 text-sm text-[#737373]", dmSansClassName())}>
|
||||
Connect Cursor, Claude, VS Code, and more via MCP.
|
||||
</p>
|
||||
|
||||
<MCPSteps variant="full" />
|
||||
<MCPSteps variant="full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,75 +34,77 @@ export function XBookmarksDetailView({ onBack }: XBookmarksDetailViewProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-8">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer"
|
||||
onClick={onBack}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start justify-start flex-1">
|
||||
<div>
|
||||
<h1 className="text-white text-[20px] font-medium mb-3 text-start">
|
||||
Import your X bookmarks via the Chrome Extension
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm mb-6 text-start max-w-2xl",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
<div className="flex flex-col flex-1 w-full p-8">
|
||||
<div className="mx-auto w-full max-w-3xl flex flex-col flex-1">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer"
|
||||
onClick={onBack}
|
||||
>
|
||||
Bring your X bookmarks into Supermemory in just a few clicks.
|
||||
They'll be automatically embedded so you can easily find what you
|
||||
need, right when you need it.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 max-w-5xl w-full">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className="flex flex-col items-center text-center bg-[#080B0F] p-3 rounded-[10px]"
|
||||
>
|
||||
<div className="rounded-2xl p-6 mb-3 w-full aspect-4/4 flex items-center justify-center relative overflow-hidden">
|
||||
<Image
|
||||
src={step.image}
|
||||
alt={`Step ${step.number}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="mb-2">
|
||||
<span className="text-white text-sm font-medium">
|
||||
Step {step.number}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm text-start",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="rounded-xl px-4 py-2 text-white h-10 cursor-pointer mx-auto bg-black"
|
||||
onClick={handleInstall}
|
||||
>
|
||||
Install Chrome Extension →
|
||||
</Button>
|
||||
<div className="flex flex-col items-start justify-start flex-1">
|
||||
<div>
|
||||
<h1 className="text-white text-[20px] font-medium mb-3 text-start">
|
||||
Import your X bookmarks via the Chrome Extension
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm mb-6 text-start max-w-2xl",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Bring your X bookmarks into Supermemory in just a few clicks.
|
||||
They'll be automatically embedded so you can easily find what you
|
||||
need, right when you need it.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 max-w-5xl w-full">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className="flex flex-col items-center text-center bg-[#080B0F] p-3 rounded-[10px]"
|
||||
>
|
||||
<div className="rounded-2xl p-6 mb-3 w-full aspect-4/4 flex items-center justify-center relative overflow-hidden">
|
||||
<Image
|
||||
src={step.image}
|
||||
alt={`Step ${step.number}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="mb-2">
|
||||
<span className="text-white text-sm font-medium">
|
||||
Step {step.number}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm text-start",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="rounded-xl px-4 py-2 text-white h-10 cursor-pointer mx-auto bg-black"
|
||||
onClick={handleInstall}
|
||||
>
|
||||
Install Chrome Extension →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,21 +29,28 @@ const viewLiterals = [
|
|||
"list",
|
||||
"integrations",
|
||||
"chat",
|
||||
] as const
|
||||
const integrationLiterals = [
|
||||
"import",
|
||||
// Integration sub-views — each card is its own view
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"notion",
|
||||
"google-drive",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
] as const
|
||||
export type IntegrationParamValue = (typeof integrationLiterals)[number]
|
||||
export const integrationParam = parseAsStringLiteral(integrationLiterals)
|
||||
export type ViewParamValue = (typeof viewLiterals)[number]
|
||||
export const viewParam =
|
||||
parseAsStringLiteral(viewLiterals).withDefault("dashboard")
|
||||
|
||||
export const pluginsPanelParam = parseAsBoolean
|
||||
// Kept for backwards compat with components that pass integration hints
|
||||
export type IntegrationParamValue =
|
||||
| "mcp"
|
||||
| "plugins"
|
||||
| "chrome"
|
||||
| "connections"
|
||||
| "shortcuts"
|
||||
| "raycast"
|
||||
| "import"
|
||||
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
|
||||
[],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export default async function proxy(request: Request) {
|
|||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// MCP setup page is public — no auth required
|
||||
if (url.searchParams.get("view") === "mcp") {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
if (!sessionCookie) {
|
||||
console.debug("[MIDDLEWARE] API route without session, returning 401")
|
||||
|
|
@ -66,6 +71,6 @@ export default async function proxy(request: Request) {
|
|||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails).*)",
|
||||
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails|mcp-supported-tools|mcp-icon.svg).*)",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue