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>
  );
}