Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/app/(dashboard)/contacts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
Expand Down Expand Up @@ -49,6 +52,7 @@ import {
SlidersHorizontal,
Filter,
X,
Tags,
} from 'lucide-react';
import { ContactForm } from '@/components/contacts/contact-form';
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
Expand Down Expand Up @@ -314,6 +318,48 @@ export default function ContactsPage() {
setBulkDeleteOpen(false);
}

async function handleBulkAddTag(tagId: string) {
const ids = [...selected];
if (ids.length === 0) return;

const rows = ids.map((contactId) => ({
contact_id: contactId,
tag_id: tagId,
}));

const { error } = await supabase.from('contact_tags').upsert(rows, {
onConflict: 'contact_id,tag_id',
ignoreDuplicates: true,
});

if (error) {
toast.error(t('toastBulkTagAddFailed'));
} else {
toast.success(t('toastBulkTagAdded', { count: ids.length }));
setSelected(new Set());
fetchContacts();
}
}

async function handleBulkRemoveTag(tagId: string) {
const ids = [...selected];
if (ids.length === 0) return;

const { error } = await supabase
.from('contact_tags')
.delete()
.in('contact_id', ids)
.eq('tag_id', tagId);

if (error) {
toast.error(t('toastBulkTagRemoveFailed'));
} else {
toast.success(t('toastBulkTagRemoved', { count: ids.length }));
setSelected(new Set());
fetchContacts();
}
}

