diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx index 95ade05e..926b2a7a 100644 --- a/apps/web/components/menu.tsx +++ b/apps/web/components/menu.tsx @@ -398,7 +398,7 @@ function Menu({ id }: { id?: string }) { {/* Menu content */} -
+
diff --git a/apps/web/components/views/integrations.tsx b/apps/web/components/views/integrations.tsx index e1949a16..5ad141f3 100644 --- a/apps/web/components/views/integrations.tsx +++ b/apps/web/components/views/integrations.tsx @@ -1,3 +1,4 @@ +import { $fetch } from "@lib/api"; import { authClient } from "@lib/auth"; import { useAuth } from "@lib/auth-context"; import { generateId } from "@lib/generate-id"; @@ -5,19 +6,53 @@ 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, - DialogTitle, DialogPortal, + DialogTitle, } from "@repo/ui/components/dialog"; -import { useMutation } from "@tanstack/react-query"; -import { Check, Copy, Smartphone, X } from "lucide-react"; +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 { useId, useState } from "react"; +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 }) => ( ( 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); @@ -60,6 +98,119 @@ export function IntegrationsView() { >(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 the documents 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({ @@ -125,10 +276,10 @@ export function IntegrationsView() { }; return ( -
+
{/* iOS Shortcuts */}
-
+
@@ -142,7 +293,7 @@ export function IntegrationsView() {

-
+
+ + )} + + {/* 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