whatsapp-integration-card.tsx9.2 KBView on GitHub
'use client';

import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle2, Loader2, MessageCircle, Plus, Unplug } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useState } from 'react';
import { toast } from 'sonner';

import { useWhatsappConnect, type WhatsappLineMode } from '@/modules/integrations/use-whatsapp-connect';

/**
 * Connect a WhatsApp line via Unipile hosted auth. The user scans the QR / signs in on
 * the hosted-auth tab; we finalize by polling `whatsapp.syncAccounts` (which reconciles
 * from Unipile without needing a public webhook) and then re-reading `whatsapp.accounts`.
 * The connect+poll flow lives in `useWhatsappConnect` (shared with the inbox prompt).
 */
export function WhatsAppIntegrationCard() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const [lineMode, setLineMode] = useState<WhatsappLineMode>('shared');
  const [disconnectingId, setDisconnectingId] = useState<string | null>(null);

  const { accounts, refetchAccounts, connectedCount, isConnecting, handleConnect } =
    useWhatsappConnect();

  const { mutateAsync: disconnect } = useMutation(
    trpc.outbound.whatsapp.disconnect.mutationOptions(),
  );

  const handleDisconnect = async (id: string) => {
    try {
      setDisconnectingId(id);
      await disconnect({ id });
      await refetchAccounts();
      void queryClient.invalidateQueries({ queryKey=[redacted] });
    } catch (error) {
      toast.error(
        `Failed to disconnect: ${error instanceof Error ? error.message : String(error)}`,
      );
    } finally {
      setDisconnectingId(null);
    }
  };

  return (
    <div className="space-y-4">
      {connectedCount > 0 && (
        <div className="space-y-2">
          {accounts
            ?.filter((a) => a.status === 'connected')
            .map((account) => (
              <div
                key=[redacted]
                className="bg-popover flex items-center justify-between gap-3 rounded-lg border p-4"
              >
                <div className="flex min-w-0 items-center gap-3">
                  <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#25D366]/10">
                    <MessageCircle className="h-5 w-5 text-[#25D366]" />
                  </div>
                  <div className="flex min-w-0 flex-col gap-0.5">
                    <span className="truncate text-sm font-medium">
                      {account.ownerPhoneE164 ?? account.name ?? account.unipileAccountId}
                    </span>
                    <div className="text-muted-foreground flex items-center gap-2 text-xs">
                      {account.isHealthy ? (
                        <span className="flex items-center gap-1 text-green-600">
                          <CheckCircle2 className="h-3 w-3" /> Healthy
                        </span>
                      ) : (
                        <span className="flex items-center gap-1 text-amber-600">
                          <AlertTriangle className="h-3 w-3" />
                          {account.unhealthyReason ?? 'Needs attention'}
                        </span>
                      )}
                      <span className="capitalize">· {account.lineMode} line</span>
                    </div>
                    <SyncAllChatsToggle
                      unipileAccountId={account.unipileAccountId}
                      enabled={account.syncAllChats === true}
                    />
                  </div>
                </div>
                <Dialog>
                  <DialogTrigger asChild>
                    <Button
                      variant="ghost"
                      size="icon"
                      className="text-muted-foreground hover:text-primary shrink-0"
                      disabled={disconnectingId === account.id}
                    >
                      {disconnectingId === account.id ? (
                        <Loader2 className="h-4 w-4 animate-spin" />
                      ) : (
                        <Unplug className="h-4 w-4" />
                      )}
                    </Button>
                  </DialogTrigger>
                  <DialogContent>
                    <DialogHeader>
                      <DialogTitle>Disconnect WhatsApp</DialogTitle>
                      <DialogDescription>
                        Are you sure you want to disconnect this WhatsApp line?
                      </DialogDescription>
                    </DialogHeader>
                    <div className="flex justify-end gap-4">
                      <DialogClose asChild>
                        <Button variant="outline">Cancel</Button>
                      </DialogClose>
                      <DialogClose asChild>
                        <Button onClick={() => handleDisconnect(account.id)}>Disconnect</Button>
                      </DialogClose>
                    </div>
                  </DialogContent>
                </Dialog>
              </div>
            ))}
        </div>
      )}

      {/* Line mode — shared (rep's own number) vs dedicated (a number only for Cedar) */}
      <div className="space-y-2 rounded-lg border bg-muted/30 p-4">
        <Label className="text-sm font-medium">Line type</Label>
        <div className="grid grid-cols-2 gap-2">
          {(['shared', 'dedicated'] as const).map((mode) => (
            <button
              key=[redacted]
              type="button"
              onClick={() => setLineMode(mode)}
              disabled={isConnecting}
              className={`rounded-lg border p-3 text-left text-sm transition-colors ${
                lineMode === mode
                  ? 'border-primary bg-primary/5'
                  : 'hover:border-primary/40 border-border'
              }`}
            >
              <div className="font-medium capitalize">{mode}</div>
              <div className="text-muted-foreground text-xs">
                {mode === 'shared'
                  ? 'Your everyday WhatsApp number'
                  : 'A separate number just for Cedar'}
              </div>
            </button>
          ))}
        </div>
      </div>

      <Button
        size="lg"
        className="flex w-full items-center gap-3 border border-gray-200 bg-white text-gray-800 shadow-sm hover:bg-gray-50"
        onClick={() => handleConnect(lineMode)}
        disabled={isConnecting}
      >
        {isConnecting ? (
          <>
            <Loader2 className="h-5 w-5 animate-spin" />
            <span>Waiting for WhatsApp…</span>
          </>
        ) : (
          <>
            {connectedCount > 0 ? (
              <Plus className="h-5 w-5" />
            ) : (
              <MessageCircle className="h-5 w-5 text-[#25D366]" />
            )}
            <span>{connectedCount > 0 ? 'Connect another line' : 'Connect WhatsApp'}</span>
          </>
        )}
      </Button>

      {isConnecting && (
        <p className="text-muted-foreground text-center text-xs">
          Scan the QR code on the WhatsApp tab — this updates automatically once connected.
        </p>
      )}
    </div>
  );
}