const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const hasNext = page < totalPages - 1;
const hasPrev = page > 0;
Expand Down Expand Up @@ -513,6 +559,77 @@ export default function ContactsPage() {
>
{t('clearSelection')}
</Button>

<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
disabled={!canEdit}
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
}
>
<Tags className="size-4" />
{t('bulkTags')}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-popover border-border min-w-40">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-popover-foreground focus:bg-muted focus:text-foreground">
{t('addTag')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="bg-popover border-border max-h-64 overflow-y-auto">
{allTags.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No tags available
</div>
) : (
allTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => handleBulkAddTag(tag.id)}
className="text-popover-foreground focus:bg-muted focus:text-foreground"
>
<span
className="size-2 rounded-full mr-2"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-popover-foreground focus:bg-muted focus:text-foreground">
{t('removeTag')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="bg-popover border-border max-h-64 overflow-y-auto">
{allTags.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No tags available
</div>
) : (
allTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => handleBulkRemoveTag(tag.id)}
className="text-popover-foreground focus:bg-muted focus:text-foreground"
>
<span
className="size-2 rounded-full mr-2"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>

<GatedButton
variant="destructive"
size="sm"
Expand Down
96 changes: 45 additions & 51 deletions src/app/(dashboard)/inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@/lib/inbox/conversations";
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
import { useRealtime } from "@/hooks/use-realtime";
import { useAuth } from "@/hooks/use-auth";
import { ConversationList } from "@/components/inbox/conversation-list";
import { MessageThread } from "@/components/inbox/message-thread";
import { ContactSidebar } from "@/components/inbox/contact-sidebar";
Expand All @@ -36,6 +37,7 @@ function InboxPageInner() {
const t = useTranslations("Inbox.page");
const router = useRouter();
const searchParams = useSearchParams();
const { user, loading } = useAuth();
/**
* `?c=<id>` deep-link support. Used when landing here from the
* dashboard's recent-conversations list so the right thread opens
Expand Down Expand Up @@ -172,45 +174,7 @@ function InboxPageInner() {
}
}, []);

// Check WhatsApp connection status on mount
useEffect(() => {
const checkConnection = async () => {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;

if (!user) return;

// whatsapp_config is one-row-per-account post-multi-user, so
// the previous `.eq('user_id', user.id)` would miss the row
// for any teammate who didn't personally save the config —
// the "WhatsApp not connected" banner would show in the
// shared inbox even though the admin had it configured.
// Resolve account_id via the profile and query by that.
const { data: profile } = await supabase
.from("profiles")
.select("account_id")
.eq("user_id", user.id)
.maybeSingle();
const accountId = profile?.account_id as string | undefined;
if (!accountId) {
setWhatsappConnected(false);
return;
}

const { data } = await supabase
.from("whatsapp_config")
.select("status")
.eq("account_id", accountId)
.maybeSingle();

setWhatsappConnected(data?.status === "connected");
};

checkConnection();
}, []);

// Handle realtime message events
const handleMessageEvent = useCallback(
Expand Down Expand Up @@ -244,14 +208,14 @@ function InboxPageInner() {
prev.map((c) =>
c.id === newMsg.conversation_id
? {
...c,
last_message_text: newMsg.content_text ?? "",
last_message_at: newMsg.created_at,
unread_count:
activeConversation?.id === newMsg.conversation_id
? 0
: c.unread_count + 1,
}
...c,
last_message_text: newMsg.content_text ?? "",
last_message_at: newMsg.created_at,
unread_count:
activeConversation?.id === newMsg.conversation_id
? 0
: c.unread_count + 1,
}
: c,
),
);
Expand Down Expand Up @@ -311,10 +275,10 @@ function InboxPageInner() {
prev.map((c) =>
c.id === conv.id
? {
...c,
...conv,
unread_count: isActive ? 0 : conv.unread_count,
}
...c,
...conv,
unread_count: isActive ? 0 : conv.unread_count,
}
: c,
),
);
Expand Down Expand Up @@ -345,7 +309,7 @@ function InboxPageInner() {
channelName: "inbox-realtime",
onMessageEvent: handleMessageEvent,
onConversationEvent: handleConversationEvent,
enabled: true,
enabled: !loading && !!user,
});

/**
Expand Down Expand Up @@ -390,6 +354,36 @@ function InboxPageInner() {
};
}, []);

/**
* Periodic resync safety net — refreshes active thread and list every 20 seconds
* when the page is visible, acting as a fallback for slow/blocked websocket events.
* Also polls /api/whatsapp/config?quick=true to keep the Vercel server warm so webhooks
* are delivered instantly without cold-start timeouts.
*/
useEffect(() => {
if (loading || !user) return;

const checkStatus = async () => {
try {
const res = await fetch("/api/whatsapp/config?quick=true");
if (res.status === 401) return;
const data = await res.json();
setWhatsappConnected(data.connected === true);
} catch (err) {
console.error("Failed to check WhatsApp config:", err);
}
};
checkStatus();

const interval = setInterval(() => {
if (document.visibilityState === "visible") {
setResyncToken((n) => n + 1);
checkStatus();
}
}, 60000);
return () => clearInterval(interval);
}, [user, loading]);

/**
* Manual refresh trigger for the thread-header refresh button.
* Bumps the same resyncToken the reconnect / visibility paths use,
Expand Down
18 changes: 18 additions & 0 deletions src/app/(dashboard)/webhooks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { WebhookTabs } from '@/components/webhooks/webhook-tabs';

export default function WebhooksPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Webhook Integration</h1>
<p className="mt-1 text-sm text-slate-400">
Connect your account with various systems and applications to trigger WhatsApp templates in real time.
</p>
</div>

<WebhookTabs />
</div>
);
}
59 changes: 59 additions & 0 deletions src/app/api/webhooks/config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';

export async function GET() {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle();
if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 });

const { data: config, error } = await supabase
.from('webhook_integrations')
.select('*')
.eq('account_id', profile.account_id)
.maybeSingle();

if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ config });
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 });
}
}

export async function POST(request: Request) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const { data: profile } = await supabase.from('profiles').select('account_id').eq('user_id', user.id).maybeSingle();
if (!profile?.account_id) return NextResponse.json({ error: 'No account linked' }, { status: 403 });

const { name, whatsapp_config_id, hmac_secret, default_phone_prefix } = await request.json();
if (!name) return NextResponse.json({ error: 'Integration Name is required' }, { status: 400 });

const { data: config, error } = await supabase
.from('webhook_integrations')
.upsert(
{
account_id: profile.account_id,
name,
whatsapp_config_id: whatsapp_config_id || null,
hmac_secret: hmac_secret || null,
default_phone_prefix: default_phone_prefix || '91',
updated_at: new Date().toISOString()
},
{ onConflict: 'account_id' }
)
.select()
.single();

if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ config });
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500 });
}
}
Loading