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