integration-card.tsx9.2 KBView on GitHub 'use client';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import type { ProviderId } from '../../../server/src/services/integrations/constants';
import { Loader2, Unplug, Check, AlertCircle, MessageSquare } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
interface IntegrationCapabilities {
hasListMeetings: boolean;
hasGetConnection: boolean;
}
export interface Integration {
id: string;
name: string;
type: string;
icon: string;
connected: boolean;
connectionId?: string;
capabilities: IntegrationCapabilities;
isPersonalConnection?: boolean;
}
interface IntegrationCardProps {
integration: Integration;
userId?: string;
onRefresh?: () => void;
}
export function IntegrationCard({ integration, userId, onRefresh }: IntegrationCardProps) {
const [isConnecting, setIsConnecting] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const trpc = useTRPC();
const queryClient = useQueryClient();
const { mutateAsync: initiateOAuth } = useMutation(
trpc.integrations.initiateOAuth.mutationOptions(),
);
const { mutateAsync: connect } = useMutation(trpc.integrations.connect.mutationOptions());
const { mutateAsync: deleteConnection } = useMutation(trpc.connections.delete.mutationOptions());
// Handle OAuth callback
useEffect(() => {
const handleWindowFocus = async () => {
const storageKey=[redacted];
const pendingAuth = sessionStorage.getItem(storageKey);
if (pendingAuth && isConnecting) {
console.log(
`[IntegrationCard] User returned from OAuth tab for ${integration.name}, checking connection...`,
);
try {
const { strataServerUrl } = JSON.parse(pendingAuth);
await connect({
providerId: integration.id as ProviderId,
connectionParams: { strataServerUrl },
userId,
});
sessionStorage.removeItem(storageKey);
setIsConnecting(false);
onRefresh?.();
// Invalidate queries to refresh list
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Ignore expected auth-in-progress errors
const isExpectedError =
errorMessage.includes('not authenticated') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('Please complete OAuth flow');
if (!isExpectedError) {
console.error(`[IntegrationCard] Error connecting ${integration.name}:`, error);
sessionStorage.removeItem(storageKey);
setIsConnecting(false);
toast.error(`Failed to connect ${integration.name}: ${errorMessage}`);
}
}
}
};
window.addEventListener('focus', handleWindowFocus);
return () => window.removeEventListener('focus', handleWindowFocus);
}, [
isConnecting,
connect,
integration.id,
integration.name,
onRefresh,
trpc,
userId,
queryClient,
]);
const handleConnect = async () => {
try {
setIsConnecting(true);
const { oauthUrl, strataServerUrl } = await initiateOAuth({
integration: integration.id,
userId,
});
if (!oauthUrl) {
setIsConnecting(false);
toast.error('Failed to get OAuth URL');
return;
}
sessionStorage.setItem(
`${integration.id}_oauth_pending`,
JSON.stringify({ strataServerUrl }),
);
window.open(oauthUrl, '_blank');
} catch (error) {
console.error(`[IntegrationCard] Error initiating auth for ${integration.name}:`, error);
setIsConnecting(false);
toast.error(`Failed to initiate connection for ${integration.name}`);
}
};
const handleDisconnect = async () => {
try {
setIsDisconnecting(true);
if (integration.connectionId) {
await deleteConnection({
connectionId: integration.connectionId,
userId,
});
onRefresh?.();
queryClient.invalidateQueries({ queryKey=[redacted] });
} else {
toast.error('Could not find connection ID to disconnect.');
}
} catch (error) {
console.error(`[IntegrationCard] Error disconnecting ${integration.name}:`, error);
toast.error(`Failed to disconnect ${integration.name}`);
} finally {
setIsDisconnecting(false);
}
};
// Icon mapping
const renderIcon = () => {
switch (integration.icon) {
case 'slack':
return <MessageSquare className="h-5 w-5" />;
case 'hubspot':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#FF7A59] text-xs font-bold text-white">
H
</div>
);
case 'attio':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-black text-xs font-bold text-white">
A
</div>
);
case 'fathom':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#FF4F00] text-xs font-bold text-white">
F
</div>
);
case 'krisp':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#0A84FF] text-xs font-bold text-white">
K
</div>
);
default:
return <div className="h-5 w-5 rounded-full bg-gray-200" />;
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
{renderIcon()}
<CardTitle className="text-base font-medium">{integration.name}</CardTitle>
</div>
<div className="flex items-center gap-2">
{integration.isPersonalConnection && integration.connected && (
<Badge
variant="outline"
className="bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-800"
>
Personal
</Badge>
)}
{integration.connected && (
<Badge
variant="secondary"
className="bg-green-100 text-green-800 hover:bg-green-200 dark:hover:bg-green-900/40 dark:bg-green-900/30 dark:text-green-300"
>
<Check className="mr-1 h-3 w-3" /> Connected
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<CardDescription>
{integration.type === 'crm'
? 'Customer Relationship Management'
: integration.type === 'meeting'
? 'Meeting Recorder'
: 'Communication'}
</CardDescription>
{/* Capabilities / Notes */}
<div className="space-y-1">
{!integration.capabilities.hasListMeetings && integration.type === 'meeting' && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
<span>Meeting listing not available via API</span>
</div>
)}
{/* Add more capability checks/notes here */}
</div>
{integration.connected ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="w-full" disabled={isDisconnecting}>
<Unplug className="mr-2 h-4 w-4" />
Disconnect
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Disconnect {integration.name}</DialogTitle>
<DialogDescription>
Are you sure you want to disconnect {integration.name}?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={handleDisconnect}>Disconnect</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
) : (
<Button className="w-full" onClick={handleConnect} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
'Connect'
)}
</Button>
)}
</div>
</CardContent>
</Card>
);
}