/**
 * "Sync every chat on this line."
 *
 * WhatsApp defaults to per-chat consent because one personal number carries business and
 * private conversations, and Cedar should not read the private ones by default. This is the
 * rep's own override of that default — and only of that default: it changes which chats are
 * INDEXED, never which are attached to a deal, which stays a manual act either way. Enabling
 * back-fills chats already on record, so the switch takes effect immediately rather than only
 * on the next inbound message.
 */
function SyncAllChatsToggle({
  unipileAccountId,
  enabled,
}: {
  unipileAccountId: string;
  enabled: boolean;
}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const id = `wa-sync-all-${unipileAccountId}`;

  const { mutate, isPending } = useMutation(
    trpc.outbound.whatsapp.setSyncAllChats.mutationOptions({
      onSuccess: () => {
        void queryClient.invalidateQueries({
          queryKey=[redacted],
        });
      },
      onError: (error: unknown) =>
        toast.error(
          `Could not change the setting: ${error instanceof Error ? error.message : String(error)}`,
        ),
    }),
  );

  return (
    <div className="mt-2 flex items-start gap-2">
      <Switch
        id={id}
        checked={enabled}
        disabled={isPending}
        onCheckedChange={(checked) => mutate({ unipileAccountId, enabled: checked })}
        className="mt-0.5 cursor-pointer"
      />
      <Label htmlFor={id} className="cursor-pointer text-xs font-normal">
        <span className="text-foreground">Sync every chat</span>
        <span className="text-muted-foreground block">
          Off by default — Cedar reads a chat only once you allow it. Linking a chat to a deal
          stays manual either way.
        </span>
      </Label>
    </div>
  );
}