From 906e37e845bcdee64a1c66fe6b2669cabc1a6819 Mon Sep 17 00:00:00 2001 From: Hannah_Bagga Date: Thu, 25 Jun 2026 15:15:15 +0200 Subject: [PATCH] .... --- database/migrations/011_contacts.sql | 22 + src/components/chats/voice-call-modal.tsx | 474 ++++++++++++++++++++++ src/lib/supabase.ts | 17 + 3 files changed, 513 insertions(+) create mode 100644 database/migrations/011_contacts.sql create mode 100644 src/components/chats/voice-call-modal.tsx create mode 100644 src/lib/supabase.ts diff --git a/database/migrations/011_contacts.sql b/database/migrations/011_contacts.sql new file mode 100644 index 0000000..1f7086f --- /dev/null +++ b/database/migrations/011_contacts.sql @@ -0,0 +1,22 @@ +create table if not exists contacts ( + id uuid default gen_random_uuid() primary key, + user_id uuid references auth.users(id) on delete cascade, + name text not null, + phone text not null, + avatar_url text, + created_at timestamp default now() +); + +alter table contacts enable row level security; + +create policy "Users see own contacts" +on contacts for select +using (auth.uid() = user_id); + +create policy "Users insert own contacts" +on contacts for insert +with check (auth.uid() = user_id); + +create policy "Users delete own contacts" +on contacts for delete +using (auth.uid() = user_id); diff --git a/src/components/chats/voice-call-modal.tsx b/src/components/chats/voice-call-modal.tsx new file mode 100644 index 0000000..317b0e2 --- /dev/null +++ b/src/components/chats/voice-call-modal.tsx @@ -0,0 +1,474 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, X } from "lucide-react" +import { getSupabase } from "@/lib/supabase" +import { useWebRTCCall } from "@/hooks/useWebRTCCall" + +interface Contact { + id: string + name: string + phone: string + avatar_url: string | null +} + +interface VoiceCallModalProps { + open: boolean + onClose: () => void +} + +export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) { + const [contacts, setContacts] = useState([]) + const [contactsLoading, setContactsLoading] = useState(false) + const [contactsError, setContactsError] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [phoneNumber, setPhoneNumber] = useState("") + const [phoneError, setPhoneError] = useState(false) + const [lookupMessage, setLookupMessage] = useState(null) + const setLookupMessageWithReset = useCallback((msg: string | null) => { + setLookupMessage(msg) + if (msg !== "This phone number is not registered on the platform.") { + setInviteUrl(null) + } + }, []) + const [inviteUrl, setInviteUrl] = useState(null) + const [inviteLoading, setInviteLoading] = useState(false) + + const generateInvite = useCallback(async (phone: string) => { + setInviteLoading(true) + try { + const res = await fetch("/api/invite/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone }), + }) + const data = await res.json() + if (data.inviteUrl) { + setInviteUrl(data.inviteUrl) + } + } catch { + // fallback: generate client-side + const token = crypto.randomUUID?.() || Math.random().toString(36).slice(2) + setInviteUrl(`${window.location.origin}/join/${token}`) + } finally { + setInviteLoading(false) + } + }, []) + + const { + isReady, + isCallActive, + isIncoming, + isMuted, + isSpeakerOn, + callState, + callDuration, + error: callError, + callerInfo, + makeCall, + acceptCall, + rejectCall, + endCall, + toggleMute, + toggleSpeaker, + formatDuration, + } = useWebRTCCall() + + useEffect(() => { + if (!open && callState !== "idle") { + endCall() + } + }, [open, callState, endCall]) + + useEffect(() => { + if (!open) { + setSearchQuery("") + setPhoneNumber("") + setPhoneError(false) + return + } + const fetchContacts = async () => { + setContactsLoading(true) + setContactsError(false) + try { + const sb = getSupabase() + const { data, error } = await sb + .from("contacts") + .select("id, name, phone, avatar_url") + .order("name", { ascending: true }) + if (error) throw error + setContacts(data || []) + } catch { + setContactsError(true) + } finally { + setContactsLoading(false) + } + } + fetchContacts() + }, [open]) + + useEffect(() => { + if (!open) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [open, onClose]) + + const filteredContacts = contacts.filter( + (c) => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.phone.toLowerCase().includes(searchQuery.toLowerCase()), + ) + + if (!open) return null + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ + +

Start a Call

+

Search contacts or dial a number

+ +

+ CONTACTS +

+ + setSearchQuery(e.target.value)} + placeholder="Search contacts..." + className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]" + /> + +
+ {contactsLoading && ( +

Loading contacts...

+ )} + {contactsError && ( +

Could not load contacts

+ )} + {!contactsLoading && !contactsError && filteredContacts.length === 0 && ( +

No contacts found

+ )} + {!contactsLoading && !contactsError && filteredContacts.map((contact) => ( +
+
+ {contact.avatar_url ? ( + {contact.name} + ) : ( + contact.name.charAt(0).toUpperCase() + )} +
+
+

{contact.name}

+

{contact.phone}

+
+ +
+ ))} +
+ +
+
+ OR +
+
+ +

+ DIAL A NUMBER +

+ + { setPhoneNumber(e.target.value); setPhoneError(false) }} + placeholder="+27 000 000 0000" + className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]" + /> + {phoneError && ( +

Please enter a phone number

+ )} + + {isIncoming ? ( +
+
+ Incoming Call +
+
+
+ {callerInfo?.firstName?.charAt(0).toUpperCase()} +
+
+

+ {callerInfo?.firstName} {callerInfo?.lastName} +

+

{callerInfo?.username}

+
+
+

Ringing...

+
+ + +
+
+ ) : isCallActive ? ( +
+ {callState === "calling" && ( + <> +
+ {phoneNumber} +
+

Calling...

+
+ +
+ + )} + {callState === "ringing" && ( + <> +
+ {phoneNumber} +
+

Ringing...

+
+ +
+ + )} + {callState === "connecting" && ( + <> +
+ {phoneNumber} +
+

Connecting...

+
+ +
+ + )} + {callState === "connected" && ( + <> +
+ {phoneNumber} +
+
+ {formatDuration(callDuration)} +
+
+ + + +
+ + )} + {(callState === "ended" || callState === "missed" || callState === "rejected" || callState === "offline" || callState === "busy") && ( + <> +
+ {phoneNumber} +
+

+ {callState === "missed" && "This phone number is not registered on the platform."} + {callState === "offline" && "User is offline"} + {callState === "rejected" && "Call rejected"} + {callState === "busy" && "User is busy"} + {callState === "ended" && "Call ended"} +

+ {callState === "missed" && ( +
+ {!inviteUrl ? ( + + ) : ( +
+

+ {inviteUrl} +

+
+ + +
+
+ )} +
+ )} + + )} +
+ ) : ( + <> + {lookupMessage ? ( +
+

{lookupMessage}

+ {lookupMessage === "This phone number is not registered on the platform." && ( +
+ {!inviteUrl ? ( + + ) : ( +
+

+ {inviteUrl} +

+
+ + +
+
+ )} +
+ )} +
+ ) : ( + <> + + {callError && ( +

{callError}

+ )} + + )} + + )} +
+
+ ) +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..4985a9c --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,17 @@ +import { createClient, SupabaseClient } from "@supabase/supabase-js" + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || "" +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "" + +let client: SupabaseClient | null = null + +if (supabaseUrl && supabaseAnonKey) { + client = createClient(supabaseUrl, supabaseAnonKey) +} + +export function getSupabase(): SupabaseClient { + if (!client) { + throw new Error("Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.") + } + return client +}