diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx index 9b9abd81..926b2a7a 100644 --- a/apps/web/components/menu.tsx +++ b/apps/web/components/menu.tsx @@ -9,7 +9,7 @@ import { Button } from "@repo/ui/components/button"; import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold"; import { GlassMenuEffect } from "@ui/other/glass-effect"; import { useCustomer } from "autumn-js/react"; -import { MessageSquareMore, Plus, User, X } from "lucide-react"; +import { MessageSquareMore, Plus, Puzzle, User, X } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useEffect, useState } from "react"; import { Drawer } from "vaul"; @@ -19,6 +19,7 @@ import { useChatOpen } from "@/stores"; import { ProjectSelector } from "./project-selector"; import { useTour } from "./tour"; import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"; +import { IntegrationsView } from "./views/integrations"; import { MCPView } from "./views/mcp"; import { ProfileView } from "./views/profile"; @@ -41,7 +42,7 @@ const MCPIcon = ({ className }: { className?: string }) => { function Menu({ id }: { id?: string }) { const [isHovered, setIsHovered] = useState(false); const [expandedView, setExpandedView] = useState< - "addUrl" | "mcp" | "projects" | "profile" | null + "addUrl" | "mcp" | "projects" | "profile" | "integrations" | null >(null); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isCollapsing, setIsCollapsing] = useState(false); @@ -52,12 +53,12 @@ function Menu({ id }: { id?: string }) { const autumn = useCustomer(); const { setIsOpen } = useChatOpen(); - const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any); + const { data: memoriesCheck } = fetchMemoriesFeature(autumn); const memoriesUsed = memoriesCheck?.usage ?? 0; const memoriesLimit = memoriesCheck?.included_usage ?? 0; - const { data: proCheck } = fetchConsumerProProduct(autumn as any); + const { data: proCheck } = fetchConsumerProProduct(autumn); useEffect(() => { if (memoriesCheck) { @@ -79,6 +80,7 @@ function Menu({ id }: { id?: string }) { addUrl: TOUR_STEP_IDS.MENU_ADD_MEMORY, projects: TOUR_STEP_IDS.MENU_PROJECTS, mcp: TOUR_STEP_IDS.MENU_MCP, + integrations: "", // No tour ID for integrations yet }; const menuItems = [ @@ -94,6 +96,12 @@ function Menu({ id }: { id?: string }) { key: "chat" as const, disabled: false, }, + { + icon: Puzzle, + text: "Integrations", + key: "integrations" as const, + disabled: false, + }, { icon: MCPIcon, text: "MCP", @@ -109,7 +117,7 @@ function Menu({ id }: { id?: string }) { ]; const handleMenuItemClick = ( - key: "chat" | "addUrl" | "mcp" | "projects" | "profile", + key: "chat" | "addUrl" | "mcp" | "projects" | "profile" | "integrations", ) => { if (key === "chat") { setIsOpen(true); @@ -365,6 +373,7 @@ function Menu({ id }: { id?: string }) { {expandedView === "mcp" && "Model Context Protocol"} {expandedView === "profile" && "Profile"} + {expandedView === "integrations" && "Integrations"} {expandedView === "mcp" && } {expandedView === "profile" && } + {expandedView === "integrations" && ( + + )} )} @@ -485,7 +497,7 @@ function Menu({ id }: { id?: string }) { {/* Menu content */} -
+
@@ -590,6 +603,8 @@ function Menu({ id }: { id?: string }) { {expandedView === "mcp" && "Model Context Protocol"} {expandedView === "profile" && "Profile"} + {expandedView === "integrations" && + "Integrations"}
diff --git a/apps/web/components/views/integrations.tsx b/apps/web/components/views/integrations.tsx new file mode 100644 index 00000000..5174aeca --- /dev/null +++ b/apps/web/components/views/integrations.tsx @@ -0,0 +1,669 @@ +import { $fetch } from "@lib/api"; +import { authClient } from "@lib/auth"; +import { useAuth } from "@lib/auth-context"; +import { generateId } from "@lib/generate-id"; +import { + ADD_MEMORY_SHORTCUT_URL, + SEARCH_MEMORY_SHORTCUT_URL, +} from "@repo/lib/constants"; +import { + fetchConnectionsFeature, + fetchConsumerProProduct, +} from "@repo/lib/queries"; +import { Button } from "@repo/ui/components/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogPortal, + DialogTitle, +} from "@repo/ui/components/dialog"; +import { Skeleton } from "@repo/ui/components/skeleton"; +import type { ConnectionResponseSchema } from "@repo/validation/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"; +import { useCustomer } from "autumn-js/react"; +import { Check, Copy, Smartphone, Trash2 } from "lucide-react"; +import { motion } from "motion/react"; +import Image from "next/image"; +import { useEffect, useId, useState } from "react"; +import { toast } from "sonner"; +import type { z } from "zod"; +import { analytics } from "@/lib/analytics"; +import { useProject } from "@/stores"; + +type Connection = z.infer; + +const CONNECTORS = { + "google-drive": { + title: "Google Drive", + description: "Connect your Google Docs, Sheets, and Slides", + icon: GoogleDrive, + }, + notion: { + title: "Notion", + description: "Import your Notion pages and databases", + icon: Notion, + }, + onedrive: { + title: "OneDrive", + description: "Access your Microsoft Office documents", + icon: OneDrive, + }, +} as const; + +type ConnectorProvider = keyof typeof CONNECTORS; + +const ChromeIcon = ({ className }: { className?: string }) => ( + + Google Chrome Icon + + + + + + +); + +export function IntegrationsView() { + const { org } = useAuth(); + const queryClient = useQueryClient(); + const { selectedProject } = useProject(); + const autumn = useCustomer(); + const [showApiKeyModal, setShowApiKeyModal] = useState(false); + const [apiKey, setApiKey] = useState(""); + const [copied, setCopied] = useState(false); + const [selectedShortcutType, setSelectedShortcutType] = useState< + "add" | "search" | null + >(null); + const apiKeyId = useId(); + + const handleUpgrade = async () => { + try { + await autumn.attach({ + productId: "consumer_pro", + successUrl: "https://app.supermemory.ai/", + }); + window.location.reload(); + } catch (error) { + console.error(error); + } + }; + + const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any); + const connectionsUsed = connectionsCheck?.balance ?? 0; + const connectionsLimit = connectionsCheck?.included_usage ?? 0; + + const { data: proCheck } = fetchConsumerProProduct(autumn as any); + const isProUser = proCheck?.allowed ?? false; + + const canAddConnection = connectionsUsed < connectionsLimit; + + const { + data: connections = [], + isLoading: connectionsLoading, + error: connectionsError, + } = 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, + refetchInterval: 60 * 1000, + }); + + useEffect(() => { + if (connectionsError) { + toast.error("Failed to load connections", { + description: + connectionsError instanceof Error + ? connectionsError.message + : "Unknown error", + }); + } + }, [connectionsError]); + + const addConnectionMutation = useMutation({ + mutationFn: async (provider: ConnectorProvider) => { + if (!canAddConnection && !isProUser) { + throw new Error( + "Free plan doesn't include connections. Upgrade to Pro for unlimited connections.", + ); + } + + const response = await $fetch("@post/connections/:provider", { + params: { provider }, + body: { + redirectUrl: window.location.href, + containerTags: [selectedProject], + }, + }); + + // biome-ignore lint/style/noNonNullAssertion: its fine + if ("data" in response && !("error" in response.data!)) { + return response.data; + } + + throw new Error(response.error?.message || "Failed to connect"); + }, + onSuccess: (data, provider) => { + analytics.connectionAdded(provider); + analytics.connectionAuthStarted(); + if (data?.authLink) { + window.location.href = data.authLink; + } + }, + onError: (error, provider) => { + analytics.connectionAuthFailed(); + toast.error(`Failed to connect ${provider}`, { + description: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + const deleteConnectionMutation = useMutation({ + mutationFn: async (connectionId: string) => { + await $fetch(`@delete/connections/${connectionId}`); + }, + onSuccess: () => { + analytics.connectionDeleted(); + toast.success( + "Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes.", + ); + queryClient.invalidateQueries({ queryKey: ["connections"] }); + }, + onError: (error) => { + toast.error("Failed to remove connection", { + description: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + const createApiKeyMutation = useMutation({ + mutationFn: async () => { + const res = await authClient.apiKey.create({ + metadata: { + organizationId: org?.id, + type: "ios-shortcut", + }, + name: `ios-${generateId().slice(0, 8)}`, + prefix: `sm_${org?.id}_`, + }); + return res.key; + }, + onSuccess: (apiKey) => { + setApiKey(apiKey); + setShowApiKeyModal(true); + setCopied(false); + handleCopyApiKey(); + }, + onError: (error) => { + toast.error("Failed to create API key", { + description: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + const handleShortcutClick = (shortcutType: "add" | "search") => { + setSelectedShortcutType(shortcutType); + createApiKeyMutation.mutate(); + }; + + const handleCopyApiKey = async () => { + try { + await navigator.clipboard.writeText(apiKey); + setCopied(true); + toast.success("API key copied to clipboard!"); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Failed to copy API key"); + } + }; + + const handleOpenShortcut = () => { + if (!selectedShortcutType) { + toast.error("No shortcut type selected"); + return; + } + + if (selectedShortcutType === "add") { + window.open(ADD_MEMORY_SHORTCUT_URL, "_blank"); + } else if (selectedShortcutType === "search") { + window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank"); + } + }; + + const handleDialogClose = (open: boolean) => { + setShowApiKeyModal(open); + if (!open) { + // Reset state when dialog closes + setSelectedShortcutType(null); + setApiKey(""); + setCopied(false); + } + }; + + return ( +
+ {/* iOS Shortcuts */} +
+
+
+
+ +
+
+

+ Apple shortcuts +

+

+ Add memories directly from iPhone, iPad or Mac. +

+
+
+
+ + +
+
+
+ + {/* Chrome Extension */} +
+
+
+
+ +
+
+
+

+ Chrome Extension +

+
+ Coming Soon +
+
+

+ Save web content with one click +

+
+
+
+
+ + {/* Connections Section */} +
+
+
+
+ + Connection Link Icon + + +
+
+

+ Connections +

+

+ Connect your accounts to sync document. +

+ {!isProUser && ( +

+ Connections require a Pro subscription +

+ )} +
+
+ + {/* Show upgrade prompt for free users */} + {!isProUser && ( + +

+ 🔌 Connections are a Pro feature +

+

+ Connect Google Drive, Notion, OneDrive and more to automatically + sync your documents. +

+ +
+ )} + + {/* All Connections with Status */} + {connectionsLoading ? ( +
+ {Object.keys(CONNECTORS).map((_, i) => ( + + + + ))} +
+ ) : ( +
+ {Object.entries(CONNECTORS).map(([provider, config], index) => { + const Icon = config.icon; + const connection = connections.find( + (conn) => conn.provider === provider, + ); + const isConnected = !!connection; + + return ( + +
+ + + +
+
+

+ {config.title} +

+ {isConnected ? ( +
+
+ + Connected + +
+ ) : ( +
+
+ + Disconnected + +
+ )} +
+

+ {config.description} +

+ {connection?.email && ( +

+ {connection.email} +

+ )} +
+
+ +
+ {isConnected ? ( + + + + ) : ( +
+
+
+ + Disconnected + +
+ + + +
+ )} +
+
+ ); + })} +
+ )} +
+
+ +
+

+ More integrations are coming soon! Have a suggestion? Share it with us + on{" "} + + X + + . +

+
+ + {/* API Key Modal */} + + + + + + Setup{" "} + {selectedShortcutType === "add" + ? "Add Memory" + : selectedShortcutType === "search" + ? "Search Memory" + : "iOS"}{" "} + Shortcut + + + +
+ {/* API Key Section */} +
+ +
+ + +
+
+ + {/* Steps */} +
+

+ Follow these steps: +

+
+
+
+ 1 +
+

+ Click "Add to Shortcuts" below to open the shortcut +

+
+
+
+ 2 +
+

+ Paste your API key when prompted +

+
+
+
+ 3 +
+

+ Start using your shortcut! +

+
+
+
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/web/public/images/ios-shortcuts.png b/apps/web/public/images/ios-shortcuts.png new file mode 100644 index 00000000..044fb683 Binary files /dev/null and b/apps/web/public/images/ios-shortcuts.png differ diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 1bfd1ea8..fde5bce1 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -1,4 +1,13 @@ const BIG_DIMENSIONS_NEW = 1536 const DEFAULT_PROJECT_ID = "sm_project_default" +const SEARCH_MEMORY_SHORTCUT_URL = + "https://www.icloud.com/shortcuts/f2b5c544372844a38ab4c6900e2a88de" +const ADD_MEMORY_SHORTCUT_URL = + "https://www.icloud.com/shortcuts/ec33b029b2c7481d89eda7640dbb7688" -export { BIG_DIMENSIONS_NEW, DEFAULT_PROJECT_ID } +export { + BIG_DIMENSIONS_NEW, + DEFAULT_PROJECT_ID, + SEARCH_MEMORY_SHORTCUT_URL, + ADD_MEMORY_SHORTCUT_URL, +}